From fa250216be2c64d7117a04e418ac51f2c7e57e55 Mon Sep 17 00:00:00 2001 From: Otto Date: Sun, 14 Jun 2026 10:37:59 +0200 Subject: [PATCH] BEHAVIOR-001: learn and predict actuator actions --- .env.example | 6 + ARCHITECTURE.md | 20 +- CHANGELOG.md | 10 + Dockerfile | 8 +- README.md | 46 ++- addon/config.yaml | 16 +- addon/run.sh | 6 + app/actuators/lifecycle.py | 65 +---- app/actuators/models.py | 54 +++- app/api/v1/actuators.py | 75 +++-- app/behavior/__init__.py | 1 + app/behavior/engine.py | 467 ++++++++++++++++++++++++++++++ app/config.py | 21 ++ app/ha/client.py | 89 ++++++ app/ha/history.py | 87 ++++++ app/ha/models.py | 1 + app/ha/reader.py | 36 ++- app/main.py | 34 ++- app/static/index.html | 323 +++++++++------------ docker-compose.yml | 6 + docs/automations.md | 18 +- docs/ml_api.md | 31 +- docs/ml_training.md | 115 +++----- pyproject.toml | 2 +- tests/actuators/test_lifecycle.py | 34 +-- tests/api/test_actuators.py | 73 ++++- tests/api/test_automations.py | 60 +--- tests/api/test_entities.py | 7 +- tests/behavior/test_engine.py | 261 +++++++++++++++++ tests/ha/test_ha_client.py | 29 ++ tests/ha/test_ha_reader.py | 36 +++ tests/ha/test_history.py | 49 +++- tests/test_config.py | 12 + tests/test_dashboard.py | 5 +- 34 files changed, 1614 insertions(+), 489 deletions(-) create mode 100644 app/behavior/__init__.py create mode 100644 app/behavior/engine.py create mode 100644 tests/behavior/test_engine.py 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..7ad94e0 --- /dev/null +++ b/app/behavior/engine.py @@ -0,0 +1,467 @@ +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]: + return [self.train(record.actuator_entity_id) for record in self._store.list()] + + 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]: + return [self.evaluate(record.actuator_entity_id) for record in self._store.list()] + + 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