diff --git a/.env.example b/.env.example
index 4044ea2..f32fc11 100644
--- a/.env.example
+++ b/.env.example
@@ -7,3 +7,9 @@ SILLYHOME_HISTORY_DAYS=14
SILLYHOME_MIN_TRAINING_POINTS=24
SILLYHOME_RETRAIN_STALE_HOURS=24
SILLYHOME_RECONCILE_INTERVAL_SECONDS=900
+SILLYHOME_MIN_BEHAVIOR_ACTIONS=3
+SILLYHOME_PREDICTION_CONFIDENCE=0.82
+SILLYHOME_PREDICTION_WINDOW_MINUTES=30
+SILLYHOME_PREDICTION_INTERVAL_SECONDS=60
+SILLYHOME_EXECUTION_COOLDOWN_SECONDS=900
+SILLYHOME_TIMEZONE=Europe/Berlin
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index aacadba..bd1fc56 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,13 +1,21 @@
# 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
- Lokal-first und datensparsam; keine Cloudpflicht.
-- Trennung von Datenintegration, Trainingspipeline, Vorhersageservice und Erklärungsschicht.
-- Standardintegration über MQTT und Home Assistant WebSocket plus REST.
-- Schnittstellen über FastAPI und OpenAI-kompatible Endpunkte.
-- Langzeitdaten in PostgreSQL und TimescaleDB; Vektoren für semantische Suche optional.
-- Deployment über Docker Compose; Kubernetes optional für erweiterte Betriebsgrößen.
+- Trennung von Datenintegration, Kontextzuordnung, Verhaltenslernen,
+ Vorhersage und Aktorausführung.
+- Logbook-basierte Herkunftserkennung; bekannte Automationen und eigene
+ Schaltungen werden nicht als Nutzerhandlungen trainiert.
+- 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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4dd2505..05915c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 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
- 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
diff --git a/Dockerfile b/Dockerfile
index 6a77ea2..3d46374 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,7 +9,13 @@ ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations \
SILLYHOME_HISTORY_DAYS=14 \
SILLYHOME_MIN_TRAINING_POINTS=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
diff --git a/README.md b/README.md
index b0575b1..585e139 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,11 @@ Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant.
## Reifegrad
-Die aktuelle Entwicklungslinie ist aktor-zentriert: Nutzer konfigurieren nur
-noch Home-Assistant-Aktuatoren. SillyHome Next findet dazu passende numerische
-Sensoren und Kontext-Entities, zeigt Evidenz und Review-Bedarf an und hält
-passende Modelle lokal und autonom aktuell.
+Die aktuelle Entwicklungslinie ist vollständig aktor-zentriert: Nutzer wählen
+nur Home-Assistant-Aktuatoren aus. SillyHome Next findet Sensoren, Zustände und
+Kontext automatisch, wertet die vorhandene Historie aus und hält passende
+lokale Modelle autonom aktuell. Es gibt keinen Regel-, Trigger-, Sensor- oder
+YAML-Konfigurationsschritt.
## 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.
@@ -16,7 +17,7 @@ TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltens
- Home Assistant und Sensoren/Aktoren verstehen
- Historie auswerten und Gewohnheiten erkennen
- 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
- 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/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow
-- `POST http://127.0.0.1:8000/v1/actuators` - Aktuator registrieren, Sensorzuordnung prüfen und Modell-Lebenszyklus starten
+- `POST http://127.0.0.1:8000/v1/actuators` - Aktor freigeben; Kontextzuordnung und Modell-Lebenszyklus starten automatisch
+- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/evaluate` - Shadow-Vorhersage aktualisieren
+- `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen
- `POST http://127.0.0.1:8000/v1/actuators/reconciliation/run` - globale Reconciliation manuell anstoßen
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen
-- `POST http://127.0.0.1:8000/v1/automations/proposals` - sicheren Entwurf anlegen
Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.
@@ -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_TOKEN` – Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten
- `SILLYHOME_MODEL_STORE` – Verzeichnis für persistierte Modell-Metadaten
-- `SILLYHOME_AUTOMATION_STORE` – Verzeichnis für Automation-Entwürfe
-- `SILLYHOME_ACTUATOR_STORE` – Verzeichnis für persistente Aktuator-Zuordnungen, Overrides und Reconciliation-Status
+- `SILLYHOME_ACTUATOR_STORE` – Verzeichnis für persistente Aktor-Zuordnungen und Reconciliation-Status
- `SILLYHOME_HISTORY_DAYS` – Trainingsfenster für HA-History (1 bis 31 Tage)
- `SILLYHOME_MIN_TRAINING_POINTS` – Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining
- `SILLYHOME_RETRAIN_STALE_HOURS` – Staleness-Grenze für automatisches Retraining
- `SILLYHOME_RECONCILE_INTERVAL_SECONDS` – Intervall für sichere periodische Reconciliation
+- `SILLYHOME_MIN_BEHAVIOR_ACTIONS` – Mindestzahl gelernter Handlungen vor einer Freigabe
+- `SILLYHOME_PREDICTION_CONFIDENCE` – Mindestkonfidenz für autonomes Schalten
+- `SILLYHOME_PREDICTION_WINDOW_MINUTES` – Zeitfenster um gelernte Handlungsmuster
+- `SILLYHOME_PREDICTION_INTERVAL_SECONDS` – Intervall für Shadow-/Aktiv-Vorhersagen
+- `SILLYHOME_EXECUTION_COOLDOWN_SECONDS` – Mindestabstand zwischen eigenen Schaltungen
+- `SILLYHOME_TIMEZONE` – lokale Zeitzone für Tages- und Wochenmuster
Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins
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`
Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingress
-geöffnet. Das Add-on nutzt die Supervisor-API nur lesend; Automation-Entwürfe werden
-lokal gespeichert und niemals automatisch ausgeführt.
+geöffnet. Dort werden ausschließlich erlaubte Aktoren ausgewählt; Kontext- und
+Lernentscheidungen erfolgen automatisch.
### Normaler Workflow
-1. Im Dashboard oder per API einen Aktuator auswählen, zum Beispiel `light.abstellkammer`.
-2. SillyHome Next bewertet passende numerische Sensoren und binäre Kontext-Entities anhand von Bereich, Gerät, Namen, Domain und `device_class`.
-3. Starke und eindeutige Zuordnungen werden automatisch genutzt; schwache oder knappe Kandidaten bleiben mit Review-Hinweis sichtbar.
-4. Manuelle Overrides haben Vorrang, bleiben persistent und überstehen Neustarts.
-5. Sobald genügend numerische HA-Historie vorhanden ist, trainiert das System automatisch ein lokales Modell pro Aktuator-Zuordnung und retrainiert es bei relevanten Datenänderungen oder Staleness.
+1. Im Dashboard einen Aktor auswählen, zum Beispiel `light.abstellkammer`.
+2. SillyHome Next bewertet automatisch Messwerte, Anwesenheit, Bewegung,
+ Bereiche, Gerätebeziehungen und weitere HA-Kontexte.
+3. Das System verwendet selbstständig die beste verfügbare Zuordnung.
+ Niedrige Sicherheit bleibt als Diagnose sichtbar, verlangt aber keine
+ manuelle Konfiguration.
+4. Sobald genügend Historie vorhanden ist, trainiert und aktualisiert das
+ System das lokale Modell automatisch.
+5. Vorhersagen laufen zunächst ausschließlich im Shadow-Modus.
+6. Erst nach ausdrücklicher Freigabe pro Aktor werden hochkonfidente,
+ erlaubte Zustände geschaltet. Eigene Schaltungen und erkannte
+ HA-Automationen werden nicht als Nutzerhandlungen zurückgelernt.
Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte
diff --git a/addon/config.yaml b/addon/config.yaml
index cc8b36c..080c736 100644
--- a/addon/config.yaml
+++ b/addon/config.yaml
@@ -1,7 +1,7 @@
name: SillyHome Next
-version: "0.4.0"
+version: "0.5.0"
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
arch:
- amd64
@@ -21,11 +21,23 @@ options:
min_training_points: 24
retrain_stale_hours: 24
reconcile_interval_seconds: 900
+ min_behavior_actions: 3
+ prediction_confidence: 0.82
+ prediction_window_minutes: 30
+ prediction_interval_seconds: 60
+ execution_cooldown_seconds: 900
+ timezone: Europe/Berlin
schema:
history_days: "int(1,31)"
min_training_points: "int(2,10000)"
retrain_stale_hours: "int(1,720)"
reconcile_interval_seconds: "int(60,86400)"
+ min_behavior_actions: "int(2,100)"
+ prediction_confidence: "float(0.5,0.99)"
+ prediction_window_minutes: "int(5,120)"
+ prediction_interval_seconds: "int(30,3600)"
+ execution_cooldown_seconds: "int(60,86400)"
+ timezone: "str"
map:
- type: addon_config
read_only: false
diff --git a/addon/run.sh b/addon/run.sh
index bd20289..3763d74 100644
--- a/addon/run.sh
+++ b/addon/run.sh
@@ -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_RETRAIN_STALE_HOURS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("retrain_stale_hours", 24))')"
export SILLYHOME_RECONCILE_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("reconcile_interval_seconds", 900))')"
+ export SILLYHOME_MIN_BEHAVIOR_ACTIONS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_behavior_actions", 3))')"
+ export SILLYHOME_PREDICTION_CONFIDENCE="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_confidence", 0.82))')"
+ export SILLYHOME_PREDICTION_WINDOW_MINUTES="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_window_minutes", 30))')"
+ export SILLYHOME_PREDICTION_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("prediction_interval_seconds", 60))')"
+ export SILLYHOME_EXECUTION_COOLDOWN_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("execution_cooldown_seconds", 900))')"
+ export SILLYHOME_TIMEZONE="$(python -c 'import json; print(json.load(open("/data/options.json")).get("timezone", "Europe/Berlin"))')"
fi
mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE"
diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py
index a923b48..7427552 100644
--- a/app/actuators/lifecycle.py
+++ b/app/actuators/lifecycle.py
@@ -13,7 +13,6 @@ from app.actuators.models import (
AssignmentSource,
LifecycleAuditEntry,
LifecycleStatus,
- ManualOverride,
ModelLifecycleState,
ReconciliationState,
model_id_for_actuator,
@@ -57,7 +56,7 @@ _STOPWORDS = frozenset(
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78
-_MAX_CONTEXT_SELECTIONS = 3
+_MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20
@@ -85,21 +84,6 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id)
- def set_override(
- self,
- actuator_entity_id: str,
- override: ManualOverride | None,
- ) -> ActuatorRecord:
- record = self._store.get(actuator_entity_id)
- updated = record.model_copy(
- update={
- "manual_override": override,
- "updated_at": datetime.now(timezone.utc),
- }
- )
- self._store.upsert(updated)
- return self.reconcile_actuator(actuator_entity_id, trigger="override")
-
def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id)
@@ -132,7 +116,7 @@ class ActuatorReconciliationService:
last_summary=(
f"{len(refreshed)} Aktuatoren geprüft, "
f"{sum(1 for record in refreshed if record.assignment.review_required)} "
- "mit Prüfbedarf."
+ "mit niedriger Zuordnungssicherheit."
),
)
self._store.save_reconciliation_state(summary)
@@ -214,7 +198,6 @@ class ActuatorReconciliationService:
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
- override=record.manual_override,
)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
@@ -225,6 +208,7 @@ class ActuatorReconciliationService:
updated = record.model_copy(
update={
"assignment": assignment,
+ "manual_override": None,
"numeric_candidates": numeric_candidates,
"context_candidates": context_candidates,
"lifecycle": lifecycle,
@@ -246,27 +230,11 @@ class ActuatorReconciliationService:
actuator: HaEntitySummary,
numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate],
- override: ManualOverride | None,
) -> AssignmentSelection:
- if override is not None:
- selected_numeric = override.numeric_entity_id
- selected_contexts = list(dict.fromkeys(override.context_entity_ids))
- return AssignmentSelection(
- selected_numeric_entity_id=selected_numeric,
- selected_context_entity_ids=selected_contexts,
- source=AssignmentSource.MANUAL,
- confidence=1.0 if selected_numeric else 0.6,
- review_required=False,
- reason=(
- "Manuelle Zuordnung überschreibt die automatische Heuristik dauerhaft."
- ),
- )
-
top_numeric = numeric_candidates[0] if numeric_candidates else None
top_contexts = [
candidate.entity_id
for candidate in context_candidates
- if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
if top_numeric is None:
return AssignmentSelection(
@@ -275,7 +243,10 @@ class ActuatorReconciliationService:
source=AssignmentSource.NONE,
confidence=0.0,
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(
@@ -285,9 +256,9 @@ class ActuatorReconciliationService:
confidence=top_numeric.confidence,
review_required=not top_numeric.auto_accepted,
reason=(
- "Automatisch akzeptiert."
+ "Kontext automatisch und eindeutig zugeordnet."
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.",
now=now,
)
- if assignment.review_required and assignment.source is not AssignmentSource.MANUAL:
- return self._archive_state(
- lifecycle,
- "Zuordnung ist nicht eindeutig; Modell wartet auf Review.",
- now=now,
- status=LifecycleStatus.REVIEW_REQUIRED,
- )
-
sensor_id = assignment.selected_numeric_entity_id
series = self._read_history(sensor_id, now)
points = series.points if series is not None else []
@@ -327,7 +290,7 @@ class ActuatorReconciliationService:
f"{len(points)} von mindestens {self._settings.min_training_points} "
f"Messpunkten für {sensor_id} vorhanden."
),
- "next_action": "Mehr Historie sammeln und Reconciliation erneut ausführen.",
+ "next_action": "Historie wird automatisch weiter gesammelt.",
"last_history_point_count": len(points),
}
),
@@ -369,7 +332,7 @@ class ActuatorReconciliationService:
"last_history_signature": signature,
"last_history_point_count": len(points),
"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",
@@ -384,8 +347,8 @@ class ActuatorReconciliationService:
"last_reconciled_at": now,
"last_history_signature": signature,
"last_history_point_count": len(points),
- "reason": "Modell ist aktuell und passt zur bestätigten Sensorzuordnung.",
- "next_action": "Auf neue Historie oder Staleness warten.",
+ "reason": "Modell ist aktuell und passt zur automatischen Kontextzuordnung.",
+ "next_action": "Neue Historie automatisch auswerten.",
}
),
action="kept",
@@ -423,7 +386,7 @@ class ActuatorReconciliationService:
"status": status,
"last_reconciled_at": now,
"reason": reason,
- "next_action": "Review oder neue Zuordnung erforderlich.",
+ "next_action": "Bei neuen Home-Assistant-Daten automatisch erneut zuordnen.",
}
),
action="archived",
diff --git a/app/actuators/models.py b/app/actuators/models.py
index 8f61716..a926825 100644
--- a/app/actuators/models.py
+++ b/app/actuators/models.py
@@ -25,6 +25,18 @@ class LifecycleStatus(StrEnum):
ARCHIVED = "archived"
+class BehaviorMode(StrEnum):
+ SHADOW = "shadow"
+ ACTIVE = "active"
+ PAUSED = "paused"
+
+
+class BehaviorStatus(StrEnum):
+ COLLECTING = "collecting"
+ TRAINED = "trained"
+ BLOCKED = "blocked"
+
+
class AssignmentCandidate(BaseModel):
entity_id: str
domain: str
@@ -71,10 +83,49 @@ class ModelLifecycleState(BaseModel):
last_history_signature: str | None = None
last_history_point_count: int = Field(default=0, ge=0)
reason: str = "Noch keine Trainingsdaten ausgewertet."
- next_action: str = "Aktuator auswählen und Zuordnung prüfen."
+ next_action: str = "Aktor auswählen; Kontext und Historie werden automatisch geprüft."
audit: list[LifecycleAuditEntry] = Field(default_factory=list)
+class BehaviorPattern(BaseModel):
+ target_state: str = Field(min_length=1, max_length=100)
+ minute_of_day: int = Field(ge=0, le=1439)
+ weekday: int = Field(ge=0, le=6)
+ context_states: dict[str, str] = Field(default_factory=dict)
+ source: str = Field(default="observed", max_length=40)
+ weight: float = Field(default=1.0, ge=0.1, le=1.0)
+ observed_at: datetime
+
+
+class BehaviorPrediction(BaseModel):
+ target_state: str
+ confidence: float = Field(ge=0.0, le=1.0)
+ generated_at: datetime
+ reason: str
+ matching_patterns: int = Field(default=0, ge=0)
+ executed: bool = False
+
+
+class ExecutionEvent(BaseModel):
+ target_state: str
+ executed_at: datetime
+
+
+class BehaviorState(BaseModel):
+ mode: BehaviorMode = BehaviorMode.SHADOW
+ status: BehaviorStatus = BehaviorStatus.COLLECTING
+ approved_at: datetime | None = None
+ sample_count: int = Field(default=0, ge=0)
+ high_confidence_sample_count: int = Field(default=0, ge=0)
+ patterns: list[BehaviorPattern] = Field(default_factory=list)
+ prediction: BehaviorPrediction | None = None
+ last_trained_at: datetime | None = None
+ last_evaluated_at: datetime | None = None
+ last_executed_at: datetime | None = None
+ execution_events: list[ExecutionEvent] = Field(default_factory=list)
+ reason: str = "Historische Aktorhandlungen werden analysiert."
+
+
class ActuatorRecord(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True
@@ -85,6 +136,7 @@ class ActuatorRecord(BaseModel):
numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list)
context_candidates: list[AssignmentCandidate] = Field(default_factory=list)
lifecycle: ModelLifecycleState
+ behavior: BehaviorState = Field(default_factory=BehaviorState)
class ReconciliationState(BaseModel):
diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py
index 0ca5803..d4b672e 100644
--- a/app/api/v1/actuators.py
+++ b/app/api/v1/actuators.py
@@ -4,8 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService
-from app.actuators.models import ActuatorRecord, ManualOverride, ReconciliationState
+from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore
+from app.behavior.engine import BehaviorEngine
from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole
from app.ha.models import HaEntitySummary
@@ -19,11 +20,8 @@ class ConfigureActuatorRequest(BaseModel):
enabled: bool = True
-class OverrideRequest(BaseModel):
- numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
- context_entity_ids: list[str] = Field(default_factory=list)
- note: str | None = Field(default=None, max_length=300)
- clear: bool = False
+class ActivationRequest(BaseModel):
+ active: bool
@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)
def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord:
try:
- return _service(request).configure_actuator(
+ record = _service(request).configure_actuator(
payload.actuator_entity_id,
enabled=payload.enabled,
)
+ _behavior(request).train(record.actuator_entity_id)
+ return _behavior(request).evaluate(record.actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -65,34 +65,44 @@ def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id)
-@router.post("/{actuator_entity_id}/override", response_model=ActuatorRecord)
-def set_override(
- actuator_entity_id: str,
- payload: OverrideRequest,
- request: Request,
-) -> ActuatorRecord:
- override = None if payload.clear else ManualOverride(
- numeric_entity_id=payload.numeric_entity_id,
- context_entity_ids=payload.context_entity_ids,
- note=payload.note,
- )
- try:
- return _service(request).set_override(actuator_entity_id, override)
- except KeyError as exc:
- raise HTTPException(status_code=404, detail=str(exc)) from exc
-
-
@router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord)
def reconcile_actuator(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
try:
- return _service(request).reconcile_actuator(actuator_entity_id, trigger="manual")
+ _service(request).reconcile_actuator(actuator_entity_id, trigger="manual")
+ _behavior(request).train(actuator_entity_id)
+ return _behavior(request).evaluate(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+@router.post("/{actuator_entity_id}/evaluate", response_model=ActuatorRecord)
+def evaluate_actuator(
+ actuator_entity_id: str,
+ request: Request,
+) -> ActuatorRecord:
+ try:
+ return _behavior(request).evaluate(actuator_entity_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
+
+@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
+def set_activation(
+ actuator_entity_id: str,
+ payload: ActivationRequest,
+ request: Request,
+) -> ActuatorRecord:
+ try:
+ return _behavior(request).set_active(actuator_entity_id, active=payload.active)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+
@router.get("/reconciliation/state", response_model=ReconciliationState)
def get_reconciliation_state(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
@@ -109,7 +119,10 @@ def run_reconciliation(
request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState:
- return _service(request).reconcile_all(trigger=trigger)
+ state = _service(request).reconcile_all(trigger=trigger)
+ _behavior(request).train_all()
+ _behavior(request).evaluate_all()
+ return state
def _service(request: Request) -> ActuatorReconciliationService:
@@ -120,3 +133,13 @@ def _service(request: Request) -> ActuatorReconciliationService:
detail="Actuator-Reconciliation nicht initialisiert.",
)
return service
+
+
+def _behavior(request: Request) -> BehaviorEngine:
+ engine = getattr(request.app.state, "behavior_engine", None)
+ if not isinstance(engine, BehaviorEngine):
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Verhaltenslernen ist nicht initialisiert.",
+ )
+ return engine
diff --git a/app/behavior/__init__.py b/app/behavior/__init__.py
new file mode 100644
index 0000000..693a85e
--- /dev/null
+++ b/app/behavior/__init__.py
@@ -0,0 +1 @@
+"""Learning and prediction for actuator behavior."""
diff --git a/app/behavior/engine.py b/app/behavior/engine.py
new file mode 100644
index 0000000..40145f1
--- /dev/null
+++ b/app/behavior/engine.py
@@ -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)
diff --git a/app/config.py b/app/config.py
index 74c5709..2bfcec5 100644
--- a/app/config.py
+++ b/app/config.py
@@ -15,6 +15,12 @@ class Settings:
min_training_points: int = 24
retrain_stale_hours: int = 24
reconcile_interval_seconds: int = 900
+ min_behavior_actions: int = 3
+ prediction_confidence: float = 0.82
+ prediction_window_minutes: int = 30
+ prediction_interval_seconds: int = 60
+ execution_cooldown_seconds: int = 900
+ timezone: str = "Europe/Berlin"
@property
def ha_configured(self) -> bool:
@@ -34,4 +40,19 @@ def load_settings() -> Settings:
reconcile_interval_seconds=max(
60, int(os.getenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "900"))
),
+ min_behavior_actions=max(2, int(os.getenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "3"))),
+ prediction_confidence=max(
+ 0.5,
+ min(0.99, float(os.getenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.82"))),
+ ),
+ prediction_window_minutes=max(
+ 5, min(120, int(os.getenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "30")))
+ ),
+ prediction_interval_seconds=max(
+ 30, int(os.getenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "60"))
+ ),
+ execution_cooldown_seconds=max(
+ 60, int(os.getenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "900"))
+ ),
+ timezone=os.getenv("SILLYHOME_TIMEZONE", "Europe/Berlin"),
)
diff --git a/app/ha/client.py b/app/ha/client.py
index 283fead..c423685 100644
--- a/app/ha/client.py
+++ b/app/ha/client.py
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from datetime import datetime
import json
import re
+from typing import Any
from urllib.parse import quote
import requests
@@ -19,6 +20,7 @@ from app.ha.exceptions import (
logger = logging.getLogger(__name__)
_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
@@ -84,6 +86,44 @@ class HaClient:
)
return payload
+ def get_logbook(
+ self,
+ entity_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ ) -> list[object]:
+ self._validate_period([entity_id], start_time, end_time)
+ start = quote(start_time.isoformat(), safe=":+")
+ payload = self._get_json(
+ f"/api/logbook/{start}",
+ params={
+ "entity": entity_id,
+ "end_time": end_time.isoformat(),
+ },
+ )
+ if not isinstance(payload, list):
+ raise HaUnexpectedPayloadError(
+ "Logbook-Antwort von Home Assistant hat unerwartetes Format."
+ )
+ return payload
+
+ def call_service(
+ self,
+ domain: str,
+ service: str,
+ service_data: dict[str, object],
+ ) -> list[object]:
+ if not _SERVICE_PART_PATTERN.fullmatch(domain):
+ raise ValueError("Ungültige Service-Domain.")
+ if not _SERVICE_PART_PATTERN.fullmatch(service):
+ raise ValueError("Ungültiger Service-Name.")
+ payload = self._post_json(f"/api/services/{domain}/{service}", service_data)
+ if not isinstance(payload, list):
+ raise HaUnexpectedPayloadError(
+ "Service-Antwort von Home Assistant hat unerwartetes Format."
+ )
+ return payload
+
def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
if not entity_ids:
return {}
@@ -153,6 +193,36 @@ class HaClient:
return payload
+ def _post_json(self, path: str, payload: Any) -> object:
+ try:
+ response = self._session.post(
+ f"{self._settings.url.rstrip('/')}{path}",
+ json=payload,
+ timeout=self._settings.timeout_seconds,
+ )
+ except requests.Timeout as exc:
+ raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
+ except requests.RequestException as exc:
+ raise HaHttpError(
+ getattr(getattr(exc, "response", None), "status_code", 502),
+ "Netzwerkfehler beim Zugriff auf Home Assistant.",
+ ) from exc
+ if response.status_code in (401, 403):
+ raise HaAuthError(
+ response.status_code,
+ "Authentifizierung bei Home Assistant fehlgeschlagen.",
+ )
+ try:
+ response.raise_for_status()
+ except requests.HTTPError as exc:
+ raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
+ try:
+ return response.json()
+ except ValueError as exc:
+ raise HaUnexpectedPayloadError(
+ "Antwort von Home Assistant ist kein gültiges JSON."
+ ) from exc
+
def _post_text(self, path: str, payload: dict[str, str]) -> str:
try:
response = self._session.post(
@@ -179,6 +249,25 @@ class HaClient:
raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc
return response.text
+ @staticmethod
+ def _validate_period(
+ entity_ids: list[str],
+ start_time: datetime,
+ end_time: datetime,
+ ) -> None:
+ if not entity_ids:
+ raise ValueError("Mindestens eine entity_id ist erforderlich.")
+ if len(entity_ids) > 100:
+ raise ValueError("Es können höchstens 100 Entities abgefragt werden.")
+ if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
+ raise ValueError("entity_id enthält ein ungültiges Format.")
+ if start_time.tzinfo is None or end_time.tzinfo is None:
+ raise ValueError("start_time und end_time müssen eine Zeitzone enthalten.")
+ if end_time <= start_time:
+ raise ValueError("end_time muss nach start_time liegen.")
+ if (end_time - start_time).total_seconds() > _MAX_HISTORY_SECONDS:
+ raise ValueError("History-Abfragen sind auf 31 Tage begrenzt.")
+
def _metadata_template(entity_ids: list[str]) -> str:
ids = json.dumps(entity_ids, ensure_ascii=True)
diff --git a/app/ha/history.py b/app/ha/history.py
index 5c4d744..a8fd9f4 100644
--- a/app/ha/history.py
+++ b/app/ha/history.py
@@ -18,6 +18,25 @@ class EntityHistorySeries(BaseModel):
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]:
if not isinstance(payload, list):
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)
+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:
entity_id: str | None = None
points: list[NumericHistoryPoint] = []
@@ -89,3 +170,9 @@ def _parse_timestamp(value: object) -> datetime:
if parsed.tzinfo is None:
raise HaUnexpectedPayloadError("History-Zeitstempel muss eine Zeitzone enthalten.")
return parsed
+
+
+def _optional_string(value: object) -> str | None:
+ if value is None or value == "":
+ return None
+ return str(value)
diff --git a/app/ha/models.py b/app/ha/models.py
index 168b5ba..183082a 100644
--- a/app/ha/models.py
+++ b/app/ha/models.py
@@ -14,6 +14,7 @@ class HaState(BaseModel):
class HaEntitySummary(BaseModel):
entity_id: str
domain: str
+ state: str | None = None
state_class: str | None = None
device_class: str | None = None
unit_of_measurement: str | None = None
diff --git a/app/ha/reader.py b/app/ha/reader.py
index 5f82653..0d85334 100644
--- a/app/ha/reader.py
+++ b/app/ha/reader.py
@@ -9,7 +9,14 @@ from app.ha.exceptions import HaClientError
from app.ha.client import HaClient
from app.ha.discovery import DiscoveredEntity, discover_entities
-from app.ha.history import EntityHistorySeries, normalize_history_payload
+from app.ha.history import (
+ EntityHistorySeries,
+ LogbookEntry,
+ StateHistorySeries,
+ normalize_history_payload,
+ normalize_logbook_payload,
+ normalize_state_history_payload,
+)
from app.ha.models import HaEntitySummary
logger = logging.getLogger(__name__)
@@ -45,6 +52,7 @@ class HaReader:
HaEntitySummary(
entity_id=entity_id,
domain=domain,
+ state=_optional_str(item.get("state")),
state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
@@ -77,6 +85,32 @@ class HaReader:
payload = self._client.get_history(entity_ids, start_time, end_time)
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:
if value is None or value == "":
diff --git a/app/main.py b/app/main.py
index 80df088..d019e72 100644
--- a/app/main.py
+++ b/app/main.py
@@ -12,8 +12,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
from app.api.v1.actuators import router as actuators_router
from app.api.v1.entities import router as entities_router
-from app.api.v1.automations import router as automations_router
-from app.automations.store import AutomationStore
+from app.behavior.engine import BehaviorEngine
from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings
@@ -27,13 +26,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings
client: HaClient | None = None
reconcile_task: asyncio.Task[None] | None = None
+ prediction_task: asyncio.Task[None] | None = None
app.state.registry = ModelRegistry(settings.model_store)
- app.state.automation_store = AutomationStore(settings.automation_store)
app.state.actuator_store = ActuatorStore(settings.actuator_store)
if hasattr(app.state, "ha_reader"):
del app.state.ha_reader
if hasattr(app.state, "actuator_service"):
del app.state.actuator_service
+ if hasattr(app.state, "behavior_engine"):
+ del app.state.behavior_engine
if settings.ha_configured:
client = HaClient(
settings=HaClientSettings(
@@ -48,8 +49,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
registry=app.state.registry,
settings=settings,
)
+ app.state.behavior_engine = BehaviorEngine(
+ ha_reader=app.state.ha_reader,
+ store=app.state.actuator_store,
+ settings=settings,
+ )
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
+ await asyncio.to_thread(app.state.behavior_engine.train_all)
+ await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
+ prediction_task = asyncio.create_task(_periodic_prediction(app))
try:
yield
finally:
@@ -57,6 +66,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
reconcile_task.cancel()
with suppress(asyncio.CancelledError):
await reconcile_task
+ if prediction_task is not None:
+ prediction_task.cancel()
+ with suppress(asyncio.CancelledError):
+ await prediction_task
if client is not None:
client.close()
@@ -64,13 +77,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
- version="0.4.0",
+ version="0.5.0",
lifespan=lifespan,
)
app.state.settings = load_settings()
register_exception_handlers(app)
app.include_router(entities_router)
-app.include_router(automations_router)
app.include_router(actuators_router)
init_ml_routes(app, model_store=app.state.settings.model_store)
@@ -95,3 +107,15 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
if not isinstance(service, ActuatorReconciliationService):
continue
await asyncio.to_thread(service.reconcile_all, "scheduled")
+ engine = getattr(app.state, "behavior_engine", None)
+ if isinstance(engine, BehaviorEngine):
+ await asyncio.to_thread(engine.train_all)
+
+
+async def _periodic_prediction(app: FastAPI) -> None:
+ while True:
+ await asyncio.sleep(app.state.settings.prediction_interval_seconds)
+ engine = getattr(app.state, "behavior_engine", None)
+ if not isinstance(engine, BehaviorEngine):
+ continue
+ await asyncio.to_thread(engine.evaluate_all)
diff --git a/app/static/index.html b/app/static/index.html
index c461205..243b29b 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -7,9 +7,9 @@
SillyHome Next
- Aktuator-zentrierte Home-Assistant-Analyse mit nachvollziehbarer Sensorzuordnung und kontrolliertem Modell-Lebenszyklus.
- Sicherheitsmodus: SillyHome führt niemals selbst Aktor-Services aus. Automationen bleiben manuell freizugebende YAML-Entwürfe.
+ Du wählst nur die Aktoren. SillyHome findet Kontext, lernt Gewohnheiten und trifft Vorhersagen im Shadow-Modus.
+ Geschaltet wird erst nach deiner ausdrücklichen Freigabe pro Aktor.
Systemstatus
Prüfung läuft ...
-
-
+
- Aktuator wählen
+ Aktor freigeben
+ Nach der Auswahl analysiert SillyHome automatisch passende Sensoren, Zustände und Historie.
-
- Noch kein Aktuator konfiguriert.
+
+ Noch kein Aktor ausgewählt.
- Konfigurierte Aktuatoren
+ Ausgewählte Aktoren
Noch nicht geladen.
- Zuordnung und Modellstatus
- Einen konfigurierten Aktuator auswählen.
-
-
-
- Automation-Entwurf
- Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.
-
-
-
-
+ Automatisch erkannter Lernkontext
+ Wähle einen Aktor aus der Liste.
diff --git a/docker-compose.yml b/docker-compose.yml
index dffc3a6..be78d82 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,6 +14,12 @@ services:
SILLYHOME_MIN_TRAINING_POINTS: 24
SILLYHOME_RETRAIN_STALE_HOURS: 24
SILLYHOME_RECONCILE_INTERVAL_SECONDS: 900
+ SILLYHOME_MIN_BEHAVIOR_ACTIONS: 3
+ SILLYHOME_PREDICTION_CONFIDENCE: 0.82
+ SILLYHOME_PREDICTION_WINDOW_MINUTES: 30
+ SILLYHOME_PREDICTION_INTERVAL_SECONDS: 60
+ SILLYHOME_EXECUTION_COOLDOWN_SECONDS: 900
+ SILLYHOME_TIMEZONE: Europe/Berlin
volumes:
- model-data:/app/data/models
- automation-data:/app/data/automations
diff --git a/docs/automations.md b/docs/automations.md
index f462691..b8f1ed1 100644
--- a/docs/automations.md
+++ b/docs/automations.md
@@ -1,14 +1,6 @@
-# Automation-Vorschläge
+# Keine manuell erzeugten Automationen
-SillyHome Next führt Automationen niemals automatisch aus. Der Workflow ist:
-
-1. Vorschlag als `draft` erstellen.
-2. Inhalt und Ziel-Entity prüfen.
-3. Mit aktueller Revision explizit freigeben oder ablehnen.
-4. Nur freigegebene Vorschläge als Home-Assistant-YAML exportieren.
-5. Das YAML außerhalb von SillyHome Next in Home Assistant importieren.
-
-Erlaubt sind numerische Sensor-Trigger und Aktionsdienste aus den Domains
-`light`, `switch`, `climate`, `fan` und `cover`. Shell-Kommandos, Skripte und
-beliebige Service-Domains werden abgewiesen. Eine einmal getroffene Entscheidung
-kann nicht überschrieben werden; Änderungen benötigen einen neuen Vorschlag.
+Seit `v0.5.0` erstellt SillyHome Next keine YAML-Automationen und bietet keinen
+Regel- oder Trigger-Editor mehr an. Der produktive Ablauf besteht aus
+Aktorauswahl, automatischem Verhaltenslernen, Shadow-Vorhersage und einer
+separaten Ausführungsfreigabe pro Aktor.
diff --git a/docs/ml_api.md b/docs/ml_api.md
index 9fcbce0..c416dfd 100644
--- a/docs/ml_api.md
+++ b/docs/ml_api.md
@@ -178,9 +178,10 @@ Listet unterstützte Aktuatoren mit angereicherter HA-Metadatenbasis.
### `POST /v1/actuators`
-Registriert einen Aktuator, ermittelt passende numerische Sensoren und
-Kontext-Entities, trainiert bei ausreichender History automatisch ein Modell und
-liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück.
+Registriert einen Aktor. Das System ermittelt passende Messwerte und
+Kontext-Entities vollständig automatisch, trainiert bei ausreichender Historie
+ein Modell und liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zur
+Diagnose zurück.
**Request**
```json
@@ -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`
Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife
ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home
Assistant.
+### `POST /v1/actuators/{actuator_entity_id}/evaluate`
+
+Erstellt aus aktuellem Kontext eine neue Shadow- oder Aktiv-Vorhersage. Im
+Shadow-Modus wird niemals geschaltet.
+
+### `POST /v1/actuators/{actuator_entity_id}/activation`
+
+```json
+{"active": true}
+```
+
+Aktiviert autonomes Schalten erst nach ausreichendem Training und nur für
+erlaubte Aktor-Domains. Mit `false` wird der Aktor sofort wieder in den
+Shadow-Modus versetzt.
+
## Betrieb
-Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktuator-,
-Override- und Reconciliation-Zustände liegen atomisch in
+Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktor- und
+Reconciliation-Zustände liegen atomisch in
`SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`,
`RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API
sollte nur in einem vertrauenswürdigen Netz oder hinter einem
diff --git a/docs/ml_training.md b/docs/ml_training.md
index 1c91df5..7b07282 100644
--- a/docs/ml_training.md
+++ b/docs/ml_training.md
@@ -1,92 +1,51 @@
-# ML Training- und Evaluations-Workflow
+# Verhaltenslernen und Vorhersage
-SillyHome Next trainiert ein lokales statistisches Baseline-Modell pro Sensor
-und Merkmal. Es benötigt keine Cloud und keine externe ML-Laufzeit.
+Seit `v0.5.0` ist der produktive Lernpfad aktor-zentriert. Nutzer wählen nur
+einen Aktor; Sensoren, Kontext und Modelle werden automatisch verwaltet.
-Seit `v0.4.0` ist der bevorzugte Weg aktor-zentriert: ein bestätigter Aktuator
-wird mit einem numerischen Primärsensor verknüpft, die Historie dieses Sensors
-wird automatisch geladen und in ein deterministisches Artefakt überführt.
+## Datengrundlage
-## 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
-Home-Assistant-History. Das Trainingsmerkmal heißt dabei immer `value`.
-Binäre Kontextsensoren bleiben Kontext und werden nicht als numerische Samples
-missverstanden.
+Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen erhalten das
+höchste Gewicht. Erkannte Automations- und Script-Aktionen werden verworfen.
+Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das
+Shadow-Modell ergänzen, reichen allein aber nicht zur Aktivierung.
-## 2. Statistisches Artefakt erzeugen
+## Modell
-```python
-store = FeatureStore()
-store.add(FeatureVector(sensor_id="sensor.kitchen", values={"temperature": 21.0}))
-pipeline = TrainingPipeline(store)
-artifact = pipeline.run("my_artifact")
-pipeline.export("my_artifact")
-```
+Das lokale Modell speichert pro beobachteter Handlung:
-`TrainingPipeline.run(...)` berechnet für jedes numerische Merkmal:
+- Zielzustand
+- lokale Tageszeit
+- Wochentag
+- Kontextzustände
+- Herkunft und Gewicht
-- Stichprobenzahl
-- Mittelwert und Standardabweichung
-- Minimum und Maximum
-- linearen Trend mit Steigung und Achsenabschnitt
+Eine Vorhersage bewertet zeitliche Nähe, Wochentagsmuster und aktuellen
+Kontext. Mehrere passende historische Handlungen erhöhen die Confidence.
-Die nächste Vorhersage kombiniert den letzten beobachteten Wert mit der
-trainierten Trendsteigung. Die Confidence berücksichtigt Datenmenge und
-Stabilität.
+## Betriebsstufen
-## 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
-evaluator = Evaluator(pipeline)
-report = evaluator.evaluate(artifact.artifact_id, validation_samples)
-```
+Die Aktivierung verlangt genügend eindeutig einem Benutzer zugeordnete
+Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
+`light`, `switch`, `fan`, `humidifier` und `cover`.
-Der Report enthält echte numerische Vergleichsmetriken:
-- `artifact_id`
-- `sample_size`
-- `mae` (Mean Absolute Error)
-- `rmse` (Root Mean Squared Error)
-- `coverage` für den Anteil auswertbarer Merkmale
+## Schutzmechanismen
-## 4. Modell registrieren
-
-Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifact)` bereitgestellt werden. Die ML-Serving-API stellt es unter `/ml/predict` und `/ml/batch` zur Verfügung.
-
-## 5. Retraining ausführen
-
-`RetrainingService.retrain(...)` führt genau einen Trainingslauf aus und ersetzt
-ein vorhandenes Artefakt mit derselben ID atomisch in der Registry:
-
-```python
-service = RetrainingService(registry)
-result = service.retrain("home-model", vectors)
-```
-
-Scheduler, Cronjobs oder Home-Assistant-Automationen können alternativ die
-zustandslose Funktion `retrain_model(registry, artifact_id, vectors)` aufrufen.
-Der Service startet bewusst keinen eigenen Hintergrundprozess. Über
-`POST /ml/retrain` kann derselbe Ablauf per API angestoßen werden.
-
-## 6. Autonomer Lebenszyklus
-
-Der `ActuatorReconciliationService` verwaltet pro konfiguriertem Aktuator:
-
-- die automatische Sensor- und Kontextzuordnung mit Score, Confidence und Evidenz
-- persistente manuelle Overrides
-- den Modellstatus (`trained`, `pending_history`, `review_required`, `archived`, ...)
-- ein Audit-Protokoll mit Gründen für Training, Retraining oder Archivierung
-
-Retraining erfolgt nur, wenn:
-
-- genügend nutzbare numerische Historie vorliegt
-- die aktuelle Zuordnung eindeutig oder manuell bestätigt ist
-- die Historie sich materiell verändert hat oder das Modell als stale gilt
-
-## Hinweise
-- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
-- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
-- Nur endliche numerische Werte werden trainiert.
-- `coverage` bleibt im Bereich 0 bis 1.
+- explizite Freigabe pro Aktor
+- konfigurierbare Mindestkonfidenz
+- Cooldown zwischen Schaltungen
+- keine Ausführung bei bereits erreichtem Zielzustand
+- keine Ausführung unbekannter Zustände oder riskanter Domains
+- eigene Schaltungen werden beim nächsten Training herausgefiltert
+- bekannte Automation-/Script-Aktionen werden nicht als Nutzerverhalten gelernt
diff --git a/pyproject.toml b/pyproject.toml
index b6f876f..5d5435d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
-version = "0.4.0"
+version = "0.5.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
dependencies = [
diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py
index d577163..cf29a52 100644
--- a/tests/actuators/test_lifecycle.py
+++ b/tests/actuators/test_lifecycle.py
@@ -5,7 +5,6 @@ from pathlib import Path
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import (
- AssignmentSource,
LifecycleStatus,
ManualOverride,
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
-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)
entities = [
HaEntitySummary(
@@ -182,10 +181,11 @@ def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: P
record = service.configure_actuator("switch.garage_pump")
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)
entities = [
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),
}
service = _service(tmp_path, entities, history)
- service.configure_actuator("light.abstellkammer")
-
- updated = service.set_override(
- "light.abstellkammer",
- ManualOverride(
- numeric_entity_id="sensor.abstellkammer_power",
- context_entity_ids=[],
- note="Manuelle Leistungs-Zuordnung",
- ),
+ configured = service.configure_actuator("light.abstellkammer")
+ legacy = configured.model_copy(
+ update={
+ "manual_override": ManualOverride(
+ numeric_entity_id="sensor.abstellkammer_power",
+ context_entity_ids=[],
+ note="Alte manuelle Zuordnung",
+ )
+ }
)
+ service._store.upsert(legacy)
restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer")
- assert updated.assignment.source is AssignmentSource.MANUAL
- assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
- assert record.manual_override is not None
- assert record.manual_override.numeric_entity_id == "sensor.abstellkammer_power"
+ assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
+ assert record.assignment.source.value == "automatic"
+ assert record.manual_override is None
diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py
index ef1837c..a1eac2b 100644
--- a/tests/api/test_actuators.py
+++ b/tests/api/test_actuators.py
@@ -7,10 +7,16 @@ from fastapi.testclient import TestClient
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore
+from app.behavior.engine import BehaviorEngine
from app.config import Settings
from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities
-from app.ha.history import EntityHistorySeries, NumericHistoryPoint
+from app.ha.history import (
+ EntityHistorySeries,
+ LogbookEntry,
+ NumericHistoryPoint,
+ StateHistorySeries,
+)
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import app
@@ -54,6 +60,30 @@ class FakeHaReader(HaReader):
if entity_id in self._history
]
+ def read_state_history(
+ self,
+ entity_ids: list[str],
+ start_time: datetime,
+ end_time: datetime,
+ ) -> list[StateHistorySeries]:
+ return []
+
+ def read_logbook(
+ self,
+ entity_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ ) -> list[LogbookEntry]:
+ return []
+
+ def call_service(
+ self,
+ domain: str,
+ service: str,
+ service_data: dict[str, object],
+ ) -> list[object]:
+ return []
+
def _install_service(tmp_path: Path) -> None:
entities = [
@@ -103,9 +133,14 @@ def _install_service(tmp_path: Path) -> None:
registry=app.state.registry,
settings=settings,
)
+ app.state.behavior_engine = BehaviorEngine(
+ ha_reader=app.state.ha_reader,
+ store=app.state.actuator_store,
+ settings=settings,
+ )
-def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None:
+def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
with TestClient(app) as client:
_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")
assert listed.status_code == 200
assert listed.json()[0]["lifecycle"]["status"] == "trained"
+ assert listed.json()[0]["behavior"]["mode"] == "shadow"
- override = client.post(
- "/v1/actuators/light.abstellkammer/override",
- json={
- "numeric_entity_id": "sensor.abstellkammer_illuminance",
- "context_entity_ids": ["binary_sensor.abstellkammer_motion"],
- "note": "Explizit bestaetigt",
- },
+ evaluation = client.post("/v1/actuators/light.abstellkammer/evaluate")
+ assert evaluation.status_code == 200
+
+ premature_activation = client.post(
+ "/v1/actuators/light.abstellkammer/activation",
+ json={"active": True},
)
- assert override.status_code == 200
- assert override.json()["assignment"]["source"] == "manual"
+ assert premature_activation.status_code == 409
reconciliation = client.post("/v1/actuators/reconciliation/run")
assert reconciliation.status_code == 200
assert reconciliation.json()["trained_models"] == 1
+
+ removed = client.delete("/v1/actuators/light.abstellkammer")
+ assert removed.status_code == 204
+ assert client.get("/v1/actuators").json() == []
+
+
+def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None:
+ with TestClient(app) as client:
+ _install_service(tmp_path)
+ client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
+
+ response = client.post(
+ "/v1/actuators/light.abstellkammer/override",
+ json={"numeric_entity_id": "sensor.abstellkammer_illuminance"},
+ )
+
+ assert response.status_code == 404
diff --git a/tests/api/test_automations.py b/tests/api/test_automations.py
index 1342df0..db8f094 100644
--- a/tests/api/test_automations.py
+++ b/tests/api/test_automations.py
@@ -1,59 +1,17 @@
-from pathlib import Path
-
from fastapi.testclient import TestClient
-from app.automations.store import AutomationStore
from app.main import app
-def _payload() -> dict[str, object]:
- return {
- "alias": "Licht bei Dunkelheit",
- "description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.",
- "trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
- "action": {
- "service": "light.turn_on",
- "entity_id": "light.hall",
- "data": {"brightness_pct": 40},
- },
- }
-
-
-def test_proposal_requires_explicit_approval_before_yaml(tmp_path: Path) -> None:
+def test_automation_api_is_not_exposed() -> None:
with TestClient(app) as client:
- app.state.automation_store = AutomationStore(tmp_path)
- created = client.post("/v1/automations/proposals", json=_payload())
- proposal_id = created.json()["proposal_id"]
- blocked = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
- approved = client.post(
- f"/v1/automations/proposals/{proposal_id}/approve",
- json={"expected_revision": 1},
+ response = client.post(
+ "/v1/automations/proposals",
+ json={
+ "alias": "Nicht mehr verfügbar",
+ "trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
+ "action": {"service": "light.turn_on", "entity_id": "light.hall"},
+ },
)
- exported = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
- assert created.status_code == 201
- assert created.json()["status"] == "draft"
- assert blocked.status_code == 409
- assert approved.json()["status"] == "approved"
- assert "service: light.turn_on" in exported.text
-
-def test_proposal_rejects_unsafe_service_domain(tmp_path: Path) -> None:
- payload = _payload()
- payload["action"] = {
- "service": "shell_command.run",
- "entity_id": "light.hall",
- "data": {},
- }
- with TestClient(app) as client:
- app.state.automation_store = AutomationStore(tmp_path)
- response = client.post("/v1/automations/proposals", json=payload)
- assert response.status_code == 422
-
-
-def test_proposal_requires_numeric_threshold(tmp_path: Path) -> None:
- payload = _payload()
- payload["trigger"] = {"entity_id": "sensor.hall_illuminance"}
- with TestClient(app) as client:
- app.state.automation_store = AutomationStore(tmp_path)
- response = client.post("/v1/automations/proposals", json=payload)
- assert response.status_code == 422
+ assert response.status_code == 404
diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py
index 56dc444..8040d1f 100644
--- a/tests/api/test_entities.py
+++ b/tests/api/test_entities.py
@@ -73,9 +73,10 @@ def test_entities_returns_reader_data() -> None:
assert response.status_code == 200
assert response.json() == [
{
- "entity_id": "sensor.temperature",
- "domain": "sensor",
- "state_class": None,
+ "entity_id": "sensor.temperature",
+ "domain": "sensor",
+ "state": None,
+ "state_class": None,
"device_class": None,
"unit_of_measurement": None,
"friendly_name": None,
diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py
new file mode 100644
index 0000000..8f7b5de
--- /dev/null
+++ b/tests/behavior/test_engine.py
@@ -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
diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py
index e6c6f74..3f18516 100644
--- a/tests/ha/test_ha_client.py
+++ b/tests/ha/test_ha_client.py
@@ -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(
("entity_ids", "start", "end"),
[
diff --git a/tests/ha/test_ha_reader.py b/tests/ha/test_ha_reader.py
index d9304cc..3a42b8f 100644
--- a/tests/ha/test_ha_reader.py
+++ b/tests/ha/test_ha_reader.py
@@ -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:
reader = HaReader(FakeHaClient())
@@ -63,6 +86,7 @@ def test_ha_reader_returns_summaries() -> None:
assert domains == {"sensor", "light"}
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C"
+ assert sensor.state == "21.5"
assert sensor.area_name == "Kueche"
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].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"
diff --git a/tests/ha/test_history.py b/tests/ha/test_history.py
index 744b68c..9dbb26f 100644
--- a/tests/ha/test_history.py
+++ b/tests/ha/test_history.py
@@ -5,7 +5,11 @@ from datetime import datetime, timezone
import pytest
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:
@@ -90,3 +94,46 @@ def test_normalize_history_payload_rejects_malformed_structure(payload: object)
def test_normalize_history_payload_accepts_empty_series() -> None:
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"
diff --git a/tests/test_config.py b/tests/test_config.py
index 9cf435a..01f4098 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -15,6 +15,12 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12")
monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48")
monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600")
+ monkeypatch.setenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "4")
+ monkeypatch.setenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.9")
+ monkeypatch.setenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "20")
+ monkeypatch.setenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "45")
+ monkeypatch.setenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "1200")
+ monkeypatch.setenv("SILLYHOME_TIMEZONE", "Europe/Berlin")
settings = load_settings()
@@ -27,4 +33,10 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
assert settings.min_training_points == 12
assert settings.retrain_stale_hours == 48
assert settings.reconcile_interval_seconds == 600
+ assert settings.min_behavior_actions == 4
+ assert settings.prediction_confidence == 0.9
+ assert settings.prediction_window_minutes == 20
+ assert settings.prediction_interval_seconds == 45
+ assert settings.execution_cooldown_seconds == 1200
+ assert settings.timezone == "Europe/Berlin"
assert settings.ha_configured
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index 094810d..5f3599c 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -9,4 +9,7 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200
assert "SillyHome Next" in response.text
- assert "Automation-Entwurf" in response.text
+ assert "Aktor freigeben" in response.text
+ assert "ausdrücklichen Freigabe pro Aktor" in response.text
+ assert "Automation-Entwurf" not in response.text
+ assert "Manuelle Overrides" not in response.text