From 6305f52cd2bf95ff7169ec1dcd695e1961db7681 Mon Sep 17 00:00:00 2001 From: Otto Date: Sat, 13 Jun 2026 22:45:07 +0200 Subject: [PATCH] ACT-001: actuator-first sensor lifecycle --- .env.example | 5 + CHANGELOG.md | 9 +- Dockerfile | 9 +- README.md | 24 +- addon/config.yaml | 14 +- addon/run.sh | 10 +- app/actuators/__init__.py | 27 ++ app/actuators/lifecycle.py | 607 +++++++++++++++++++++++++ app/actuators/models.py | 102 +++++ app/actuators/store.py | 116 +++++ app/api/v1/actuators.py | 122 +++++ app/config.py | 12 + app/ha/client.py | 81 ++++ app/ha/models.py | 5 + app/ha/reader.py | 25 + app/main.py | 34 +- app/ml/registry/model_registry.py | 19 + app/static/index.html | 362 +++++++++++---- docker-compose.yml | 7 + docs/ha_data.md | 12 +- docs/ml_api.md | 47 +- docs/ml_training.md | 24 + pyproject.toml | 2 +- tests/actuators/test_actuator_store.py | 18 + tests/actuators/test_lifecycle.py | 238 ++++++++++ tests/api/test_actuators.py | 135 ++++++ tests/api/test_entities.py | 5 + tests/ha/test_ha_client.py | 20 + tests/ha/test_ha_reader.py | 12 + tests/test_config.py | 10 + 30 files changed, 2010 insertions(+), 103 deletions(-) create mode 100644 app/actuators/__init__.py create mode 100644 app/actuators/lifecycle.py create mode 100644 app/actuators/models.py create mode 100644 app/actuators/store.py create mode 100644 app/api/v1/actuators.py create mode 100644 tests/actuators/test_actuator_store.py create mode 100644 tests/actuators/test_lifecycle.py create mode 100644 tests/api/test_actuators.py diff --git a/.env.example b/.env.example index 4346fe7..4044ea2 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ SILLYHOME_HA_URL=http://homeassistant.local:8123 SILLYHOME_HA_TOKEN=REPLACE_ME_WITH_LONG_LIVED_TOKEN SILLYHOME_MODEL_STORE=.model_store SILLYHOME_AUTOMATION_STORE=.automation_store +SILLYHOME_ACTUATOR_STORE=.actuator_store +SILLYHOME_HISTORY_DAYS=14 +SILLYHOME_MIN_TRAINING_POINTS=24 +SILLYHOME_RETRAIN_STALE_HOURS=24 +SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4790af9..4dd2505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## Unreleased -- Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage -- Persistenter Automation-Freigabeprozess mit sicherem YAML-Export +## 0.4.0 - 2026-06-13 +- Aktuator-zentrierte Einrichtung: Nutzer wählen nur noch Aktuatoren, Sensoren werden deterministisch gefunden und bewertet +- Persistente automatische und manuelle Sensorzuordnungen mit Evidenz, Confidence, Review-Gating und Neustart-Sicherheit +- Autonomer Modell-Lebenszyklus auf echter HA-Historie: Training, Retraining bei Staleness oder Datenänderung, Archivierung von Waisen +- Neues Dashboard und API für Aktuatorauswahl, Reconciliation, Overrides, Modellstatus und Audit-Trail +- Neue Container-/Add-on-Defaults für Aktuator-Store und periodische Reconciliation ohne zusätzliche Gerätesteuerung ## 0.2.0 - 2026-06-13 - Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern diff --git a/Dockerfile b/Dockerfile index 5bf990a..6a77ea2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ SILLYHOME_MODEL_STORE=/app/data/models -ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations +ENV SILLYHOME_AUTOMATION_STORE=/app/data/automations \ + SILLYHOME_ACTUATOR_STORE=/app/data/actuators \ + SILLYHOME_HISTORY_DAYS=14 \ + SILLYHOME_MIN_TRAINING_POINTS=24 \ + SILLYHOME_RETRAIN_STALE_HOURS=24 \ + SILLYHOME_RECONCILE_INTERVAL_SECONDS=900 WORKDIR /app @@ -15,7 +20,7 @@ COPY app ./app COPY backend ./backend RUN python -m pip install --upgrade pip && \ python -m pip install . && \ - mkdir -p /app/data/models /app/data/automations && \ + mkdir -p /app/data/models /app/data/automations /app/data/actuators && \ chown -R sillyhome:sillyhome /app/data EXPOSE 8000 diff --git a/README.md b/README.md index 8b3e8f0..b0575b1 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,10 @@ Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant. ## Reifegrad -Die aktuelle Entwicklungslinie stellt eine gehärtete technische Basis bereit: -Home-Assistant-Entities und Historie lesen, Sensoren klassifizieren, -regelbasierte Bausteine sowie ein lokal trainierbares statistisches -Baseline-Modell mit persistenter Registry, Confidence und echten -Evaluationsmetriken. +Die aktuelle Entwicklungslinie ist aktor-zentriert: Nutzer konfigurieren nur +noch Home-Assistant-Aktuatoren. SillyHome Next findet dazu passende numerische +Sensoren und Kontext-Entities, zeigt Evidenz und Review-Bedarf an und hält +passende Modelle lokal und autonom aktuell. ## Motivation TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit. @@ -46,6 +45,9 @@ uvicorn app.main:app --reload - `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities - `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities - `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen +- `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow +- `POST http://127.0.0.1:8000/v1/actuators` - Aktuator registrieren, Sensorzuordnung prüfen und Modell-Lebenszyklus starten +- `POST http://127.0.0.1:8000/v1/actuators/reconciliation/run` - globale Reconciliation manuell anstoßen - `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health - `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren - `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen @@ -69,6 +71,11 @@ dem Netz muss ein authentifizierender Reverse Proxy vorgeschaltet werden. - `SILLYHOME_HA_TOKEN` – Long-Lived Access Token eines dedizierten HA-Benutzers mit minimalen Rechten - `SILLYHOME_MODEL_STORE` – Verzeichnis für persistierte Modell-Metadaten - `SILLYHOME_AUTOMATION_STORE` – Verzeichnis für Automation-Entwürfe +- `SILLYHOME_ACTUATOR_STORE` – Verzeichnis für persistente Aktuator-Zuordnungen, Overrides und Reconciliation-Status +- `SILLYHOME_HISTORY_DAYS` – Trainingsfenster für HA-History (1 bis 31 Tage) +- `SILLYHOME_MIN_TRAINING_POINTS` – Mindestanzahl nutzbarer numerischer Messpunkte vor einem Modelltraining +- `SILLYHOME_RETRAIN_STALE_HOURS` – Staleness-Grenze für automatisches Retraining +- `SILLYHOME_RECONCILE_INTERVAL_SECONDS` – Intervall für sichere periodische Reconciliation Niemals Administrator-Tokens oder Passwörter eintragen. `.env` gehört nicht ins Versionskontrollsystem. @@ -84,6 +91,13 @@ Danach **SillyHome Next** installieren und starten. Das Dashboard wird per Ingre geöffnet. Das Add-on nutzt die Supervisor-API nur lesend; Automation-Entwürfe werden lokal gespeichert und niemals automatisch ausgeführt. +### Normaler Workflow +1. Im Dashboard oder per API einen Aktuator auswählen, zum Beispiel `light.abstellkammer`. +2. SillyHome Next bewertet passende numerische Sensoren und binäre Kontext-Entities anhand von Bereich, Gerät, Namen, Domain und `device_class`. +3. Starke und eindeutige Zuordnungen werden automatisch genutzt; schwache oder knappe Kandidaten bleiben mit Review-Hinweis sichtbar. +4. Manuelle Overrides haben Vorrang, bleiben persistent und überstehen Neustarts. +5. Sobald genügend numerische HA-Historie vorhanden ist, trainiert das System automatisch ein lokales Modell pro Aktuator-Zuordnung und retrainiert es bei relevanten Datenänderungen oder Staleness. + Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups** eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte Backup öffnen, **SillyHome Next** auswählen und wiederherstellen. Der erste produktive diff --git a/addon/config.yaml b/addon/config.yaml index c5d389f..cc8b36c 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.3.0" +version: "0.4.0" slug: sillyhome_next description: Lokale HA-Analyse, Vorhersagen und sichere Automation-Entwürfe url: http://192.168.6.31:3000/pino/sillyhome-next @@ -16,8 +16,16 @@ panel_admin: true homeassistant_api: true hassio_api: false auth_api: false -options: {} -schema: {} +options: + history_days: 14 + min_training_points: 24 + retrain_stale_hours: 24 + reconcile_interval_seconds: 900 +schema: + history_days: "int(1,31)" + min_training_points: "int(2,10000)" + retrain_stale_hours: "int(1,720)" + reconcile_interval_seconds: "int(60,86400)" map: - type: addon_config read_only: false diff --git a/addon/run.sh b/addon/run.sh index 365d824..bd20289 100644 --- a/addon/run.sh +++ b/addon/run.sh @@ -5,7 +5,15 @@ export SILLYHOME_HA_URL="${SILLYHOME_HA_URL:-http://supervisor/core}" export SILLYHOME_HA_TOKEN="${SILLYHOME_HA_TOKEN:-${SUPERVISOR_TOKEN:-}}" export SILLYHOME_MODEL_STORE=/data/models export SILLYHOME_AUTOMATION_STORE=/data/automations +export SILLYHOME_ACTUATOR_STORE=/data/actuators -mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" +if [ -f /data/options.json ]; then + export SILLYHOME_HISTORY_DAYS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("history_days", 14))')" + export SILLYHOME_MIN_TRAINING_POINTS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("min_training_points", 24))')" + export SILLYHOME_RETRAIN_STALE_HOURS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("retrain_stale_hours", 24))')" + export SILLYHOME_RECONCILE_INTERVAL_SECONDS="$(python -c 'import json; print(json.load(open("/data/options.json")).get("reconcile_interval_seconds", 900))')" +fi + +mkdir -p "$SILLYHOME_MODEL_STORE" "$SILLYHOME_AUTOMATION_STORE" "$SILLYHOME_ACTUATOR_STORE" exec uvicorn app.main:app --app-dir /app --host 0.0.0.0 --port 8000 \ --proxy-headers --forwarded-allow-ips='*' diff --git a/app/actuators/__init__.py b/app/actuators/__init__.py new file mode 100644 index 0000000..91880b5 --- /dev/null +++ b/app/actuators/__init__.py @@ -0,0 +1,27 @@ +from app.actuators.lifecycle import ( + ActuatorReconciliationService, +) +from app.actuators.models import ( + ActuatorRecord, + AssignmentCandidate, + AssignmentSelection, + LifecycleAuditEntry, + LifecycleStatus, + ManualOverride, + ReconciliationState, + model_id_for_actuator, +) +from app.actuators.store import ActuatorStore + +__all__ = [ + "ActuatorReconciliationService", + "ActuatorRecord", + "ActuatorStore", + "AssignmentCandidate", + "AssignmentSelection", + "LifecycleAuditEntry", + "LifecycleStatus", + "ManualOverride", + "ReconciliationState", + "model_id_for_actuator", +] diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py new file mode 100644 index 0000000..a923b48 --- /dev/null +++ b/app/actuators/lifecycle.py @@ -0,0 +1,607 @@ +from __future__ import annotations + +import hashlib +import logging +import re +from collections.abc import Iterable +from datetime import datetime, timedelta, timezone + +from app.actuators.models import ( + ActuatorRecord, + AssignmentCandidate, + AssignmentSelection, + AssignmentSource, + LifecycleAuditEntry, + LifecycleStatus, + ManualOverride, + ModelLifecycleState, + ReconciliationState, + model_id_for_actuator, +) +from app.actuators.store import ActuatorStore +from app.config import Settings +from app.ha.discovery import DiscoveredEntity, EntityRole +from app.ha.history import EntityHistorySeries, NumericHistoryPoint +from app.ha.models import HaEntitySummary +from app.ha.reader import HaReader +from app.ml.feature_store import FeatureVector +from app.ml.registry.model_registry import ModelRegistry +from app.ml.retraining import retrain_model +from app.ml.training import TrainedArtifact + +logger = logging.getLogger(__name__) + +_TOKEN_PATTERN = re.compile(r"[a-z0-9]+", re.IGNORECASE) +_STOPWORDS = frozenset( + { + "actuator", + "battery", + "bin", + "binary", + "brightness", + "current", + "door", + "energy", + "entity", + "humidity", + "illuminance", + "light", + "power", + "sensor", + "state", + "switch", + "temperature", + "value", + } +) +_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 +_NUMERIC_MIN_MARGIN = 0.18 +_CONTEXT_AUTO_ACCEPT_SCORE = 0.78 +_MAX_CONTEXT_SELECTIONS = 3 +_AUDIT_LIMIT = 20 + + +class ActuatorReconciliationService: + def __init__( + self, + *, + ha_reader: HaReader, + store: ActuatorStore, + registry: ModelRegistry, + settings: Settings, + ) -> None: + self._ha_reader = ha_reader + self._store = store + self._registry = registry + self._settings = settings + + def list_configured(self) -> list[ActuatorRecord]: + return self._store.list() + + def configure_actuator(self, actuator_entity_id: str, *, enabled: bool = True) -> ActuatorRecord: + self._store.configure(actuator_entity_id, enabled=enabled) + return self.reconcile_actuator(actuator_entity_id, trigger="configuration") + + def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord: + return self._store.get(actuator_entity_id) + + def set_override( + self, + actuator_entity_id: str, + override: ManualOverride | None, + ) -> ActuatorRecord: + record = self._store.get(actuator_entity_id) + updated = record.model_copy( + update={ + "manual_override": override, + "updated_at": datetime.now(timezone.utc), + } + ) + self._store.upsert(updated) + return self.reconcile_actuator(actuator_entity_id, trigger="override") + + def delete_actuator(self, actuator_entity_id: str) -> None: + model_id = model_id_for_actuator(actuator_entity_id) + self._registry.archive(model_id) + self._store.delete(actuator_entity_id) + + def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: + state = self._store.load_reconciliation_state().model_copy( + update={ + "running": True, + "last_started_at": datetime.now(timezone.utc), + "last_trigger": trigger, + } + ) + self._store.save_reconciliation_state(state) + records = self._store.list() + for record in records: + self.reconcile_actuator(record.actuator_entity_id, trigger=trigger) + self._archive_orphan_models({model_id_for_actuator(record.actuator_entity_id) for record in records}) + refreshed = self._store.list() + summary = ReconciliationState( + last_started_at=state.last_started_at, + last_completed_at=datetime.now(timezone.utc), + last_trigger=trigger, + running=False, + configured_actuators=len(refreshed), + review_required=sum(1 for record in refreshed if record.assignment.review_required), + trained_models=sum( + 1 for record in refreshed if record.lifecycle.status is LifecycleStatus.TRAINED + ), + last_summary=( + f"{len(refreshed)} Aktuatoren geprüft, " + f"{sum(1 for record in refreshed if record.assignment.review_required)} " + "mit Prüfbedarf." + ), + ) + self._store.save_reconciliation_state(summary) + return summary + + def reconcile_actuator(self, actuator_entity_id: str, trigger: str = "manual") -> ActuatorRecord: + now = datetime.now(timezone.utc) + record = self._store.get(actuator_entity_id) + entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} + discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()} + actuator = entities.get(actuator_entity_id) + descriptor = discovered.get(actuator_entity_id) + lifecycle = record.lifecycle.model_copy(update={"last_reconciled_at": now}) + + if not record.enabled: + lifecycle = self._archive_state( + lifecycle, + "Aktuator ist deaktiviert; Modell bleibt archiviert.", + now=now, + ) + updated = record.model_copy( + update={ + "assignment": AssignmentSelection( + selected_numeric_entity_id=None, + selected_context_entity_ids=[], + source=AssignmentSource.NONE, + confidence=0.0, + review_required=False, + reason="Aktuator ist deaktiviert.", + ), + "numeric_candidates": [], + "context_candidates": [], + "lifecycle": lifecycle, + "updated_at": now, + } + ) + return self._store.upsert(updated) + + if actuator is None or descriptor is None or descriptor.role is not EntityRole.ACTUATOR: + lifecycle = self._archive_state( + lifecycle, + "Aktuator ist in Home Assistant nicht mehr als Aktor vorhanden.", + now=now, + status=LifecycleStatus.ORPHANED, + ) + updated = record.model_copy( + update={ + "assignment": AssignmentSelection( + selected_numeric_entity_id=None, + selected_context_entity_ids=[], + source=AssignmentSource.NONE, + confidence=0.0, + review_required=True, + reason="Aktuator fehlt oder ist kein unterstützter Aktor mehr.", + ), + "numeric_candidates": [], + "context_candidates": [], + "lifecycle": lifecycle, + "updated_at": now, + } + ) + return self._store.upsert(updated) + + numeric_candidates = self._rank_candidates( + actuator=actuator, + candidates=_filter_candidates(entities, discovered, {EntityRole.MEASUREMENT}), + context=False, + ) + context_candidates = self._rank_candidates( + actuator=actuator, + candidates=_filter_candidates( + entities, + discovered, + {EntityRole.BINARY_CONTEXT, EntityRole.CONTEXT}, + ), + context=True, + ) + assignment = self._select_assignment( + actuator=actuator, + numeric_candidates=numeric_candidates, + context_candidates=context_candidates, + override=record.manual_override, + ) + lifecycle = self._reconcile_lifecycle( + actuator=actuator, + assignment=assignment, + lifecycle=lifecycle, + now=now, + ) + updated = record.model_copy( + update={ + "assignment": assignment, + "numeric_candidates": numeric_candidates, + "context_candidates": context_candidates, + "lifecycle": lifecycle, + "updated_at": now, + } + ) + self._store.upsert(updated) + logger.info( + "Actuator %s reconciled via %s -> %s", + actuator_entity_id, + trigger, + lifecycle.status, + ) + return updated + + def _select_assignment( + self, + *, + actuator: HaEntitySummary, + numeric_candidates: list[AssignmentCandidate], + context_candidates: list[AssignmentCandidate], + override: ManualOverride | None, + ) -> AssignmentSelection: + if override is not None: + selected_numeric = override.numeric_entity_id + selected_contexts = list(dict.fromkeys(override.context_entity_ids)) + return AssignmentSelection( + selected_numeric_entity_id=selected_numeric, + selected_context_entity_ids=selected_contexts, + source=AssignmentSource.MANUAL, + confidence=1.0 if selected_numeric else 0.6, + review_required=False, + reason=( + "Manuelle Zuordnung überschreibt die automatische Heuristik dauerhaft." + ), + ) + + top_numeric = numeric_candidates[0] if numeric_candidates else None + top_contexts = [ + candidate.entity_id + for candidate in context_candidates + if candidate.auto_accepted + ][: _MAX_CONTEXT_SELECTIONS] + if top_numeric is None: + return AssignmentSelection( + selected_numeric_entity_id=None, + selected_context_entity_ids=top_contexts, + source=AssignmentSource.NONE, + confidence=0.0, + review_required=True, + reason=f"Kein numerischer Sensor konnte für {display_name(actuator)} bestimmt werden.", + ) + + return AssignmentSelection( + selected_numeric_entity_id=top_numeric.entity_id, + selected_context_entity_ids=top_contexts, + source=AssignmentSource.AUTOMATIC, + confidence=top_numeric.confidence, + review_required=not top_numeric.auto_accepted, + reason=( + "Automatisch akzeptiert." + if top_numeric.auto_accepted + else "Top-Kandidat gefunden, aber Zuordnung ist noch nicht eindeutig genug." + ), + ) + + def _reconcile_lifecycle( + self, + *, + actuator: HaEntitySummary, + assignment: AssignmentSelection, + lifecycle: ModelLifecycleState, + now: datetime, + ) -> ModelLifecycleState: + model_id = lifecycle.model_id + if assignment.selected_numeric_entity_id is None: + return self._archive_state( + lifecycle, + "Ohne numerische Sensorzuordnung wird kein Modell aktiv gehalten.", + now=now, + ) + if assignment.review_required and assignment.source is not AssignmentSource.MANUAL: + return self._archive_state( + lifecycle, + "Zuordnung ist nicht eindeutig; Modell wartet auf Review.", + now=now, + status=LifecycleStatus.REVIEW_REQUIRED, + ) + + sensor_id = assignment.selected_numeric_entity_id + series = self._read_history(sensor_id, now) + points = series.points if series is not None else [] + if len(points) < self._settings.min_training_points: + return self._with_audit( + lifecycle.model_copy( + update={ + "status": LifecycleStatus.PENDING_HISTORY, + "last_reconciled_at": now, + "reason": ( + f"{len(points)} von mindestens {self._settings.min_training_points} " + f"Messpunkten für {sensor_id} vorhanden." + ), + "next_action": "Mehr Historie sammeln und Reconciliation erneut ausführen.", + "last_history_point_count": len(points), + } + ), + action="history_wait", + reason=( + f"Training für {display_name(actuator)} verschoben: zu wenig numerische Historie." + ), + now=now, + ) + + signature = _history_signature(sensor_id, points) + artifact = self._registry.get_optional(model_id) + needs_retrain = artifact is None + retrain_reason = "Noch kein Modell vorhanden." + if artifact is not None: + valid, reason = _artifact_valid_for_sensor(artifact, sensor_id) + if not valid: + self._registry.archive(model_id) + needs_retrain = True + retrain_reason = reason + elif lifecycle.last_history_signature != signature: + needs_retrain = True + retrain_reason = "Historie hat sich seit dem letzten Training materiell geändert." + elif lifecycle.last_trained_at is None or ( + now - lifecycle.last_trained_at + ) >= timedelta(hours=self._settings.retrain_stale_hours): + needs_retrain = True + retrain_reason = "Modell gilt als veraltet und wird präventiv neu trainiert." + + if needs_retrain: + vectors = [FeatureVector(sensor_id=sensor_id, values={"value": point.value}) for point in points] + result = retrain_model(self._registry, model_id, vectors) + return self._with_audit( + lifecycle.model_copy( + update={ + "status": LifecycleStatus.TRAINED, + "last_reconciled_at": now, + "last_trained_at": now, + "last_history_signature": signature, + "last_history_point_count": len(points), + "reason": retrain_reason, + "next_action": "Automatisch überwachen und bei neuen Daten neu trainieren.", + } + ), + action="retrained" if result.replaced else "trained", + reason=f"{retrain_reason} Modell {model_id} aktualisiert.", + now=now, + ) + + return self._with_audit( + lifecycle.model_copy( + update={ + "status": LifecycleStatus.TRAINED, + "last_reconciled_at": now, + "last_history_signature": signature, + "last_history_point_count": len(points), + "reason": "Modell ist aktuell und passt zur bestätigten Sensorzuordnung.", + "next_action": "Auf neue Historie oder Staleness warten.", + } + ), + action="kept", + reason=f"Modell {model_id} blieb unverändert.", + now=now, + ) + + def _read_history(self, sensor_id: str, now: datetime) -> EntityHistorySeries | None: + start = now - timedelta(days=self._settings.history_days) + history = list(self._ha_reader.read_history([sensor_id], start, now)) + for series in history: + if series.entity_id == sensor_id: + return series + return None + + def _archive_orphan_models(self, configured_model_ids: set[str]) -> None: + for artifact in self._registry.list_models(): + if not artifact.artifact_id.startswith("actuator."): + continue + if artifact.artifact_id not in configured_model_ids: + self._registry.archive(artifact.artifact_id) + + def _archive_state( + self, + lifecycle: ModelLifecycleState, + reason: str, + *, + now: datetime, + status: LifecycleStatus = LifecycleStatus.ARCHIVED, + ) -> ModelLifecycleState: + self._registry.archive(lifecycle.model_id) + return self._with_audit( + lifecycle.model_copy( + update={ + "status": status, + "last_reconciled_at": now, + "reason": reason, + "next_action": "Review oder neue Zuordnung erforderlich.", + } + ), + action="archived", + reason=reason, + now=now, + ) + + def _rank_candidates( + self, + *, + actuator: HaEntitySummary, + candidates: Iterable[tuple[HaEntitySummary, DiscoveredEntity]], + context: bool, + ) -> list[AssignmentCandidate]: + scored: list[AssignmentCandidate] = [] + all_scores: list[float] = [] + for entity, discovered in candidates: + score, evidence = _score_candidate(actuator, entity, discovered.role, context=context) + if score <= 0: + continue + all_scores.append(score) + scored.append( + AssignmentCandidate( + entity_id=entity.entity_id, + domain=entity.domain, + role=discovered.role, + device_class=entity.device_class, + state_class=entity.state_class, + unit_of_measurement=entity.unit_of_measurement, + friendly_name=entity.friendly_name, + area_name=entity.area_name, + device_name=entity.device_name, + score=score, + confidence=0.0, + evidence=evidence, + ) + ) + if not scored: + return [] + highest = max(all_scores) + sorted_candidates = sorted(scored, key=lambda item: (-item.score, item.entity_id)) + second_score = sorted_candidates[1].score if len(sorted_candidates) > 1 else 0.0 + for index, candidate in enumerate(sorted_candidates): + confidence = candidate.score / highest if highest else 0.0 + margin = candidate.score - second_score if index == 0 else 0.0 + auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE + auto_accepted = confidence >= auto_score and ( + context or margin >= _NUMERIC_MIN_MARGIN + ) + sorted_candidates[index] = candidate.model_copy( + update={ + "confidence": round(confidence, 4), + "auto_accepted": auto_accepted, + } + ) + return sorted_candidates + + @staticmethod + def _with_audit( + lifecycle: ModelLifecycleState, + *, + action: str, + reason: str, + now: datetime, + ) -> ModelLifecycleState: + audit = list(lifecycle.audit) + entry = LifecycleAuditEntry(at=now, action=action, reason=reason) + if not audit or audit[-1].action != action or audit[-1].reason != reason: + audit.append(entry) + if len(audit) > _AUDIT_LIMIT: + audit = audit[-_AUDIT_LIMIT:] + return lifecycle.model_copy(update={"audit": audit}) + + +def display_name(entity: HaEntitySummary) -> str: + return entity.friendly_name or entity.device_name or entity.entity_id + + +def _filter_candidates( + entities: dict[str, HaEntitySummary], + discovered: dict[str, DiscoveredEntity], + roles: set[EntityRole], +) -> list[tuple[HaEntitySummary, DiscoveredEntity]]: + result: list[tuple[HaEntitySummary, DiscoveredEntity]] = [] + for entity_id, summary in entities.items(): + candidate = discovered.get(entity_id) + if candidate is None or candidate.role not in roles: + continue + result.append((summary, candidate)) + return result + + +def _score_candidate( + actuator: HaEntitySummary, + entity: HaEntitySummary, + role: EntityRole, + *, + context: bool, +) -> tuple[float, list[str]]: + evidence: list[str] = [] + score = 0.0 + actuator_tokens = _metadata_tokens(actuator) + entity_tokens = _metadata_tokens(entity) + overlap = sorted(actuator_tokens.intersection(entity_tokens)) + if overlap: + score += min(0.4, 0.1 * len(overlap)) + evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}") + if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name: + score += 0.35 + evidence.append(f"Gleicher Bereich: {actuator.area_name}") + if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id: + score += 0.2 + evidence.append("Gleiche Home-Assistant-Geräte-ID") + if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name: + score += 0.15 + evidence.append(f"Gleicher Gerätename: {actuator.device_name}") + if actuator.friendly_name and entity.friendly_name and actuator.friendly_name == entity.friendly_name: + score += 0.1 + evidence.append("Gleicher Friendly Name") + preferred_device_classes = _preferred_device_classes(actuator.domain, context=context) + if entity.device_class in preferred_device_classes: + score += 0.2 + evidence.append(f"Passende device_class: {entity.device_class}") + if not context and entity.unit_of_measurement is not None: + score += 0.05 + evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}") + if context and role is EntityRole.BINARY_CONTEXT: + score += 0.05 + evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.") + return round(min(score, 1.0), 4), evidence + + +def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: + if context: + return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) + mapping = { + "climate": {"temperature", "humidity", "power"}, + "cover": {"illuminance", "temperature", "wind_speed"}, + "fan": {"temperature", "humidity", "power"}, + "humidifier": {"humidity", "temperature", "power"}, + "light": {"illuminance", "power", "energy"}, + "switch": {"power", "energy", "current"}, + "valve": {"temperature", "pressure", "humidity"}, + } + return frozenset(mapping.get(domain, {"power", "energy", "temperature"})) + + +def _metadata_tokens(entity: HaEntitySummary) -> set[str]: + raw_values = [ + entity.entity_id, + entity.friendly_name, + entity.area_name, + entity.device_name, + ] + tokens: set[str] = set() + for value in raw_values: + if value is None: + continue + for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): + if len(token) < 3 or token in _STOPWORDS: + continue + tokens.add(token) + return tokens + + +def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str: + digest = hashlib.sha256() + digest.update(sensor_id.encode("utf-8")) + for point in points: + digest.update(point.timestamp.isoformat().encode("utf-8")) + digest.update(f"{point.value:.6f}".encode("utf-8")) + return digest.hexdigest() + + +def _artifact_valid_for_sensor(artifact: TrainedArtifact, sensor_id: str) -> tuple[bool, str]: + if sensor_id not in artifact.supported_sensors: + return False, "Vorhandenes Modell passt nicht mehr zur aktuellen Sensorzuordnung." + feature_models = artifact.feature_models.get(sensor_id, {}) + if "value" not in feature_models: + return False, "Vorhandenes Modell enthält kein numerisches Trainingsmerkmal 'value'." + return True, "Modell ist kompatibel." diff --git a/app/actuators/models.py b/app/actuators/models.py new file mode 100644 index 0000000..8f61716 --- /dev/null +++ b/app/actuators/models.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import StrEnum + +from pydantic import BaseModel, Field + +from app.ha.discovery import EntityRole + + +class AssignmentSource(StrEnum): + NONE = "none" + AUTOMATIC = "automatic" + MANUAL = "manual" + + +class LifecycleStatus(StrEnum): + PENDING_ASSIGNMENT = "pending_assignment" + REVIEW_REQUIRED = "review_required" + PENDING_HISTORY = "pending_history" + TRAINED = "trained" + STALE = "stale" + INVALID = "invalid" + ORPHANED = "orphaned" + ARCHIVED = "archived" + + +class AssignmentCandidate(BaseModel): + entity_id: str + domain: str + role: EntityRole + device_class: str | None = None + state_class: str | None = None + unit_of_measurement: str | None = None + friendly_name: str | None = None + area_name: str | None = None + device_name: str | None = None + score: float = Field(ge=0.0) + confidence: float = Field(ge=0.0, le=1.0) + auto_accepted: bool = False + evidence: list[str] = Field(default_factory=list) + + +class AssignmentSelection(BaseModel): + selected_numeric_entity_id: str | None = None + selected_context_entity_ids: list[str] = Field(default_factory=list) + source: AssignmentSource = AssignmentSource.NONE + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + review_required: bool = True + reason: str = "Noch keine Zuordnung vorhanden." + + +class ManualOverride(BaseModel): + numeric_entity_id: str | None = None + context_entity_ids: list[str] = Field(default_factory=list) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + note: str | None = None + + +class LifecycleAuditEntry(BaseModel): + at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + action: str = Field(min_length=1, max_length=120) + reason: str = Field(min_length=1, max_length=500) + + +class ModelLifecycleState(BaseModel): + model_id: str + status: LifecycleStatus = LifecycleStatus.PENDING_ASSIGNMENT + last_reconciled_at: datetime | None = None + last_trained_at: datetime | None = None + last_history_signature: str | None = None + last_history_point_count: int = Field(default=0, ge=0) + reason: str = "Noch keine Trainingsdaten ausgewertet." + next_action: str = "Aktuator auswählen und Zuordnung prüfen." + audit: list[LifecycleAuditEntry] = Field(default_factory=list) + + +class ActuatorRecord(BaseModel): + actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") + enabled: bool = True + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + assignment: AssignmentSelection = Field(default_factory=AssignmentSelection) + manual_override: ManualOverride | None = None + numeric_candidates: list[AssignmentCandidate] = Field(default_factory=list) + context_candidates: list[AssignmentCandidate] = Field(default_factory=list) + lifecycle: ModelLifecycleState + + +class ReconciliationState(BaseModel): + last_started_at: datetime | None = None + last_completed_at: datetime | None = None + last_trigger: str | None = None + running: bool = False + configured_actuators: int = Field(default=0, ge=0) + review_required: int = Field(default=0, ge=0) + trained_models: int = Field(default=0, ge=0) + last_summary: str = "Noch keine Reconciliation ausgeführt." + + +def model_id_for_actuator(actuator_entity_id: str) -> str: + return f"actuator.{actuator_entity_id}" diff --git a/app/actuators/store.py b/app/actuators/store.py new file mode 100644 index 0000000..1b771d1 --- /dev/null +++ b/app/actuators/store.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from threading import RLock + +from app.actuators.models import ( + ActuatorRecord, + LifecycleStatus, + ModelLifecycleState, + ReconciliationState, + model_id_for_actuator, +) + + +class ActuatorStore: + def __init__(self, root: str | Path) -> None: + self._root = Path(root).resolve() + self._actuators_root = self._root / "actuators" + self._actuators_root.mkdir(parents=True, exist_ok=True) + self._lock = RLock() + self._reconciliation_state_path = self._root / "reconciliation_state.json" + + def list(self) -> list[ActuatorRecord]: + with self._lock: + return [self._load(path) for path in sorted(self._actuators_root.glob("*.json"))] + + def get(self, actuator_entity_id: str) -> ActuatorRecord: + with self._lock: + target = self._target(actuator_entity_id) + if not target.exists(): + raise KeyError("Aktuator-Konfiguration nicht gefunden.") + return self._load(target) + + def upsert(self, record: ActuatorRecord) -> ActuatorRecord: + with self._lock: + self._persist(record) + return record + + def configure(self, actuator_entity_id: str, *, enabled: bool = True) -> ActuatorRecord: + with self._lock: + target = self._target(actuator_entity_id) + if target.exists(): + record = self._load(target) + updated = record.model_copy( + update={ + "enabled": enabled, + "updated_at": datetime.now(timezone.utc), + } + ) + self._persist(updated) + return updated + record = ActuatorRecord( + actuator_entity_id=actuator_entity_id, + enabled=enabled, + lifecycle=ModelLifecycleState( + model_id=model_id_for_actuator(actuator_entity_id), + status=LifecycleStatus.PENDING_ASSIGNMENT, + ), + ) + self._persist(record) + return record + + def delete(self, actuator_entity_id: str) -> None: + with self._lock: + target = self._target(actuator_entity_id) + if target.exists(): + target.unlink() + + def load_reconciliation_state(self) -> ReconciliationState: + with self._lock: + if not self._reconciliation_state_path.exists(): + return ReconciliationState() + try: + return ReconciliationState.model_validate_json( + self._reconciliation_state_path.read_text(encoding="utf-8") + ) + except ValueError as exc: + raise ValueError("Ungültiger Reconciliation-Status.") from exc + + def save_reconciliation_state(self, state: ReconciliationState) -> ReconciliationState: + with self._lock: + self._persist_reconciliation_state(state) + return state + + def _target(self, actuator_entity_id: str) -> Path: + if "." not in actuator_entity_id: + raise ValueError("Ungültige actuator_entity_id.") + safe_name = actuator_entity_id.replace(".", "__") + return self._actuators_root / f"{safe_name}.json" + + def _persist(self, record: ActuatorRecord) -> None: + target = self._target(record.actuator_entity_id) + temporary = target.with_suffix(".json.tmp") + temporary.write_text( + json.dumps(record.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.replace(temporary, target) + + def _persist_reconciliation_state(self, state: ReconciliationState) -> None: + temporary = self._reconciliation_state_path.with_suffix(".json.tmp") + temporary.write_text( + json.dumps(state.model_dump(mode="json"), ensure_ascii=True, sort_keys=True) + "\n", + encoding="utf-8", + ) + os.replace(temporary, self._reconciliation_state_path) + + @staticmethod + def _load(path: Path) -> ActuatorRecord: + try: + return ActuatorRecord.model_validate_json(path.read_text(encoding="utf-8")) + except ValueError as exc: + raise ValueError(f"Ungültige Aktuator-Konfiguration: {path.name}") from exc diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py new file mode 100644 index 0000000..0ca5803 --- /dev/null +++ b/app/api/v1/actuators.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, Field + +from app.actuators.lifecycle import ActuatorReconciliationService +from app.actuators.models import ActuatorRecord, ManualOverride, ReconciliationState +from app.actuators.store import ActuatorStore +from app.dependencies import get_ha_reader +from app.ha.discovery import EntityRole +from app.ha.models import HaEntitySummary +from app.ha.reader import HaReader + +router = APIRouter(prefix="/v1/actuators", tags=["actuators"]) + + +class ConfigureActuatorRequest(BaseModel): + actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") + enabled: bool = True + + +class OverrideRequest(BaseModel): + numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") + context_entity_ids: list[str] = Field(default_factory=list) + note: str | None = Field(default=None, max_length=300) + clear: bool = False + + +@router.get("/discovery", response_model=list[HaEntitySummary]) +def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: + entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} + discovered = ha_reader.discover() + actuator_ids = sorted( + entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR + ) + return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] + + +@router.get("", response_model=list[ActuatorRecord]) +def list_configured(request: Request) -> list[ActuatorRecord]: + return _service(request).list_configured() + + +@router.post("", response_model=ActuatorRecord, status_code=201) +def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord: + try: + return _service(request).configure_actuator( + payload.actuator_entity_id, + enabled=payload.enabled, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/{actuator_entity_id}", response_model=ActuatorRecord) +def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord: + try: + return _service(request).get_actuator(actuator_entity_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/{actuator_entity_id}", status_code=204) +def delete_actuator(actuator_entity_id: str, request: Request) -> None: + _service(request).delete_actuator(actuator_entity_id) + + +@router.post("/{actuator_entity_id}/override", response_model=ActuatorRecord) +def set_override( + actuator_entity_id: str, + payload: OverrideRequest, + request: Request, +) -> ActuatorRecord: + override = None if payload.clear else ManualOverride( + numeric_entity_id=payload.numeric_entity_id, + context_entity_ids=payload.context_entity_ids, + note=payload.note, + ) + try: + return _service(request).set_override(actuator_entity_id, override) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord) +def reconcile_actuator( + actuator_entity_id: str, + request: Request, +) -> ActuatorRecord: + try: + return _service(request).reconcile_actuator(actuator_entity_id, trigger="manual") + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/reconciliation/state", response_model=ReconciliationState) +def get_reconciliation_state(request: Request) -> ReconciliationState: + store = getattr(request.app.state, "actuator_store", None) + if not isinstance(store, ActuatorStore): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Actuator Store nicht initialisiert.", + ) + return store.load_reconciliation_state() + + +@router.post("/reconciliation/run", response_model=ReconciliationState) +def run_reconciliation( + request: Request, + trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"), +) -> ReconciliationState: + return _service(request).reconcile_all(trigger=trigger) + + +def _service(request: Request) -> ActuatorReconciliationService: + service = getattr(request.app.state, "actuator_service", None) + if not isinstance(service, ActuatorReconciliationService): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Actuator-Reconciliation nicht initialisiert.", + ) + return service diff --git a/app/config.py b/app/config.py index 6341344..74c5709 100644 --- a/app/config.py +++ b/app/config.py @@ -10,6 +10,11 @@ class Settings: ha_token: str | None = None model_store: str = ".model_store" automation_store: str = ".automation_store" + actuator_store: str = ".actuator_store" + history_days: int = 14 + min_training_points: int = 24 + retrain_stale_hours: int = 24 + reconcile_interval_seconds: int = 900 @property def ha_configured(self) -> bool: @@ -22,4 +27,11 @@ def load_settings() -> Settings: ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"), model_store=os.getenv("SILLYHOME_MODEL_STORE", ".model_store"), automation_store=os.getenv("SILLYHOME_AUTOMATION_STORE", ".automation_store"), + actuator_store=os.getenv("SILLYHOME_ACTUATOR_STORE", ".actuator_store"), + history_days=max(1, min(31, int(os.getenv("SILLYHOME_HISTORY_DAYS", "14")))), + min_training_points=max(2, int(os.getenv("SILLYHOME_MIN_TRAINING_POINTS", "24"))), + retrain_stale_hours=max(1, int(os.getenv("SILLYHOME_RETRAIN_STALE_HOURS", "24"))), + reconcile_interval_seconds=max( + 60, int(os.getenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "900")) + ), ) diff --git a/app/ha/client.py b/app/ha/client.py index fa716eb..283fead 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging from dataclasses import dataclass from datetime import datetime +import json import re from urllib.parse import quote @@ -83,6 +84,32 @@ class HaClient: ) return payload + def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]: + if not entity_ids: + return {} + if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids): + raise ValueError("entity_id enthält ein ungültiges Format.") + template = _metadata_template(entity_ids) + rendered = self._post_text("/api/template", {"template": template}) + try: + payload = json.loads(rendered) + except json.JSONDecodeError as exc: + raise HaUnexpectedPayloadError("Entity-Metadaten konnten nicht gelesen werden.") from exc + if not isinstance(payload, list): + raise HaUnexpectedPayloadError("Entity-Metadaten haben ein unerwartetes Format.") + result: dict[str, dict[str, str | None]] = {} + for item in payload: + if not isinstance(item, dict): + raise HaUnexpectedPayloadError("Entity-Metadaten haben ein unerwartetes Format.") + entity_id = item.get("entity_id") + if not isinstance(entity_id, str) or "." not in entity_id: + raise HaUnexpectedPayloadError("Entity-Metadaten enthalten ungültige entity_id.") + result[entity_id] = { + key: _optional_string(item.get(key)) + for key in ("area_id", "area_name", "device_id", "device_name") + } + return result + def _get_json( self, path: str, @@ -125,3 +152,57 @@ class HaClient: ) from exc return payload + + def _post_text(self, path: str, payload: dict[str, str]) -> str: + try: + response = self._session.post( + f"{self._settings.url.rstrip('/')}{path}", + json=payload, + timeout=self._settings.timeout_seconds, + ) + except requests.Timeout as exc: + raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc + except requests.RequestException as exc: + raise HaHttpError( + getattr(getattr(exc, "response", None), "status_code", 502), + "Netzwerkfehler beim Zugriff auf Home Assistant.", + ) from exc + + if response.status_code in (401, 403): + raise HaAuthError( + response.status_code, + "Authentifizierung bei Home Assistant fehlgeschlagen.", + ) + try: + response.raise_for_status() + except requests.HTTPError as exc: + raise HaHttpError(response.status_code, "Home Assistant meldet einen Fehler.") from exc + return response.text + + +def _metadata_template(entity_ids: list[str]) -> str: + ids = json.dumps(entity_ids, ensure_ascii=True) + return ( + "{% set ids = " + f"{ids}" + " %}[" + "{% for entity_id in ids %}" + "{% set device = device_id(entity_id) %}" + "{{ " + "{" + "\"entity_id\": entity_id," + "\"area_id\": area_id(entity_id)," + "\"area_name\": area_name(entity_id)," + "\"device_id\": device," + "\"device_name\": device_attr(device, 'name') if device else none" + "}" + " | tojson }}" + "{% if not loop.last %},{% endif %}" + "{% endfor %}]" + ) + + +def _optional_string(value: object) -> str | None: + if value is None or value == "": + return None + return str(value) diff --git a/app/ha/models.py b/app/ha/models.py index bfb1c04..168b5ba 100644 --- a/app/ha/models.py +++ b/app/ha/models.py @@ -17,3 +17,8 @@ class HaEntitySummary(BaseModel): state_class: str | None = None device_class: str | None = None unit_of_measurement: str | None = None + friendly_name: str | None = None + area_id: str | None = None + area_name: str | None = None + device_id: str | None = None + device_name: str | None = None diff --git a/app/ha/reader.py b/app/ha/reader.py index b91b69d..5f82653 100644 --- a/app/ha/reader.py +++ b/app/ha/reader.py @@ -3,12 +3,17 @@ from __future__ import annotations from collections.abc import Sequence from datetime import datetime from typing import Any +import logging + +from app.ha.exceptions import HaClientError from app.ha.client import HaClient from app.ha.discovery import DiscoveredEntity, discover_entities from app.ha.history import EntityHistorySeries, normalize_history_payload from app.ha.models import HaEntitySummary +logger = logging.getLogger(__name__) + class HaReader: def __init__(self, client: HaClient) -> None: @@ -16,6 +21,16 @@ class HaReader: def read_entities(self) -> Sequence[HaEntitySummary]: entities = self._client.list_entities() + entity_ids = [ + raw_entity_id + for item in entities + if isinstance((raw_entity_id := item.get("entity_id")), str) and "." in raw_entity_id + ] + try: + metadata_by_entity = self._client.list_entity_metadata(entity_ids) + except (HaClientError, ValueError) as exc: + logger.warning("HA metadata enrichment skipped: %s", exc) + metadata_by_entity = {} summaries: list[HaEntitySummary] = [] for item in entities: raw_entity_id = item.get("entity_id") @@ -25,6 +40,7 @@ class HaReader: domain = entity_id.split(".", 1)[0] raw_attributes = item.get("attributes") or {} attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {} + metadata = metadata_by_entity.get(entity_id, {}) summaries.append( HaEntitySummary( entity_id=entity_id, @@ -32,6 +48,15 @@ class HaReader: state_class=_optional_str(attributes.get("state_class")), device_class=_optional_str(attributes.get("device_class")), unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")), + friendly_name=_optional_str(attributes.get("friendly_name")), + area_id=_optional_str(metadata.get("area_id") or attributes.get("area_id")), + area_name=_optional_str(metadata.get("area_name") or attributes.get("area_name")), + device_id=_optional_str(metadata.get("device_id") or attributes.get("device_id")), + device_name=_optional_str( + metadata.get("device_name") + or attributes.get("device_name") + or attributes.get("device") + ), ) ) return summaries diff --git a/app/main.py b/app/main.py index b8ec755..80df088 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ -from contextlib import asynccontextmanager +import asyncio +from contextlib import asynccontextmanager, suppress from collections.abc import AsyncIterator from pathlib import Path from typing import cast @@ -7,6 +8,9 @@ from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from app.actuators.lifecycle import ActuatorReconciliationService +from app.actuators.store import ActuatorStore +from app.api.v1.actuators import router as actuators_router from app.api.v1.entities import router as entities_router from app.api.v1.automations import router as automations_router from app.automations.store import AutomationStore @@ -22,10 +26,14 @@ from backend.routes.ml import init_ml_routes async def lifespan(app: FastAPI) -> AsyncIterator[None]: settings = app.state.settings client: HaClient | None = None + reconcile_task: asyncio.Task[None] | None = None app.state.registry = ModelRegistry(settings.model_store) app.state.automation_store = AutomationStore(settings.automation_store) + app.state.actuator_store = ActuatorStore(settings.actuator_store) if hasattr(app.state, "ha_reader"): del app.state.ha_reader + if hasattr(app.state, "actuator_service"): + del app.state.actuator_service if settings.ha_configured: client = HaClient( settings=HaClientSettings( @@ -34,9 +42,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) ) app.state.ha_reader = HaReader(client=client) + app.state.actuator_service = ActuatorReconciliationService( + ha_reader=app.state.ha_reader, + store=app.state.actuator_store, + registry=app.state.registry, + settings=settings, + ) + await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup") + reconcile_task = asyncio.create_task(_periodic_reconciliation(app)) try: yield finally: + if reconcile_task is not None: + reconcile_task.cancel() + with suppress(asyncio.CancelledError): + await reconcile_task if client is not None: client.close() @@ -44,13 +64,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.3.0", + version="0.4.0", lifespan=lifespan, ) app.state.settings = load_settings() register_exception_handlers(app) app.include_router(entities_router) app.include_router(automations_router) +app.include_router(actuators_router) init_ml_routes(app, model_store=app.state.settings.model_store) STATIC_DIR = Path(__file__).with_name("static") @@ -65,3 +86,12 @@ def health() -> dict[str, str]: @app.get("/") def root() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") + + +async def _periodic_reconciliation(app: FastAPI) -> None: + while True: + await asyncio.sleep(app.state.settings.reconcile_interval_seconds) + service = getattr(app.state, "actuator_service", None) + if not isinstance(service, ActuatorReconciliationService): + continue + await asyncio.to_thread(service.reconcile_all, "scheduled") diff --git a/app/ml/registry/model_registry.py b/app/ml/registry/model_registry.py index cac7df2..5f28ed7 100644 --- a/app/ml/registry/model_registry.py +++ b/app/ml/registry/model_registry.py @@ -20,6 +20,8 @@ class ModelRegistry: def __init__(self, root: str | Path) -> None: self._root = Path(root).resolve() self._root.mkdir(parents=True, exist_ok=True) + self._archive_root = self._root / "archive" + self._archive_root.mkdir(parents=True, exist_ok=True) self._artifacts: dict[str, TrainedArtifact] = {} self._lock = RLock() self._load_existing() @@ -43,10 +45,27 @@ class ModelRegistry: raise KeyError(f"Artifact '{artifact_id}' nicht registriert.") return self._artifacts[artifact_id] + def get_optional(self, artifact_id: str) -> TrainedArtifact | None: + self._validate_artifact_id(artifact_id) + with self._lock: + return self._artifacts.get(artifact_id) + def list_models(self) -> Iterable[TrainedArtifact]: with self._lock: return [self._artifacts[key] for key in sorted(self._artifacts)] + def archive(self, artifact_id: str) -> bool: + self._validate_artifact_id(artifact_id) + with self._lock: + artifact = self._artifacts.pop(artifact_id, None) + source = self._root / f"{artifact_id}.json" + if not source.exists(): + return artifact is not None + target = self._archive_root / f"{artifact_id}.json" + os.replace(source, target) + logger.info("Modell archiviert: %s", target) + return True + def _load_existing(self) -> None: for source in sorted(self._root.glob("*.json")): try: diff --git a/app/static/index.html b/app/static/index.html index 69a0e00..c461205 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -8,63 +8,65 @@ :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; } body { margin: 0; } header { padding: 20px; background: linear-gradient(135deg,#142b3a,#193f36); } - h1,h2 { margin: 0 0 12px; } + h1,h2,h3 { margin: 0 0 12px; } header p { margin: 4px 0; color: #b9c9d6; } - main { display: grid; grid-template-columns: repeat(auto-fit,minmax(310px,1fr)); gap: 14px; padding: 14px; } + main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; } section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; } .wide { grid-column: 1 / -1; } - .ok { color: #66dfa9; } .bad { color: #ff8f8f; } + .ok { color: #66dfa9; } + .warn { color: #f3c969; } + .bad { color: #ff8f8f; } label { display: block; margin: 9px 0 4px; color: #b9c9d6; } input,select,textarea,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 9px; background: #101820; color: #fff; } button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button.secondary { background: #37495c; } - pre { white-space: pre-wrap; max-height: 310px; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; } + pre { white-space: pre-wrap; overflow: auto; background: #0d141b; padding: 10px; border-radius: 7px; } table { width: 100%; border-collapse: collapse; font-size: .9rem; } - td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; } + td,th { padding: 7px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; } + ul { margin: 8px 0; padding-left: 18px; } .notice { border-left: 4px solid #e8b34b; padding-left: 10px; } + .grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:8px; } + .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; } + .chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }

SillyHome Next

-

Lokale Home-Assistant-Analyse, Vorhersagen und kontrollierte Automation-Entwürfe.

-

Sicherheitsmodus: Entwürfe werden niemals automatisch in Home Assistant ausgeführt.

+

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.

Systemstatus

Prüfung läuft ...
- +
+ +
+
-

Entity Discovery

- - - -
Noch nicht geladen.
+

Aktuator wählen

+ + + +
Noch kein Aktuator konfiguriert.
-
-

Modell trainieren

- - - - - -
Bereit.
+ +
+

Konfigurierte Aktuatoren

+
Noch nicht geladen.
-
-

Vorhersage

- - - - - -
Bereit.
+ +
+

Zuordnung und Modellstatus

+
Einen konfigurierten Aktuator auswählen.
+

Automation-Entwurf

Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.

-
+
@@ -78,72 +80,276 @@
diff --git a/docker-compose.yml b/docker-compose.yml index 76ff008..dffc3a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,15 @@ services: environment: SILLYHOME_MODEL_STORE: /app/data/models SILLYHOME_AUTOMATION_STORE: /app/data/automations + SILLYHOME_ACTUATOR_STORE: /app/data/actuators + SILLYHOME_HISTORY_DAYS: 14 + SILLYHOME_MIN_TRAINING_POINTS: 24 + SILLYHOME_RETRAIN_STALE_HOURS: 24 + SILLYHOME_RECONCILE_INTERVAL_SECONDS: 900 volumes: - model-data:/app/data/models - automation-data:/app/data/automations + - actuator-data:/app/data/actuators read_only: true tmpfs: - /tmp @@ -24,3 +30,4 @@ services: volumes: model-data: automation-data: + actuator-data: diff --git a/docs/ha_data.md b/docs/ha_data.md index d255835..e66750a 100644 --- a/docs/ha_data.md +++ b/docs/ha_data.md @@ -14,6 +14,15 @@ Trainings- und Erklärungsprozesse. - `actuator`: mögliche Automationsziele, nicht als Trainingssensor verwendet - `unsupported`: noch nicht klassifizierte Entity-Typen +Zusätzlich reichert `HaReader` verfügbare Metadaten wie `friendly_name`, +Bereich und Gerät aus Home Assistant an. Für die aktor-zentrierte Zuordnung +nutzt SillyHome Next bevorzugt: + +- `area_id` und `area_name` +- `device_id` und `device_name` +- Friendly Names und Entity-ID-Tokens +- Domain und `device_class` + Optionale Query-Parameter: - `domain=sensor` kann mehrfach angegeben werden @@ -32,7 +41,8 @@ Historische Zustände werden über Home Assistants Die Normalisierung übernimmt nur endliche numerische Zustände. `unknown`, `unavailable`, nichtnumerische Werte, `NaN` und unendliche Werte werden nicht als Trainingsdaten verwendet. Ergebnisse werden je Entity chronologisch -sortiert. +sortiert. Binäre Kontext-Entities werden bewusst nicht in numerische +Trainingsreihen konvertiert. ## Datenschutz und Betrieb diff --git a/docs/ml_api.md b/docs/ml_api.md index b4f7941..9fcbce0 100644 --- a/docs/ml_api.md +++ b/docs/ml_api.md @@ -1,7 +1,7 @@ # ML-Serving-API Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen -Modell-Artefakt- und Vorhersage-Schnittstelle. +Modell-Artefakt-, Vorhersage- und aktor-zentrierten Lifecycle-Schnittstelle. Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell. @@ -15,6 +15,8 @@ Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell. - Einzelvorhersage: `/predict` - Batchvorhersage: `/batch` +Die aktor-zentrierte API liegt unter `/v1/actuators`. + Der Standard-Start erfolgt über `uvicorn app.main:app`, danach stehen HA- und ML-Routen in derselben Anwendung bereit. @@ -168,14 +170,45 @@ Batch-Vorhersage für mehrere Sensorwerte. - `422 Unprocessable Content`: Sensor wird vom Modell nicht unterstützt oder Eingabe ist ungültig. - `503 Service Unavailable`: Registry ist nicht initialisiert. +## Aktuator-zentrierte API + +### `GET /v1/actuators/discovery` + +Listet unterstützte Aktuatoren mit angereicherter HA-Metadatenbasis. + +### `POST /v1/actuators` + +Registriert einen Aktuator, ermittelt passende numerische Sensoren und +Kontext-Entities, trainiert bei ausreichender History automatisch ein Modell und +liefert Zuordnung, Confidence, Evidenz und Lifecycle-Status zurück. + +**Request** +```json +{ + "actuator_entity_id": "light.abstellkammer", + "enabled": true +} +``` + +### `POST /v1/actuators/{actuator_entity_id}/override` + +Persistiert manuelle Overrides. Diese haben Vorrang vor der automatischen +Heuristik und überstehen Neustarts. + +### `POST /v1/actuators/reconciliation/run` + +Führt eine sichere globale Reconciliation aus. Die periodische Add-on-Schleife +ruft denselben idempotenten Ablauf auf, startet aber keine Services in Home +Assistant. + ## Betrieb -Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte -werden über `/ml/retrain`, `RetrainingService` oder direkt über -`ModelRegistry.register(...)` registriert. Die Registry speichert validiertes -JSON atomisch und lädt es beim Neustart. Die API sollte nur in einem -vertrauenswürdigen Netz oder hinter einem authentifizierenden Reverse Proxy -erreichbar sein. +Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Aktuator-, +Override- und Reconciliation-Zustände liegen atomisch in +`SILLYHOME_ACTUATOR_STORE`. Neue Artefakte werden über `/ml/retrain`, +`RetrainingService` oder den aktor-zentrierten Lifecycle registriert. Die API +sollte nur in einem vertrauenswürdigen Netz oder hinter einem +authentifizierenden Reverse Proxy erreichbar sein. ## Verweise diff --git a/docs/ml_training.md b/docs/ml_training.md index 956e5f4..1c91df5 100644 --- a/docs/ml_training.md +++ b/docs/ml_training.md @@ -3,10 +3,19 @@ SillyHome Next trainiert ein lokales statistisches Baseline-Modell pro Sensor und Merkmal. Es benötigt keine Cloud und keine externe ML-Laufzeit. +Seit `v0.4.0` ist der bevorzugte Weg aktor-zentriert: ein bestätigter Aktuator +wird mit einem numerischen Primärsensor verknüpft, die Historie dieses Sensors +wird automatisch geladen und in ein deterministisches Artefakt überführt. + ## 1. Daten sammeln Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen. +Im Normalbetrieb erzeugt die Reconciliation diese Vektoren selbst aus realer +Home-Assistant-History. Das Trainingsmerkmal heißt dabei immer `value`. +Binäre Kontextsensoren bleiben Kontext und werden nicht als numerische Samples +missverstanden. + ## 2. Statistisches Artefakt erzeugen ```python @@ -61,6 +70,21 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 8e3455e..b6f876f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.3.0" +version = "0.4.0" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/actuators/test_actuator_store.py b/tests/actuators/test_actuator_store.py new file mode 100644 index 0000000..d98b1ac --- /dev/null +++ b/tests/actuators/test_actuator_store.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from pathlib import Path + +from app.actuators.models import ReconciliationState +from app.actuators.store import ActuatorStore + + +def test_actuator_store_persists_record_and_reconciliation_state(tmp_path: Path) -> None: + store = ActuatorStore(tmp_path) + store.configure("light.abstellkammer") + state = ReconciliationState(last_summary="ok", configured_actuators=1) + + store.save_reconciliation_state(state) + + restarted = ActuatorStore(tmp_path) + assert restarted.get("light.abstellkammer").actuator_entity_id == "light.abstellkammer" + assert restarted.load_reconciliation_state().last_summary == "ok" diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py new file mode 100644 index 0000000..d577163 --- /dev/null +++ b/tests/actuators/test_lifecycle.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from app.actuators.lifecycle import ActuatorReconciliationService +from app.actuators.models import ( + AssignmentSource, + LifecycleStatus, + ManualOverride, + model_id_for_actuator, +) +from app.actuators.store import ActuatorStore +from app.config import Settings +from app.ha.discovery import DiscoveredEntity +from app.ha.discovery import discover_entities +from app.ha.history import EntityHistorySeries, NumericHistoryPoint +from app.ha.models import HaEntitySummary +from app.ha.reader import HaReader +from app.ml.registry.model_registry import ModelRegistry + + +class FakeActuatorReader(HaReader): + def __init__( + self, + entities: list[HaEntitySummary], + history_by_entity: dict[str, list[NumericHistoryPoint]], + ) -> None: + self._entities = entities + self._history_by_entity = history_by_entity + + def read_entities(self) -> list[HaEntitySummary]: + return list(self._entities) + + def discover( + self, + domains: set[str] | None = None, + learnable: bool | None = None, + ) -> list[DiscoveredEntity]: + return discover_entities(self._entities, domains=domains, learnable=learnable) + + def read_history( + self, + entity_ids: list[str], + start_time: datetime, + end_time: datetime, + ) -> list[EntityHistorySeries]: + series: list[EntityHistorySeries] = [] + for entity_id in entity_ids: + points = [ + point + for point in self._history_by_entity.get(entity_id, []) + if start_time <= point.timestamp <= end_time + ] + if points: + series.append(EntityHistorySeries(entity_id=entity_id, points=points)) + return series + + +def _points(count: int, start: datetime, value: float) -> list[NumericHistoryPoint]: + return [ + NumericHistoryPoint(timestamp=start + timedelta(hours=index), value=value + index) + for index in range(count) + ] + + +def _service( + tmp_path: Path, + entities: list[HaEntitySummary], + history_by_entity: dict[str, list[NumericHistoryPoint]], +) -> ActuatorReconciliationService: + return ActuatorReconciliationService( + ha_reader=FakeActuatorReader(entities, history_by_entity), + store=ActuatorStore(tmp_path / "actuators"), + registry=ModelRegistry(tmp_path / "models"), + settings=Settings( + ha_url="http://ha.local", + ha_token="token", + model_store=str(tmp_path / "models"), + automation_store=str(tmp_path / "automations"), + actuator_store=str(tmp_path / "actuators"), + history_days=14, + min_training_points=5, + retrain_stale_hours=24, + reconcile_interval_seconds=900, + ), + ) + + +def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None: + start = datetime(2026, 6, 1, tzinfo=timezone.utc) + entities = [ + HaEntitySummary( + entity_id="light.abstellkammer", + domain="light", + friendly_name="Abstellkammer Licht", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="sensor.abstellkammer_illuminance", + domain="sensor", + device_class="illuminance", + state_class="measurement", + unit_of_measurement="lx", + friendly_name="Abstellkammer Helligkeit", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="binary_sensor.abstellkammer_motion", + domain="binary_sensor", + device_class="motion", + friendly_name="Abstellkammer Bewegung", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="sensor.kitchen_temperature", + domain="sensor", + device_class="temperature", + state_class="measurement", + unit_of_measurement="°C", + friendly_name="Kueche Temperatur", + area_name="Kueche", + ), + ] + service = _service( + tmp_path, + entities, + { + "sensor.abstellkammer_illuminance": _points(8, start, 10.0), + "sensor.kitchen_temperature": _points(8, start, 18.0), + }, + ) + + record = service.configure_actuator("light.abstellkammer") + + assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance" + assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_motion"] + assert record.assignment.review_required is False + assert record.lifecycle.status is LifecycleStatus.TRAINED + artifact = service._registry.load_artifact(model_id_for_actuator("light.abstellkammer")) + assert artifact.supported_sensors == ("sensor.abstellkammer_illuminance",) + assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors + + +def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: Path) -> None: + start = datetime(2026, 6, 1, tzinfo=timezone.utc) + entities = [ + HaEntitySummary( + entity_id="switch.garage_pump", + domain="switch", + friendly_name="Garage Pumpe", + area_name="Garage", + ), + HaEntitySummary( + entity_id="sensor.garage_power", + domain="sensor", + device_class="power", + state_class="measurement", + unit_of_measurement="W", + friendly_name="Garage Leistung", + area_name="Garage", + ), + HaEntitySummary( + entity_id="sensor.garage_energy", + domain="sensor", + device_class="energy", + state_class="measurement", + unit_of_measurement="kWh", + friendly_name="Garage Energie", + area_name="Garage", + ), + ] + service = _service( + tmp_path, + entities, + { + "sensor.garage_power": _points(8, start, 10.0), + "sensor.garage_energy": _points(8, start, 11.0), + }, + ) + + record = service.configure_actuator("switch.garage_pump") + + assert record.assignment.review_required is True + assert record.lifecycle.status is LifecycleStatus.REVIEW_REQUIRED + + +def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None: + start = datetime(2026, 6, 1, tzinfo=timezone.utc) + entities = [ + HaEntitySummary( + entity_id="light.abstellkammer", + domain="light", + friendly_name="Abstellkammer Licht", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="sensor.abstellkammer_illuminance", + domain="sensor", + device_class="illuminance", + state_class="measurement", + unit_of_measurement="lx", + friendly_name="Abstellkammer Helligkeit", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="sensor.abstellkammer_power", + domain="sensor", + device_class="power", + state_class="measurement", + unit_of_measurement="W", + friendly_name="Abstellkammer Leistung", + area_name="Abstellkammer", + ), + ] + history = { + "sensor.abstellkammer_illuminance": _points(8, start, 10.0), + "sensor.abstellkammer_power": _points(8, start, 30.0), + } + service = _service(tmp_path, entities, history) + service.configure_actuator("light.abstellkammer") + + updated = service.set_override( + "light.abstellkammer", + ManualOverride( + numeric_entity_id="sensor.abstellkammer_power", + context_entity_ids=[], + note="Manuelle Leistungs-Zuordnung", + ), + ) + + restarted = _service(tmp_path, entities, history) + record = restarted.reconcile_actuator("light.abstellkammer") + + assert updated.assignment.source is AssignmentSource.MANUAL + assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power" + assert record.manual_override is not None + assert record.manual_override.numeric_entity_id == "sensor.abstellkammer_power" diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py new file mode 100644 index 0000000..ef1837c --- /dev/null +++ b/tests/api/test_actuators.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.actuators.lifecycle import ActuatorReconciliationService +from app.actuators.store import ActuatorStore +from app.config import Settings +from app.ha.discovery import DiscoveredEntity +from app.ha.discovery import discover_entities +from app.ha.history import EntityHistorySeries, NumericHistoryPoint +from app.ha.models import HaEntitySummary +from app.ha.reader import HaReader +from app.main import app +from app.ml.registry.model_registry import ModelRegistry + + +class FakeHaReader(HaReader): + def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None: + self._entities = entities + self._history = history + + def read_entities(self) -> list[HaEntitySummary]: + return list(self._entities) + + def discover( + self, + domains: set[str] | None = None, + learnable: bool | None = None, + ) -> list[DiscoveredEntity]: + return discover_entities(self._entities, domains=domains, learnable=learnable) + + def read_history( + self, + entity_ids: list[str], + start_time: datetime, + end_time: datetime, + ) -> list[EntityHistorySeries]: + base = start_time + return [ + EntityHistorySeries( + entity_id=entity_id, + points=[ + NumericHistoryPoint( + timestamp=base + timedelta(hours=index), + value=value, + ) + for index, value in enumerate(self._history.get(entity_id, [])) + ], + ) + for entity_id in entity_ids + if entity_id in self._history + ] + + +def _install_service(tmp_path: Path) -> None: + entities = [ + HaEntitySummary( + entity_id="light.abstellkammer", + domain="light", + friendly_name="Abstellkammer Licht", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="sensor.abstellkammer_illuminance", + domain="sensor", + device_class="illuminance", + state_class="measurement", + unit_of_measurement="lx", + friendly_name="Abstellkammer Helligkeit", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="binary_sensor.abstellkammer_motion", + domain="binary_sensor", + device_class="motion", + friendly_name="Abstellkammer Bewegung", + area_name="Abstellkammer", + ), + ] + settings = Settings( + ha_url="http://ha.local", + ha_token="token", + model_store=str(tmp_path / "models"), + automation_store=str(tmp_path / "automations"), + actuator_store=str(tmp_path / "actuators"), + history_days=14, + min_training_points=5, + retrain_stale_hours=24, + reconcile_interval_seconds=900, + ) + app.state.registry = ModelRegistry(tmp_path / "models") + app.state.actuator_store = ActuatorStore(tmp_path / "actuators") + app.state.ha_reader = FakeHaReader( + entities, + {"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]}, + ) + app.state.actuator_service = ActuatorReconciliationService( + ha_reader=app.state.ha_reader, + store=app.state.actuator_store, + registry=app.state.registry, + settings=settings, + ) + + +def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + + created = client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + assert created.status_code == 201 + assert created.json()["assignment"]["selected_numeric_entity_id"] == ( + "sensor.abstellkammer_illuminance" + ) + + listed = client.get("/v1/actuators") + assert listed.status_code == 200 + assert listed.json()[0]["lifecycle"]["status"] == "trained" + + override = client.post( + "/v1/actuators/light.abstellkammer/override", + json={ + "numeric_entity_id": "sensor.abstellkammer_illuminance", + "context_entity_ids": ["binary_sensor.abstellkammer_motion"], + "note": "Explizit bestaetigt", + }, + ) + assert override.status_code == 200 + assert override.json()["assignment"]["source"] == "manual" + + reconciliation = client.post("/v1/actuators/reconciliation/run") + assert reconciliation.status_code == 200 + assert reconciliation.json()["trained_models"] == 1 diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index 2d72feb..56dc444 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -78,6 +78,11 @@ def test_entities_returns_reader_data() -> None: "state_class": None, "device_class": None, "unit_of_measurement": None, + "friendly_name": None, + "area_id": None, + "area_name": None, + "device_id": None, + "device_name": None, } ] diff --git a/tests/ha/test_ha_client.py b/tests/ha/test_ha_client.py index e1746a2..e6c6f74 100644 --- a/tests/ha/test_ha_client.py +++ b/tests/ha/test_ha_client.py @@ -88,6 +88,26 @@ def test_get_history_calls_home_assistant_history_api() -> None: assert call.kwargs["params"]["end_time"] == "2026-06-02T00:00:00+00:00" +def test_list_entity_metadata_calls_template_api() -> None: + response = _response() + response.text = ( + '[{"entity_id":"sensor.temperature","area_name":"Kueche","device_name":"Thermometer"}]' + ) + client = HaClient(HaClientSettings(url="http://ha.local", token="test-token")) + client._session.post = Mock(return_value=response) # type: ignore[method-assign] + + metadata = client.list_entity_metadata(["sensor.temperature"]) + + assert metadata == { + "sensor.temperature": { + "area_id": None, + "area_name": "Kueche", + "device_id": None, + "device_name": "Thermometer", + } + } + + @pytest.mark.parametrize( ("entity_ids", "start", "end"), [ diff --git a/tests/ha/test_ha_reader.py b/tests/ha/test_ha_reader.py index 05a2bbc..d9304cc 100644 --- a/tests/ha/test_ha_reader.py +++ b/tests/ha/test_ha_reader.py @@ -44,6 +44,16 @@ class FakeHaClient(HaClient): ] ] + def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]: + return { + "sensor.temperature": { + "area_id": "kitchen", + "area_name": "Kueche", + "device_id": "device-1", + "device_name": "Thermometer", + } + } + def test_ha_reader_returns_summaries() -> None: reader = HaReader(FakeHaClient()) @@ -53,6 +63,8 @@ def test_ha_reader_returns_summaries() -> None: assert domains == {"sensor", "light"} sensor = next(item for item in summaries if item.entity_id == "sensor.temperature") assert sensor.unit_of_measurement == "°C" + assert sensor.area_name == "Kueche" + assert sensor.device_name == "Thermometer" def test_ha_reader_discovers_learnable_sensors() -> None: diff --git a/tests/test_config.py b/tests/test_config.py index 1b6c010..9cf435a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,11 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) -> monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret") monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models") monkeypatch.setenv("SILLYHOME_AUTOMATION_STORE", "/tmp/automations") + monkeypatch.setenv("SILLYHOME_ACTUATOR_STORE", "/tmp/actuators") + monkeypatch.setenv("SILLYHOME_HISTORY_DAYS", "7") + monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12") + monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48") + monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600") settings = load_settings() @@ -17,4 +22,9 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) -> assert settings.ha_token == "secret" assert settings.model_store == "/tmp/models" assert settings.automation_store == "/tmp/automations" + assert settings.actuator_store == "/tmp/actuators" + assert settings.history_days == 7 + assert settings.min_training_points == 12 + assert settings.retrain_stale_hours == 48 + assert settings.reconcile_interval_seconds == 600 assert settings.ha_configured -- 2.47.3