Compare commits

..

7 Commits

36 changed files with 1691 additions and 505 deletions

View File

@@ -7,3 +7,9 @@ SILLYHOME_HISTORY_DAYS=14
SILLYHOME_MIN_TRAINING_POINTS=24 SILLYHOME_MIN_TRAINING_POINTS=24
SILLYHOME_RETRAIN_STALE_HOURS=24 SILLYHOME_RETRAIN_STALE_HOURS=24
SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 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,5 +1,27 @@
# Changelog # Changelog
## 0.5.2 - 2026-06-14
- Add-on-Build invalidiert den Docker-Cache bei jeder Versionsänderung, damit
Versionsmetadaten und tatsächlich ausgelieferter Anwendungscode übereinstimmen
- Korrigierte Ingress-Oberfläche aus 0.5.1 dadurch erstmals zuverlässig ausgeliefert
## 0.5.1 - 2026-06-14
- Technische Modell-, Intervall- und Sicherheitsparameter aus der normalen
Home-Assistant-Add-on-Konfiguration entfernt; sichere Standardwerte bleiben aktiv
- Ingress um einen klaren Ablauf mit Aktorauswahl, Beobachtungsphase und späterer
Ausführungsfreigabe ergänzt
- Bedienelemente und Diagnosen in verständlicher Alltagssprache erklärt
## 0.5.0 - 2026-06-14
- Ingress auf reine Aktorauswahl, automatischen Lernstatus und Vorhersagen reduziert
- 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 ## 0.4.0 - 2026-06-13
- Aktuator-zentrierte Einrichtung: Nutzer wählen nur noch Aktuatoren, Sensoren werden deterministisch gefunden und bewertet - 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 - Persistente automatische und manuelle Sensorzuordnungen mit Evidenz, Confidence, Review-Gating und Neustart-Sicherheit

View File

@@ -9,7 +9,13 @@ ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations \
SILLYHOME_HISTORY_DAYS=14 \ SILLYHOME_HISTORY_DAYS=14 \
SILLYHOME_MIN_TRAINING_POINTS=24 \ SILLYHOME_MIN_TRAINING_POINTS=24 \
SILLYHOME_RETRAIN_STALE_HOURS=24 \ SILLYHOME_RETRAIN_STALE_HOURS=24 \
SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 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

View File

@@ -4,10 +4,11 @@ Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant.
## Reifegrad ## Reifegrad
Die aktuelle Entwicklungslinie ist aktor-zentriert: Nutzer konfigurieren nur Die aktuelle Entwicklungslinie ist vollständig aktor-zentriert: Nutzer wählen
noch Home-Assistant-Aktuatoren. SillyHome Next findet dazu passende numerische nur Home-Assistant-Aktuatoren aus. SillyHome Next findet Sensoren, Zustände und
Sensoren und Kontext-Entities, zeigt Evidenz und Review-Bedarf an und hält Kontext automatisch, wertet die vorhandene Historie aus und hält passende
passende Modelle lokal und autonom aktuell. lokale Modelle autonom aktuell. Es gibt keinen Regel-, Trigger-, Sensor- oder
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.
@@ -16,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,12 +47,13 @@ uvicorn app.main:app --reload
- `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 - `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
- `POST http://127.0.0.1:8000/v1/actuators` - Aktuator registrieren, Sensorzuordnung prüfen und Modell-Lebenszyklus starten - `POST http://127.0.0.1:8000/v1/actuators` - 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 - `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`.
@@ -70,12 +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_ACTUATOR_STORE` Verzeichnis für persistente Aktuator-Zuordnungen, Overrides und Reconciliation-Status
- `SILLYHOME_HISTORY_DAYS` Trainingsfenster für HA-History (1 bis 31 Tage) - `SILLYHOME_HISTORY_DAYS` Trainingsfenster für HA-History (1 bis 31 Tage)
- `SILLYHOME_MIN_TRAINING_POINTS` Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining - `SILLYHOME_MIN_TRAINING_POINTS` Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining
- `SILLYHOME_RETRAIN_STALE_HOURS` Staleness-Grenze für automatisches Retraining - `SILLYHOME_RETRAIN_STALE_HOURS` Staleness-Grenze für automatisches Retraining
- `SILLYHOME_RECONCILE_INTERVAL_SECONDS` Intervall für sichere periodische Reconciliation - `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.
@@ -88,15 +95,22 @@ 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 ### Normaler Workflow
1. Im Dashboard oder per API einen Aktuator auswählen, zum Beispiel `light.abstellkammer`. 1. Im Dashboard einen Aktor auswählen, zum Beispiel `light.abstellkammer`.
2. SillyHome Next bewertet passende numerische Sensoren und binäre Kontext-Entities anhand von Bereich, Gerät, Namen, Domain und `device_class`. 2. SillyHome Next bewertet automatisch Messwerte, Anwesenheit, Bewegung,
3. Starke und eindeutige Zuordnungen werden automatisch genutzt; schwache oder knappe Kandidaten bleiben mit Review-Hinweis sichtbar. Bereiche, Gerätebeziehungen und weitere HA-Kontexte.
4. Manuelle Overrides haben Vorrang, bleiben persistent und überstehen Neustarts. 3. Das System verwendet selbstständig die beste verfügbare Zuordnung.
5. Sobald genügend numerische HA-Historie vorhanden ist, trainiert das System automatisch ein lokales Modell pro Aktuator-Zuordnung und retrainiert es bei relevanten Datenänderungen oder Staleness. 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** Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte

View File

@@ -4,13 +4,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
# The add-on version changes for every release. Copying its config before the
# clone makes Docker invalidate the application layer instead of reusing old code.
COPY config.yaml /tmp/addon-config.yaml
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git \ && apt-get install -y --no-install-recommends git \
&& git clone --depth 1 --branch main \ && git clone --depth 1 --branch main \
http://192.168.6.31:3000/pino/sillyhome-next.git /app \ http://192.168.6.31:3000/pino/sillyhome-next.git /app \
&& python -m pip install --upgrade pip \ && python -m pip install --upgrade pip \
&& python -m pip install /app \ && python -m pip install /app \
&& rm -rf /var/lib/apt/lists/* /app/.git && rm -rf /var/lib/apt/lists/* /app/.git /tmp/addon-config.yaml
COPY run.sh /run.sh COPY run.sh /run.sh
RUN chmod 0755 /run.sh RUN chmod 0755 /run.sh

View File

@@ -1,7 +1,7 @@
name: SillyHome Next name: SillyHome Next
version: "0.4.0" version: "0.5.2"
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
@@ -16,16 +16,6 @@ panel_admin: true
homeassistant_api: true homeassistant_api: true
hassio_api: false hassio_api: false
auth_api: false auth_api: false
options:
history_days: 14
min_training_points: 24
retrain_stale_hours: 24
reconcile_interval_seconds: 900
schema:
history_days: "int(1,31)"
min_training_points: "int(2,10000)"
retrain_stale_hours: "int(1,720)"
reconcile_interval_seconds: "int(60,86400)"
map: map:
- type: addon_config - type: addon_config
read_only: false read_only: false

View File

@@ -12,6 +12,12 @@ if [ -f /data/options.json ]; then
export SILLYHOME_MIN_TRAINING_POINTS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_training_points", 24))')" 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_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_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 fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE" mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"

View File

@@ -13,7 +13,6 @@ from app.actuators.models import (
AssignmentSource, AssignmentSource,
LifecycleAuditEntry, LifecycleAuditEntry,
LifecycleStatus, LifecycleStatus,
ManualOverride,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
model_id_for_actuator, model_id_for_actuator,
@@ -57,7 +56,7 @@ _STOPWORDS = frozenset(
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78 _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_MAX_CONTEXT_SELECTIONS = 3 _MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20 _AUDIT_LIMIT = 20
@@ -85,21 +84,6 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord: def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id) return self._store.get(actuator_entity_id)
def set_override(
self,
actuator_entity_id: str,
override: ManualOverride | None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
updated = record.model_copy(
update={
"manual_override": override,
"updated_at": datetime.now(timezone.utc),
}
)
self._store.upsert(updated)
return self.reconcile_actuator(actuator_entity_id, trigger="override")
def delete_actuator(self, actuator_entity_id: str) -> None: def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id) model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id) self._registry.archive(model_id)
@@ -132,7 +116,7 @@ class ActuatorReconciliationService:
last_summary=( last_summary=(
f"{len(refreshed)} Aktuatoren geprüft, " f"{len(refreshed)} Aktuatoren geprüft, "
f"{sum(1 for record in refreshed if record.assignment.review_required)} " f"{sum(1 for record in refreshed if record.assignment.review_required)} "
"mit Prüfbedarf." "mit niedriger Zuordnungssicherheit."
), ),
) )
self._store.save_reconciliation_state(summary) self._store.save_reconciliation_state(summary)
@@ -214,7 +198,6 @@ class ActuatorReconciliationService:
actuator=actuator, actuator=actuator,
numeric_candidates=numeric_candidates, numeric_candidates=numeric_candidates,
context_candidates=context_candidates, context_candidates=context_candidates,
override=record.manual_override,
) )
lifecycle = self._reconcile_lifecycle( lifecycle = self._reconcile_lifecycle(
actuator=actuator, actuator=actuator,
@@ -225,6 +208,7 @@ class ActuatorReconciliationService:
updated = record.model_copy( updated = record.model_copy(
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": None,
"numeric_candidates": numeric_candidates, "numeric_candidates": numeric_candidates,
"context_candidates": context_candidates, "context_candidates": context_candidates,
"lifecycle": lifecycle, "lifecycle": lifecycle,
@@ -246,27 +230,11 @@ class ActuatorReconciliationService:
actuator: HaEntitySummary, actuator: HaEntitySummary,
numeric_candidates: list[AssignmentCandidate], numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate], context_candidates: list[AssignmentCandidate],
override: ManualOverride | None,
) -> AssignmentSelection: ) -> AssignmentSelection:
if override is not None:
selected_numeric = override.numeric_entity_id
selected_contexts = list(dict.fromkeys(override.context_entity_ids))
return AssignmentSelection(
selected_numeric_entity_id=selected_numeric,
selected_context_entity_ids=selected_contexts,
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_numeric else 0.6,
review_required=False,
reason=(
"Manuelle Zuordnung überschreibt die automatische Heuristik dauerhaft."
),
)
top_numeric = numeric_candidates[0] if numeric_candidates else None top_numeric = numeric_candidates[0] if numeric_candidates else None
top_contexts = [ top_contexts = [
candidate.entity_id candidate.entity_id
for candidate in context_candidates for candidate in context_candidates
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS] ][: _MAX_CONTEXT_SELECTIONS]
if top_numeric is None: if top_numeric is None:
return AssignmentSelection( return AssignmentSelection(
@@ -275,7 +243,10 @@ class ActuatorReconciliationService:
source=AssignmentSource.NONE, source=AssignmentSource.NONE,
confidence=0.0, confidence=0.0,
review_required=True, review_required=True,
reason=f"Kein numerischer Sensor konnte für {display_name(actuator)} bestimmt werden.", reason=(
f"Für {display_name(actuator)} ist noch kein nutzbarer numerischer "
"Kontext verfügbar. Die Zuordnung wird automatisch erneut geprüft."
),
) )
return AssignmentSelection( return AssignmentSelection(
@@ -285,9 +256,9 @@ class ActuatorReconciliationService:
confidence=top_numeric.confidence, confidence=top_numeric.confidence,
review_required=not top_numeric.auto_accepted, review_required=not top_numeric.auto_accepted,
reason=( reason=(
"Automatisch akzeptiert." "Kontext automatisch und eindeutig zugeordnet."
if top_numeric.auto_accepted if top_numeric.auto_accepted
else "Top-Kandidat gefunden, aber Zuordnung ist noch nicht eindeutig genug." else "Besten verfügbaren Kontext automatisch mit niedriger Sicherheit zugeordnet."
), ),
) )
@@ -306,14 +277,6 @@ class ActuatorReconciliationService:
"Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.", "Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.",
now=now, now=now,
) )
if assignment.review_required and assignment.source is not AssignmentSource.MANUAL:
return self._archive_state(
lifecycle,
"Zuordnung ist nicht eindeutig; Modell wartet auf Review.",
now=now,
status=LifecycleStatus.REVIEW_REQUIRED,
)
sensor_id = assignment.selected_numeric_entity_id sensor_id = assignment.selected_numeric_entity_id
series = self._read_history(sensor_id, now) series = self._read_history(sensor_id, now)
points = series.points if series is not None else [] points = series.points if series is not None else []
@@ -327,7 +290,7 @@ class ActuatorReconciliationService:
f"{len(points)} von mindestens {self._settings.min_training_points} " f"{len(points)} von mindestens {self._settings.min_training_points} "
f"Messpunkten für {sensor_id} vorhanden." f"Messpunkten für {sensor_id} vorhanden."
), ),
"next_action": "Mehr Historie sammeln und Reconciliation erneut ausführen.", "next_action": "Historie wird automatisch weiter gesammelt.",
"last_history_point_count": len(points), "last_history_point_count": len(points),
} }
), ),
@@ -369,7 +332,7 @@ class ActuatorReconciliationService:
"last_history_signature": signature, "last_history_signature": signature,
"last_history_point_count": len(points), "last_history_point_count": len(points),
"reason": retrain_reason, "reason": retrain_reason,
"next_action": "Automatisch überwachen und bei neuen Daten neu trainieren.", "next_action": "Neue Daten automatisch überwachen und nachtrainieren.",
} }
), ),
action="retrained" if result.replaced else "trained", action="retrained" if result.replaced else "trained",
@@ -384,8 +347,8 @@ class ActuatorReconciliationService:
"last_reconciled_at": now, "last_reconciled_at": now,
"last_history_signature": signature, "last_history_signature": signature,
"last_history_point_count": len(points), "last_history_point_count": len(points),
"reason": "Modell ist aktuell und passt zur bestätigten Sensorzuordnung.", "reason": "Modell ist aktuell und passt zur automatischen Kontextzuordnung.",
"next_action": "Auf neue Historie oder Staleness warten.", "next_action": "Neue Historie automatisch auswerten.",
} }
), ),
action="kept", action="kept",
@@ -423,7 +386,7 @@ class ActuatorReconciliationService:
"status": status, "status": status,
"last_reconciled_at": now, "last_reconciled_at": now,
"reason": reason, "reason": reason,
"next_action": "Review oder neue Zuordnung erforderlich.", "next_action": "Bei neuen Home-Assistant-Daten automatisch erneut zuordnen.",
} }
), ),
action="archived", action="archived",

View File

@@ -25,6 +25,18 @@ class LifecycleStatus(StrEnum):
ARCHIVED = "archived" ARCHIVED = "archived"
class BehaviorMode(StrEnum):
SHADOW = "shadow"
ACTIVE = "active"
PAUSED = "paused"
class BehaviorStatus(StrEnum):
COLLECTING = "collecting"
TRAINED = "trained"
BLOCKED = "blocked"
class AssignmentCandidate(BaseModel): class AssignmentCandidate(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -71,10 +83,49 @@ class ModelLifecycleState(BaseModel):
last_history_signature: str | None = None last_history_signature: str | None = None
last_history_point_count: int = Field(default=0, ge=0) last_history_point_count: int = Field(default=0, ge=0)
reason: str = "Noch keine Trainingsdaten ausgewertet." reason: str = "Noch keine Trainingsdaten ausgewertet."
next_action: str = "Aktuator auswählen und Zuordnung prüfen." next_action: str = "Aktor auswählen; Kontext und Historie werden automatisch geprüft."
audit: list[LifecycleAuditEntry] = Field(default_factory=list) 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): class ActuatorRecord(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True enabled: bool = True
@@ -85,6 +136,7 @@ class ActuatorRecord(BaseModel):
numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list) numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list)
context_candidates: list[AssignmentCandidate] = Field(default_factory=list) context_candidates: list[AssignmentCandidate] = Field(default_factory=list)
lifecycle: ModelLifecycleState lifecycle: ModelLifecycleState
behavior: BehaviorState = Field(default_factory=BehaviorState)
class ReconciliationState(BaseModel): class ReconciliationState(BaseModel):

View File

@@ -4,8 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ManualOverride, ReconciliationState from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole from app.ha.discovery import EntityRole
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
@@ -19,11 +20,8 @@ class ConfigureActuatorRequest(BaseModel):
enabled: bool = True enabled: bool = True
class OverrideRequest(BaseModel): class ActivationRequest(BaseModel):
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") active: bool
context_entity_ids: list[str] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=300)
clear: bool = False
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
@@ -44,10 +42,12 @@ def list_configured(request: Request) -> list[ActuatorRecord]:
@router.post("", response_model=ActuatorRecord, status_code=201) @router.post("", response_model=ActuatorRecord, status_code=201)
def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord: def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord:
try: try:
return _service(request).configure_actuator( record = _service(request).configure_actuator(
payload.actuator_entity_id, payload.actuator_entity_id,
enabled=payload.enabled, enabled=payload.enabled,
) )
_behavior(request).train(record.actuator_entity_id)
return _behavior(request).evaluate(record.actuator_entity_id)
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -65,34 +65,44 @@ def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id) _service(request).delete_actuator(actuator_entity_id)
@router.post("/{actuator_entity_id}/override", response_model=ActuatorRecord)
def set_override(
actuator_entity_id: str,
payload: OverrideRequest,
request: Request,
) -> ActuatorRecord:
override = None if payload.clear else ManualOverride(
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
try:
return _service(request).set_override(actuator_entity_id, override)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord)
def reconcile_actuator( def reconcile_actuator(
actuator_entity_id: str, actuator_entity_id: str,
request: Request, request: Request,
) -> ActuatorRecord: ) -> ActuatorRecord:
try: try:
return _service(request).reconcile_actuator(actuator_entity_id, trigger="manual") _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: except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from 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) @router.get("/reconciliation/state", response_model=ReconciliationState)
def get_reconciliation_state(request: Request) -> ReconciliationState: def get_reconciliation_state(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)
@@ -109,7 +119,10 @@ def run_reconciliation(
request: Request, request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"), trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState: ) -> ReconciliationState:
return _service(request).reconcile_all(trigger=trigger) state = _service(request).reconcile_all(trigger=trigger)
_behavior(request).train_all()
_behavior(request).evaluate_all()
return state
def _service(request: Request) -> ActuatorReconciliationService: def _service(request: Request) -> ActuatorReconciliationService:
@@ -120,3 +133,13 @@ def _service(request: Request) -> ActuatorReconciliationService:
detail="Actuator-Reconciliation nicht initialisiert.", detail="Actuator-Reconciliation nicht initialisiert.",
) )
return service 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

@@ -15,6 +15,12 @@ class Settings:
min_training_points: int = 24 min_training_points: int = 24
retrain_stale_hours: int = 24 retrain_stale_hours: int = 24
reconcile_interval_seconds: int = 900 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:
@@ -34,4 +40,19 @@ def load_settings() -> Settings:
reconcile_interval_seconds=max( reconcile_interval_seconds=max(
60, int(os.getenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "900")) 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

@@ -5,6 +5,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import json import json
import re import re
from typing import Any
from urllib.parse import quote from urllib.parse import quote
import requests import requests
@@ -19,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
@@ -84,6 +86,44 @@ 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]]: def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
if not entity_ids: if not entity_ids:
return {} return {}
@@ -153,6 +193,36 @@ class HaClient:
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: def _post_text(self, path: str, payload: dict[str, str]) -> str:
try: try:
response = self._session.post( response = self._session.post(
@@ -179,6 +249,25 @@ class HaClient:
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
return response.text 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: def _metadata_template(entity_ids: list[str]) -> str:
ids = json.dumps(entity_ids, ensure_ascii=True) ids = json.dumps(entity_ids, ensure_ascii=True)

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

View File

@@ -9,7 +9,14 @@ 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__) logger = logging.getLogger(__name__)
@@ -45,6 +52,7 @@ class HaReader:
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")),
@@ -77,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

@@ -12,8 +12,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router 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
@@ -27,13 +26,15 @@ 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 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) 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"): if hasattr(app.state, "actuator_service"):
del 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(
@@ -48,8 +49,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
registry=app.state.registry, registry=app.state.registry,
settings=settings, 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.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)) reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
prediction_task = asyncio.create_task(_periodic_prediction(app))
try: try:
yield yield
finally: finally:
@@ -57,6 +66,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task.cancel() reconcile_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await reconcile_task 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()
@@ -64,13 +77,12 @@ 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.4.0", version="0.5.2",
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) 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)
@@ -95,3 +107,15 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):
continue continue
await asyncio.to_thread(service.reconcile_all, "scheduled") 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

@@ -7,97 +7,133 @@
<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,h3 { 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(320px,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; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
.step p { margin:5px 0; }
.ok { color: #66dfa9; } .ok { color: #66dfa9; }
.warn { color: #f3c969; } .warn { color: #f3c969; }
.bad { color: #ff8f8f; } .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; 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; vertical-align: top; } td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; } ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid #e8b34b; padding-left: 10px; } .notice { border-left: 4px solid #66dfa9; padding-left: 10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:8px; } .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; } .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; } .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>Aktuator-zentrierte Home-Assistant-Analyse mit nachvollziehbarer Sensorzuordnung und kontrolliertem Modell-Lebenszyklus.</p> <p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
<p class="notice">Sicherheitsmodus: SillyHome führt niemals selbst Aktor-Services aus. Automationen bleiben manuell freizugebende YAML-Entwürfe.</p> <p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</header> </header>
<main> <main>
<section> <section class="wide">
<h2>Systemstatus</h2> <h2>So gehst du vor</h2>
<div id="status">Prüfung läuft ...</div> <div class="steps">
<div class="chips" id="status-chips"></div> <div class="step">
<button class="secondary" onclick="loadOverview()">Neu laden</button> <span class="step-number">1</span>
<button onclick="runReconciliation()">Reconciliation ausführen</button> <h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Unten im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
</div>
</section> </section>
<section> <section>
<h2>Aktuator wählen</h2> <h2>Systemstatus</h2>
<label for="actuator-select">Home-Assistant-Aktor</label> <p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
</section>
<section>
<h2>1. Gerät zum Lernen auswählen</h2>
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
<label for="actuator-select">Gerät aus Home Assistant</label>
<select id="actuator-select"></select> <select id="actuator-select"></select>
<button onclick="configureActuator()">Aktuator übernehmen</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<pre id="actuator-config-result">Noch kein Aktuator konfiguriert.</pre> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
</section> </section>
<section class="wide"> <section class="wide">
<h2>Konfigurierte Aktuatoren</h2> <h2>2. Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
<div id="configured-actuators">Noch nicht geladen.</div> <div id="configured-actuators">Noch nicht geladen.</div>
</section> </section>
<section class="wide"> <section class="wide">
<h2>Zuordnung und Modellstatus</h2> <h2>3. Lernfortschritt und Freigabe</h2>
<div id="actuator-detail">Einen konfigurierten Aktuator auswählen.</div> <p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
</section> <div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
<section class="wide">
<h2>Automation-Entwurf</h2>
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p>
<div class="grid-two">
<div><label for="alias">Name</label><input id="alias" value="Licht bei Dunkelheit"></div>
<div><label for="trigger">Trigger-Entity</label><input id="trigger" placeholder="sensor.flur_illuminance"></div>
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div>
<div><label for="service">Dienst</label><select id="service"><option>light.turn_on</option><option>light.turn_off</option><option>switch.turn_on</option><option>switch.turn_off</option></select></div>
<div><label for="target">Ziel-Entity</label><input id="target" placeholder="light.flur"></div>
</div>
<button onclick="createProposal()">Entwurf speichern</button>
<button class="secondary" onclick="loadProposals()">Entwürfe aktualisieren</button>
<div id="proposals"></div>
</section> </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; 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;
} }
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) { function statusClass(record) {
if (record.assignment.review_required) return "warn";
if (record.lifecycle.status === "trained") return "ok"; if (record.lifecycle.status === "trained") return "ok";
if (record.lifecycle.status === "review_required" || record.lifecycle.status === "invalid") return "warn"; if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
return "bad"; return "bad";
} }
function renderEvidence(evidence) { function behaviorLabel(record) {
return evidence.length ? `<ul>${evidence.map(item => `<li>${item}</li>`).join("")}</ul>` : "<span class='bad'>Keine Evidenz</span>"; 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() { async function loadOverview() {
@@ -110,57 +146,53 @@ async function loadOverview() {
api("v1/actuators/reconciliation/state"), api("v1/actuators/reconciliation/state"),
api("v1/actuators"), api("v1/actuators"),
]); ]);
status.innerHTML = `<p class="ok">API und ML bereit</p><p>Letzte Reconciliation: ${reconciliation.last_completed_at || "noch nie"}</p><p>${reconciliation.last_summary}</p>`; status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
chips.innerHTML = [ chips.innerHTML = [
`<span class="chip">Health: ${health.status}</span>`, `<span class="chip">API: ${escapeHtml(health.status)}</span>`,
`<span class="chip">ML: ${ml.status}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
`<span class="chip">Aktuatoren: ${actuators.length}</span>`, `<span class="chip">Aktoren: ${actuators.length}</span>`,
`<span class="chip">Trainierte Modelle: ${reconciliation.trained_models}</span>`, `<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
status.innerHTML = `<p class="bad">${error.message}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators(), loadProposals()]); await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select"); const select = document.getElementById("actuator-select");
try { try {
const actuators = await api("v1/actuators/discovery"); const [available, configured] = await Promise.all([
select.innerHTML = actuators.length api("v1/actuators/discovery"),
? actuators.map(entity => `<option value="${entity.entity_id}">${entity.friendly_name || entity.entity_id}${entity.area_name ? ` (${entity.area_name})` : ""}</option>`).join("") api("v1/actuators"),
: "<option value=''>Keine Aktuatoren gefunden</option>"; ]);
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) { } catch (error) {
select.innerHTML = `<option value="">${error.message}</option>`; select.innerHTML = `<option value="">${escapeHtml(error.message)}</option>`;
} }
} }
async function configureActuator() { async function configureActuator() {
const actuatorId = document.getElementById("actuator-select").value; const actuatorId = document.getElementById("actuator-select").value;
const box = document.getElementById("actuator-config-result"); const result = document.getElementById("actuator-config-result");
if (!actuatorId) return; if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ...";
try { try {
const record = await api("v1/actuators", { const record = await api("v1/actuators", {
method: "POST", method: "POST",
body: JSON.stringify({actuator_entity_id: actuatorId}), body: JSON.stringify({actuator_entity_id: actuatorId}),
}); });
currentActuatorId = record.actuator_entity_id; currentActuatorId = record.actuator_entity_id;
box.textContent = pretty(record); result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
await loadOverview(); await loadOverview();
await showActuator(record.actuator_entity_id); await showActuator(record.actuator_entity_id);
} catch (error) { } catch (error) {
box.textContent = error.message; result.textContent = error.message;
}
}
async function runReconciliation() {
try {
await api("v1/actuators/reconciliation/run", {method: "POST"});
await loadOverview();
if (currentActuatorId) await showActuator(currentActuatorId);
} catch (error) {
alert(error.message);
} }
} }
@@ -170,18 +202,22 @@ async function loadConfiguredActuators() {
const rows = await api("v1/actuators"); const rows = await api("v1/actuators");
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
<table> <table>
<tr><th>Aktuator</th><th>Numerischer Sensor</th><th>Review</th><th>Modellstatus</th><th>Letztes Training</th><th>Aktion</th></tr> <tr><th>Gerät</th><th>Lernstatus</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
${rows.map(record => ` ${rows.map(record => `
<tr> <tr>
<td>${record.actuator_entity_id}</td> <td>${escapeHtml(record.actuator_entity_id)}</td>
<td>${record.assignment.selected_numeric_entity_id || "-"}</td> <td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
<td class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nein"}</td> <td>${record.behavior.sample_count}</td>
<td class="${statusClass(record)}">${record.lifecycle.status}</td> <td>${record.behavior.prediction
<td>${record.lifecycle.last_trained_at || "-"}</td> ? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
<td><button onclick="showActuator('${record.actuator_entity_id}')">Details</button></td> : "-"}</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> </tr>
`).join("")} `).join("")}
</table>` : "<p>Keine konfigurierten Aktuatoren.</p>"; </table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
@@ -192,163 +228,94 @@ async function showActuator(actuatorId) {
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
try { try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`); const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericRows = record.numeric_candidates.map(candidate => ` const contexts = [
<tr> record.assignment.selected_numeric_entity_id,
<td>${candidate.entity_id}</td> ...record.assignment.selected_context_entity_ids,
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td> ].filter(Boolean);
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>review</span>"}</td> const evidence = [...record.numeric_candidates, ...record.context_candidates]
<td>${renderEvidence(candidate.evidence)}</td> .filter(candidate => contexts.includes(candidate.entity_id))
</tr> .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
`).join(""); .join("");
const contextRows = record.context_candidates.map(candidate => ` const prediction = record.behavior.prediction;
<tr> const activationButton = record.behavior.mode === "active"
<td>${candidate.entity_id}</td> ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td> : record.behavior.status === "trained"
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>optional</span>"}</td> ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
<td>${renderEvidence(candidate.evidence)}</td> : "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
</tr>
`).join("");
box.innerHTML = ` box.innerHTML = `
<div class="grid-two"> <div class="grid-two">
<div> <div>
<h3>Auswahl</h3> <h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p><strong>Aktuator:</strong> ${record.actuator_entity_id}</p> <p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Numerischer Sensor:</strong> ${record.assignment.selected_numeric_entity_id || "-"}</p> <p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
<p><strong>Kontext:</strong> ${record.assignment.selected_context_entity_ids.join(", ") || "-"}</p> <p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p><strong>Quelle:</strong> ${record.assignment.source}</p> <p class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
<p><strong>Review:</strong> <span class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nicht erforderlich"}</span></p> <p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
<p><strong>Begruendung:</strong> ${record.assignment.reason}</p>
</div> </div>
<div> <div>
<h3>Modell-Lebenszyklus</h3> <h3>Lernfortschritt</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${record.lifecycle.status}</span></p> <p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Letztes Training:</strong> ${record.lifecycle.last_trained_at || "-"}</p> <p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Messpunkte:</strong> ${record.lifecycle.last_history_point_count}</p> <p><strong>Davon eindeutig Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Grund:</strong> ${record.lifecycle.reason}</p> <p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Nächste Aktion:</strong> ${record.lifecycle.next_action}</p> <p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
<button onclick="reconcileActuator('${record.actuator_entity_id}')">Diesen Aktuator erneut prüfen</button> ${activationButton}
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Vorhersage jetzt prüfen</button>
</div> </div>
</div> </div>
<div class="grid-two"> <h3>Was SillyHome aktuell vorhersagt</h3>
<div> ${prediction
<h3>Manuelle Overrides</h3> ? `<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>`
<label for="override-numeric">Numerischer Sensor</label> : "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<input id="override-numeric" value="${record.manual_override?.numeric_entity_id || record.assignment.selected_numeric_entity_id || ""}"> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
<label for="override-context">Kontext-Entities (kommagetrennt)</label> ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<textarea id="override-context">${(record.manual_override?.context_entity_ids || record.assignment.selected_context_entity_ids || []).join(", ")}</textarea>
<label for="override-note">Notiz</label>
<input id="override-note" value="${record.manual_override?.note || ""}">
<button onclick="saveOverride('${record.actuator_entity_id}')">Override speichern</button>
<button class="secondary" onclick="clearOverride('${record.actuator_entity_id}')">Override löschen</button>
</div>
<div>
<h3>Audit</h3>
<pre>${pretty(record.lifecycle.audit)}</pre>
</div>
</div>
<h3>Numerische Kandidaten</h3>
${numericRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${numericRows}</table>` : "<p>Keine Kandidaten.</p>"}
<h3>Kontext-Kandidaten</h3>
${contextRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${contextRows}</table>` : "<p>Keine Kandidaten.</p>"}
`; `;
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
} }
async function reconcileActuator(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/reconcile`, {method: "POST"}); await api(`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`, {method: "POST"});
await loadOverview(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function saveOverride(actuatorId) { async function setActivation(actuatorId, active) {
const numeric = document.getElementById("override-numeric").value.trim() || null; const question = active
const contexts = document.getElementById("override-context").value ? `${actuatorId} wirklich für autonomes Lernen und Schalten freigeben?`
.split(",") : `${actuatorId} wieder in den Shadow-Modus setzen?`;
.map(item => item.trim()) if (!confirm(question)) return;
.filter(Boolean);
const note = document.getElementById("override-note").value.trim() || null;
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, { await api(`v1/actuators/${encodeURIComponent(actuatorId)}/activation`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({active}),
numeric_entity_id: numeric,
context_entity_ids: contexts,
note,
}),
}); });
await loadOverview(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function clearOverride(actuatorId) { async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, { await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
method: "POST", if (currentActuatorId === actuatorId) {
body: JSON.stringify({clear: true}), currentActuatorId = null;
}); document.getElementById("actuator-detail").textContent = "Öffne bei einem beobachteten Gerät die Details.";
}
await loadOverview(); await loadOverview();
await showActuator(actuatorId);
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
async function createProposal() {
try {
await api("v1/automations/proposals", {method: "POST", body: JSON.stringify({
alias: document.getElementById("alias").value,
description: "Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.",
trigger: {entity_id: document.getElementById("trigger").value, below: Number(document.getElementById("below").value)},
action: {service: document.getElementById("service").value, entity_id: document.getElementById("target").value, data: {}}
})});
await loadProposals();
} catch (error) {
alert(error.message);
}
}
async function decide(id, revision, action) {
try {
await api(`v1/automations/proposals/${id}/${action}`, {method: "POST", body: JSON.stringify({expected_revision: revision})});
await loadProposals();
} catch (error) {
alert(error.message);
}
}
async function loadProposals() {
const box = document.getElementById("proposals");
try {
const rows = await api("v1/automations/proposals");
box.innerHTML = rows.length ? `
<table>
<tr><th>Name</th><th>Status</th><th>Aktion</th></tr>
${rows.map(item => `
<tr>
<td>${item.alias}</td>
<td>${item.status}</td>
<td>${item.status === "draft"
? `<button onclick="decide('${item.proposal_id}',${item.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${item.proposal_id}',${item.revision},'reject')">Ablehnen</button>`
: item.status === "approved"
? `<a href="v1/automations/proposals/${item.proposal_id}/yaml">YAML laden</a>`
: "-"}</td>
</tr>
`).join("")}
</table>` : "<p>Keine Entwürfe.</p>";
} catch (error) {
box.textContent = error.message;
}
}
loadOverview(); loadOverview();
</script> </script>
</body> </body>

View File

@@ -14,6 +14,12 @@ services:
SILLYHOME_MIN_TRAINING_POINTS: 24 SILLYHOME_MIN_TRAINING_POINTS: 24
SILLYHOME_RETRAIN_STALE_HOURS: 24 SILLYHOME_RETRAIN_STALE_HOURS: 24
SILLYHOME_RECONCILE_INTERVAL_SECONDS: 900 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

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

@@ -178,9 +178,10 @@ Listet unterstützte Aktuatoren mit angereicherter HA-Metadatenbasis.
### `POST /v1/actuators` ### `POST /v1/actuators`
Registriert einen Aktuator, ermittelt passende numerische Sensoren und Registriert einen Aktor. Das System ermittelt passende Messwerte und
Kontext-Entities, trainiert bei ausreichender History automatisch ein Modell und Kontext-Entities vollständig automatisch, trainiert bei ausreichender Historie
liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück. ein Modell und liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zur
Diagnose zurück.
**Request** **Request**
```json ```json
@@ -190,21 +191,31 @@ liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück.
} }
``` ```
### `POST /v1/actuators/{actuator_entity_id}/override`
Persistiert manuelle Overrides. Diese haben Vorrang vor der automatischen
Heuristik und überstehen Neustarts.
### `POST /v1/actuators/reconciliation/run` ### `POST /v1/actuators/reconciliation/run`
Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife
ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home
Assistant. 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`. Aktuator-, Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktor- und
Override- und Reconciliation-Zustände liegen atomisch in Reconciliation-Zustände liegen atomisch in
`SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`, `SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`,
`RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API `RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API
sollte nur in einem vertrauenswürdigen Netz oder hinter einem sollte nur in einem vertrauenswürdigen Netz oder hinter einem

View File

@@ -1,92 +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.
Seit `v0.4.0` ist der bevorzugte Weg aktor-zentriert: ein bestätigter Aktuator ## Datengrundlage
wird mit einem numerischen Primärsensor verknüpft, die Historie dieses Sensors
wird automatisch geladen und in ein deterministisches Artefakt überführt.
## 1. Daten sammeln Für jeden Aktor lädt SillyHome Next:
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen. - 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
Im Normalbetrieb erzeugt die Reconciliation diese Vektoren selbst aus realer Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen erhalten das
Home-Assistant-History. Das Trainingsmerkmal heißt dabei immer `value`. höchste Gewicht. Erkannte Automations- und Script-Aktionen werden verworfen.
Binäre Kontextsensoren bleiben Kontext und werden nicht als numerische Samples Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das
missverstanden. Shadow-Modell ergänzen, reichen allein aber nicht zur Aktivierung.
## 2. Statistisches Artefakt erzeugen ## Modell
```python Das lokale Modell speichert pro beobachteter Handlung:
store = FeatureStore()
store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0}))
pipeline = TrainingPipeline(store)
artifact = pipeline.run("my_artifact")
pipeline.export("my_artifact")
```
`TrainingPipeline.run(...)` berechnet für jedes numerische Merkmal: - Zielzustand
- lokale Tageszeit
- Wochentag
- Kontextzustände
- Herkunft und Gewicht
- Stichprobenzahl Eine Vorhersage bewertet zeitliche Nähe, Wochentagsmuster und aktuellen
- Mittelwert und Standardabweichung Kontext. Mehrere passende historische Handlungen erhöhen die Confidence.
- Minimum und Maximum
- linearen Trend mit Steigung und Achsenabschnitt
Die nächste Vorhersage kombiniert den letzten beobachteten Wert mit der ## Betriebsstufen
trainierten Trendsteigung. Die Confidence berücksichtigt Datenmenge und
Stabilität.
## 3. Modell evaluieren 1. `collecting`: Noch nicht genügend Handlungen vorhanden.
2. `shadow`: Modell ist trainiert; Vorhersagen werden angezeigt, aber nicht ausgeführt.
3. `active`: Nutzer hat den Aktor ausdrücklich freigegeben.
```python Die Aktivierung verlangt genügend eindeutig einem Benutzer zugeordnete
evaluator = Evaluator(pipeline) Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
report = evaluator.evaluate(artifact.artifact_id, validation_samples) `light`, `switch`, `fan`, `humidifier` und `cover`.
```
Der Report enthält echte numerische Vergleichsmetriken: ## Schutzmechanismen
- `artifact_id`
- `sample_size`
- `mae` (Mean Absolute Error)
- `rmse` (Root Mean Squared Error)
- `coverage` für den Anteil auswertbarer Merkmale
## 4. Modell registrieren - explizite Freigabe pro Aktor
- konfigurierbare Mindestkonfidenz
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. - Cooldown zwischen Schaltungen
- keine Ausführung bei bereits erreichtem Zielzustand
## 5. Retraining ausführen - keine Ausführung unbekannter Zustände oder riskanter Domains
- eigene Schaltungen werden beim nächsten Training herausgefiltert
`RetrainingService.retrain(...)` führt genau einen Trainingslauf aus und ersetzt - bekannte Automation-/Script-Aktionen werden nicht als Nutzerverhalten gelernt
ein vorhandenes Artefakt mit derselben ID atomisch in der Registry:
```python
service = RetrainingService(registry)
result = service.retrain("home-model", vectors)
```
Scheduler, Cronjobs oder Home-Assistant-Automationen können alternativ die
zustandslose Funktion `retrain_model(registry, artifact_id, vectors)` aufrufen.
Der Service startet bewusst keinen eigenen Hintergrundprozess. Über
`POST /ml/retrain` kann derselbe Ablauf per API angestoßen werden.
## 6. Autonomer Lebenszyklus
Der `ActuatorReconciliationService` verwaltet pro konfiguriertem Aktuator:
- die automatische Sensor- und Kontextzuordnung mit Score, Confidence und Evidenz
- persistente manuelle Overrides
- den Modellstatus (`trained`, `pending_history`, `review_required`, `archived`, ...)
- ein Audit-Protokoll mit Gründen für Training, Retraining oder Archivierung
Retraining erfolgt nur, wenn:
- genügend nutzbare numerische Historie vorliegt
- die aktuelle Zuordnung eindeutig oder manuell bestätigt ist
- die Historie sich materiell verändert hat oder das Modell als stale gilt
## Hinweise
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
- Nur endliche numerische Werte werden trainiert.
- `coverage` bleibt im Bereich 0 bis 1.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.4.0" version = "0.5.2"
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

@@ -5,7 +5,6 @@ from pathlib import Path
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ( from app.actuators.models import (
AssignmentSource,
LifecycleStatus, LifecycleStatus,
ManualOverride, ManualOverride,
model_id_for_actuator, model_id_for_actuator,
@@ -142,7 +141,7 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: Path) -> None: def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -182,10 +181,11 @@ def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: P
record = service.configure_actuator("switch.garage_pump") record = service.configure_actuator("switch.garage_pump")
assert record.assignment.review_required is True assert record.assignment.review_required is True
assert record.lifecycle.status is LifecycleStatus.REVIEW_REQUIRED assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy"
assert record.lifecycle.status is LifecycleStatus.TRAINED
def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None: def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -218,21 +218,21 @@ def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None
"sensor.abstellkammer_power": _points(8, start, 30.0), "sensor.abstellkammer_power": _points(8, start, 30.0),
} }
service = _service(tmp_path, entities, history) service = _service(tmp_path, entities, history)
service.configure_actuator("light.abstellkammer") configured = service.configure_actuator("light.abstellkammer")
legacy = configured.model_copy(
updated = service.set_override( update={
"light.abstellkammer", "manual_override": ManualOverride(
ManualOverride( numeric_entity_id="sensor.abstellkammer_power",
numeric_entity_id="sensor.abstellkammer_power", context_entity_ids=[],
context_entity_ids=[], note="Alte manuelle Zuordnung",
note="Manuelle Leistungs-Zuordnung", )
), }
) )
service._store.upsert(legacy)
restarted = _service(tmp_path, entities, history) restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer") record = restarted.reconcile_actuator("light.abstellkammer")
assert updated.assignment.source is AssignmentSource.MANUAL assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power" assert record.assignment.source.value == "automatic"
assert record.manual_override is not None assert record.manual_override is None
assert record.manual_override.numeric_entity_id == "sensor.abstellkammer_power"

View File

@@ -7,10 +7,16 @@ from fastapi.testclient import TestClient
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint from app.ha.history import (
EntityHistorySeries,
LogbookEntry,
NumericHistoryPoint,
StateHistorySeries,
)
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.main import app from app.main import app
@@ -54,6 +60,30 @@ class FakeHaReader(HaReader):
if entity_id in self._history 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: def _install_service(tmp_path: Path) -> None:
entities = [ entities = [
@@ -103,9 +133,14 @@ def _install_service(tmp_path: Path) -> None:
registry=app.state.registry, registry=app.state.registry,
settings=settings, 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_overrides(tmp_path: Path) -> None: def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
@@ -118,18 +153,34 @@ def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> Non
listed = client.get("/v1/actuators") listed = client.get("/v1/actuators")
assert listed.status_code == 200 assert listed.status_code == 200
assert listed.json()[0]["lifecycle"]["status"] == "trained" assert listed.json()[0]["lifecycle"]["status"] == "trained"
assert listed.json()[0]["behavior"]["mode"] == "shadow"
override = client.post( evaluation = client.post("/v1/actuators/light.abstellkammer/evaluate")
"/v1/actuators/light.abstellkammer/override", assert evaluation.status_code == 200
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance", premature_activation = client.post(
"context_entity_ids": ["binary_sensor.abstellkammer_motion"], "/v1/actuators/light.abstellkammer/activation",
"note": "Explizit bestaetigt", json={"active": True},
},
) )
assert override.status_code == 200 assert premature_activation.status_code == 409
assert override.json()["assignment"]["source"] == "manual"
reconciliation = client.post("/v1/actuators/reconciliation/run") reconciliation = client.post("/v1/actuators/reconciliation/run")
assert reconciliation.status_code == 200 assert reconciliation.status_code == 200
assert reconciliation.json()["trained_models"] == 1 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 {
"alias": "Licht bei Dunkelheit",
"description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.",
"trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
"action": {
"service": "light.turn_on",
"entity_id": "light.hall",
"data": {"brightness_pct": 40},
},
}
def test_proposal_requires_explicit_approval_before_yaml(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
app.state.automation_store = AutomationStore(tmp_path) response = client.post(
created = client.post("/v1/automations/proposals", json=_payload()) "/v1/automations/proposals",
proposal_id = created.json()["proposal_id"] json={
blocked = client.get(f"/v1/automations/proposals/{proposal_id}/yaml") "alias": "Nicht mehr verfügbar",
approved = client.post( "trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
f"/v1/automations/proposals/{proposal_id}/approve", "action": {"service": "light.turn_on", "entity_id": "light.hall"},
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

@@ -73,9 +73,10 @@ def test_entities_returns_reader_data() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == [ assert response.json() == [
{ {
"entity_id": "sensor.temperature", "entity_id": "sensor.temperature",
"domain": "sensor", "domain": "sensor",
"state_class": None, "state": None,
"state_class": None,
"device_class": None, "device_class": None,
"unit_of_measurement": None, "unit_of_measurement": None,
"friendly_name": None, "friendly_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

@@ -108,6 +108,35 @@ def test_list_entity_metadata_calls_template_api() -> None:
} }
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

@@ -54,6 +54,29 @@ class FakeHaClient(HaClient):
} }
} }
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())
@@ -63,6 +86,7 @@ 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.area_name == "Kueche"
assert sensor.device_name == "Thermometer" assert sensor.device_name == "Thermometer"
@@ -87,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

@@ -0,0 +1,18 @@
from pathlib import Path
def test_addon_does_not_expose_internal_learning_parameters() -> None:
config = Path("addon/config.yaml").read_text(encoding="utf-8")
assert "\noptions:" not in config
assert "\nschema:" not in config
assert "prediction_confidence" not in config
assert "execution_cooldown_seconds" not in config
def test_addon_version_invalidates_application_build_layer() -> None:
dockerfile = Path("addon/Dockerfile").read_text(encoding="utf-8")
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone

View File

@@ -15,6 +15,12 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12") monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12")
monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48") monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48")
monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600") 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()
@@ -27,4 +33,10 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
assert settings.min_training_points == 12 assert settings.min_training_points == 12
assert settings.retrain_stale_hours == 48 assert settings.retrain_stale_hours == 48
assert settings.reconcile_interval_seconds == 600 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,10 @@ 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 "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" in response.text
assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text