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 ...
-
+
+
+
+
-