ACT-001: actuator-first sensor lifecycle

This commit is contained in:
2026-06-13 22:45:07 +02:00
parent d6631fe752
commit 6305f52cd2
30 changed files with 2010 additions and 103 deletions

27
app/actuators/__init__.py Normal file
View File

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

607
app/actuators/lifecycle.py Normal file
View File

@@ -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."

102
app/actuators/models.py Normal file
View File

@@ -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}"

116
app/actuators/store.py Normal file
View File

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

122
app/api/v1/actuators.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

@@ -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; }
</style>
</head>
<body>
<header>
<h1>SillyHome Next</h1>
<p>Lokale Home-Assistant-Analyse, Vorhersagen und kontrollierte Automation-Entwürfe.</p>
<p class="notice">Sicherheitsmodus: Entwürfe werden niemals automatisch in Home Assistant ausgeführt.</p>
<p>Aktuator-zentrierte Home-Assistant-Analyse mit nachvollziehbarer Sensorzuordnung und kontrolliertem Modell-Lebenszyklus.</p>
<p class="notice">Sicherheitsmodus: SillyHome führt niemals selbst Aktor-Services aus. Automationen bleiben manuell freizugebende YAML-Entwürfe.</p>
</header>
<main>
<section>
<h2>Systemstatus</h2>
<div id="status">Prüfung läuft ...</div>
<button class="secondary" onclick="loadStatus()">Neu laden</button>
<div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Neu laden</button>
<button onclick="runReconciliation()">Reconciliation ausführen</button>
</section>
<section>
<h2>Entity Discovery</h2>
<label for="domain">Domain (optional)</label>
<input id="domain" placeholder="sensor">
<button onclick="discover()">HA-Entities analysieren</button>
<pre id="discovery">Noch nicht geladen.</pre>
<h2>Aktuator wählen</h2>
<label for="actuator-select">Home-Assistant-Aktor</label>
<select id="actuator-select"></select>
<button onclick="configureActuator()">Aktuator übernehmen</button>
<pre id="actuator-config-result">Noch kein Aktuator konfiguriert.</pre>
</section>
<section>
<h2>Modell trainieren</h2>
<label for="train-model">Modell-ID</label><input id="train-model" value="home-model">
<label for="train-sensor">Sensor</label><input id="train-sensor" placeholder="sensor.temperatur">
<label for="train-feature">Merkmal</label><input id="train-feature" value="value">
<label for="train-values">Messwerte, komma-getrennt</label><input id="train-values" placeholder="19,20,21">
<button onclick="train()">Trainieren</button>
<pre id="training">Bereit.</pre>
<section class="wide">
<h2>Konfigurierte Aktuatoren</h2>
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section>
<h2>Vorhersage</h2>
<label for="predict-model">Modell-ID</label><input id="predict-model" value="home-model">
<label for="predict-sensor">Sensor</label><input id="predict-sensor" placeholder="sensor.temperatur">
<label for="predict-feature">Merkmal</label><input id="predict-feature" value="value">
<label for="predict-value">Aktueller Wert</label><input id="predict-value" type="number" step="any">
<button onclick="predict()">Vorhersagen und erklären</button>
<pre id="prediction">Bereit.</pre>
<section class="wide">
<h2>Zuordnung und Modellstatus</h2>
<div id="actuator-detail">Einen konfigurierten Aktuator auswählen.</div>
</section>
<section class="wide">
<h2>Automation-Entwurf</h2>
<p>Der Entwurf muss explizit freigegeben werden. Auch danach wird nur YAML exportiert, nichts geschaltet.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">
<div class="grid-two">
<div><label for="alias">Name</label><input id="alias" value="Licht bei Dunkelheit"></div>
<div><label for="trigger">Trigger-Entity</label><input id="trigger" placeholder="sensor.flur_illuminance"></div>
<div><label for="below">Unter Grenzwert</label><input id="below" type="number" value="10"></div>
@@ -78,72 +80,276 @@
</main>
<script>
const pretty = value => JSON.stringify(value, null, 2);
async function api(path, options={}) {
const response = await fetch(path, {headers: {"Content-Type":"application/json"}, ...options});
let currentActuatorId = null;
async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.detail || `${response.status} ${response.statusText}`);
return body;
}
async function loadStatus() {
const box=document.getElementById("status");
try {
const [health, ml, models]=await Promise.all([api("health"),api("ml/health"),api("ml/models")]);
box.innerHTML=`<p class="ok">API und ML bereit</p><p>Modelle: ${models.models.length}</p>`;
} catch(e) { box.innerHTML=`<p class="bad">${e.message}</p>`; }
function statusClass(record) {
if (record.assignment.review_required) return "warn";
if (record.lifecycle.status === "trained") return "ok";
if (record.lifecycle.status === "review_required" || record.lifecycle.status === "invalid") return "warn";
return "bad";
}
async function discover() {
const out=document.getElementById("discovery"), domain=document.getElementById("domain").value.trim();
out.textContent="Lade ...";
try {
const rows=await api(`v1/discovery?learnable=true${domain?`&domain=${encodeURIComponent(domain)}`:""}`);
out.textContent=pretty({learnable_entities:rows.length, entities:rows.slice(0,100)});
} catch(e) { out.textContent=e.message; }
function renderEvidence(evidence) {
return evidence.length ? `<ul>${evidence.map(item => `<li>${item}</li>`).join("")}</ul>` : "<span class='bad'>Keine Evidenz</span>";
}
async function train() {
const out=document.getElementById("training");
async function loadOverview() {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
try {
const values=document.getElementById("train-values").value.split(",").map(Number).filter(Number.isFinite);
if (!values.length) throw new Error("Mindestens einen Messwert eingeben.");
const sensor=document.getElementById("train-sensor").value.trim(), feature=document.getElementById("train-feature").value.trim();
const samples=values.map(value=>({sensor_id:sensor,values:{[feature]:value}}));
out.textContent=pretty(await api("ml/retrain",{method:"POST",body:JSON.stringify({modelId:document.getElementById("train-model").value,samples})}));
await loadStatus();
} catch(e) { out.textContent=e.message; }
const [health, ml, reconciliation, actuators] = await Promise.all([
api("health"),
api("ml/health"),
api("v1/actuators/reconciliation/state"),
api("v1/actuators"),
]);
status.innerHTML = `<p class="ok">API und ML bereit</p><p>Letzte Reconciliation: ${reconciliation.last_completed_at || "noch nie"}</p><p>${reconciliation.last_summary}</p>`;
chips.innerHTML = [
`<span class="chip">Health: ${health.status}</span>`,
`<span class="chip">ML: ${ml.status}</span>`,
`<span class="chip">Aktuatoren: ${actuators.length}</span>`,
`<span class="chip">Trainierte Modelle: ${reconciliation.trained_models}</span>`,
].join("");
} catch (error) {
status.innerHTML = `<p class="bad">${error.message}</p>`;
chips.innerHTML = "";
}
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators(), loadProposals()]);
}
async function predict() {
const out=document.getElementById("prediction");
async function loadActuatorDiscovery() {
const select = document.getElementById("actuator-select");
try {
const feature=document.getElementById("predict-feature").value.trim();
out.textContent=pretty(await api("ml/predict",{method:"POST",body:JSON.stringify({
modelId:document.getElementById("predict-model").value,
sensor_id:document.getElementById("predict-sensor").value.trim(),
values:{[feature]:Number(document.getElementById("predict-value").value)}
})}));
} catch(e) { out.textContent=e.message; }
const actuators = await api("v1/actuators/discovery");
select.innerHTML = actuators.length
? actuators.map(entity => `<option value="${entity.entity_id}">${entity.friendly_name || entity.entity_id}${entity.area_name ? ` (${entity.area_name})` : ""}</option>`).join("")
: "<option value=''>Keine Aktuatoren gefunden</option>";
} catch (error) {
select.innerHTML = `<option value="">${error.message}</option>`;
}
}
async function configureActuator() {
const actuatorId = document.getElementById("actuator-select").value;
const box = document.getElementById("actuator-config-result");
if (!actuatorId) return;
try {
const record = await api("v1/actuators", {
method: "POST",
body: JSON.stringify({actuator_entity_id: actuatorId}),
});
currentActuatorId = record.actuator_entity_id;
box.textContent = pretty(record);
await loadOverview();
await showActuator(record.actuator_entity_id);
} catch (error) {
box.textContent = error.message;
}
}
async function runReconciliation() {
try {
await api("v1/actuators/reconciliation/run", {method: "POST"});
await loadOverview();
if (currentActuatorId) await showActuator(currentActuatorId);
} catch (error) {
alert(error.message);
}
}
async function loadConfiguredActuators() {
const box = document.getElementById("configured-actuators");
try {
const rows = await api("v1/actuators");
box.innerHTML = rows.length ? `
<table>
<tr><th>Aktuator</th><th>Numerischer Sensor</th><th>Review</th><th>Modellstatus</th><th>Letztes Training</th><th>Aktion</th></tr>
${rows.map(record => `
<tr>
<td>${record.actuator_entity_id}</td>
<td>${record.assignment.selected_numeric_entity_id || "-"}</td>
<td class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nein"}</td>
<td class="${statusClass(record)}">${record.lifecycle.status}</td>
<td>${record.lifecycle.last_trained_at || "-"}</td>
<td><button onclick="showActuator('${record.actuator_entity_id}')">Details</button></td>
</tr>
`).join("")}
</table>` : "<p>Keine konfigurierten Aktuatoren.</p>";
} catch (error) {
box.textContent = error.message;
}
}
async function showActuator(actuatorId) {
currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail");
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericRows = record.numeric_candidates.map(candidate => `
<tr>
<td>${candidate.entity_id}</td>
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td>
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>review</span>"}</td>
<td>${renderEvidence(candidate.evidence)}</td>
</tr>
`).join("");
const contextRows = record.context_candidates.map(candidate => `
<tr>
<td>${candidate.entity_id}</td>
<td>${candidate.score.toFixed(3)} / ${candidate.confidence.toFixed(2)}</td>
<td>${candidate.auto_accepted ? "<span class='ok'>auto</span>" : "<span class='warn'>optional</span>"}</td>
<td>${renderEvidence(candidate.evidence)}</td>
</tr>
`).join("");
box.innerHTML = `
<div class="grid-two">
<div>
<h3>Auswahl</h3>
<p><strong>Aktuator:</strong> ${record.actuator_entity_id}</p>
<p><strong>Numerischer Sensor:</strong> ${record.assignment.selected_numeric_entity_id || "-"}</p>
<p><strong>Kontext:</strong> ${record.assignment.selected_context_entity_ids.join(", ") || "-"}</p>
<p><strong>Quelle:</strong> ${record.assignment.source}</p>
<p><strong>Review:</strong> <span class="${record.assignment.review_required ? "warn" : "ok"}">${record.assignment.review_required ? "erforderlich" : "nicht erforderlich"}</span></p>
<p><strong>Begruendung:</strong> ${record.assignment.reason}</p>
</div>
<div>
<h3>Modell-Lebenszyklus</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${record.lifecycle.status}</span></p>
<p><strong>Letztes Training:</strong> ${record.lifecycle.last_trained_at || "-"}</p>
<p><strong>Messpunkte:</strong> ${record.lifecycle.last_history_point_count}</p>
<p><strong>Grund:</strong> ${record.lifecycle.reason}</p>
<p><strong>Nächste Aktion:</strong> ${record.lifecycle.next_action}</p>
<button onclick="reconcileActuator('${record.actuator_entity_id}')">Diesen Aktuator erneut prüfen</button>
</div>
</div>
<div class="grid-two">
<div>
<h3>Manuelle Overrides</h3>
<label for="override-numeric">Numerischer Sensor</label>
<input id="override-numeric" value="${record.manual_override?.numeric_entity_id || record.assignment.selected_numeric_entity_id || ""}">
<label for="override-context">Kontext-Entities (kommagetrennt)</label>
<textarea id="override-context">${(record.manual_override?.context_entity_ids || record.assignment.selected_context_entity_ids || []).join(", ")}</textarea>
<label for="override-note">Notiz</label>
<input id="override-note" value="${record.manual_override?.note || ""}">
<button onclick="saveOverride('${record.actuator_entity_id}')">Override speichern</button>
<button class="secondary" onclick="clearOverride('${record.actuator_entity_id}')">Override löschen</button>
</div>
<div>
<h3>Audit</h3>
<pre>${pretty(record.lifecycle.audit)}</pre>
</div>
</div>
<h3>Numerische Kandidaten</h3>
${numericRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${numericRows}</table>` : "<p>Keine Kandidaten.</p>"}
<h3>Kontext-Kandidaten</h3>
${contextRows ? `<table><tr><th>Entity</th><th>Score / Confidence</th><th>Auto</th><th>Evidenz</th></tr>${contextRows}</table>` : "<p>Keine Kandidaten.</p>"}
`;
} catch (error) {
box.textContent = error.message;
}
}
async function reconcileActuator(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/reconcile`, {method: "POST"});
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function saveOverride(actuatorId) {
const numeric = document.getElementById("override-numeric").value.trim() || null;
const contexts = document.getElementById("override-context").value
.split(",")
.map(item => item.trim())
.filter(Boolean);
const note = document.getElementById("override-note").value.trim() || null;
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numeric,
context_entity_ids: contexts,
note,
}),
});
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function clearOverride(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/override`, {
method: "POST",
body: JSON.stringify({clear: true}),
});
await loadOverview();
await showActuator(actuatorId);
} catch (error) {
alert(error.message);
}
}
async function createProposal() {
try {
await api("v1/automations/proposals",{method:"POST",body:JSON.stringify({
alias:document.getElementById("alias").value,
description:"Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.",
trigger:{entity_id:document.getElementById("trigger").value,below:Number(document.getElementById("below").value)},
action:{service:document.getElementById("service").value,entity_id:document.getElementById("target").value,data:{}}
await api("v1/automations/proposals", {method: "POST", body: JSON.stringify({
alias: document.getElementById("alias").value,
description: "Manuell im SillyHome-Dashboard erstellter und nicht automatisch ausgeführter Entwurf.",
trigger: {entity_id: document.getElementById("trigger").value, below: Number(document.getElementById("below").value)},
action: {service: document.getElementById("service").value, entity_id: document.getElementById("target").value, data: {}}
})});
await loadProposals();
} catch(e) { alert(e.message); }
} catch (error) {
alert(error.message);
}
}
async function decide(id, revision, action) {
try { await api(`v1/automations/proposals/${id}/${action}`,{method:"POST",body:JSON.stringify({expected_revision:revision})}); await loadProposals(); }
catch(e) { alert(e.message); }
}
async function loadProposals() {
const box=document.getElementById("proposals");
try {
const rows=await api("v1/automations/proposals");
box.innerHTML=rows.length?`<table><tr><th>Name</th><th>Status</th><th>Aktion</th></tr>${rows.map(x=>`<tr><td>${x.alias}</td><td>${x.status}</td><td>${x.status==="draft"?`<button onclick="decide('${x.proposal_id}',${x.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${x.proposal_id}',${x.revision},'reject')">Ablehnen</button>`:`${x.status==="approved"?`<a href="v1/automations/proposals/${x.proposal_id}/yaml">YAML laden</a>`:"-"}`}</td></tr>`).join("")}</table>`:"<p>Keine Entwürfe.</p>";
} catch(e) { box.textContent=e.message; }
await api(`v1/automations/proposals/${id}/${action}`, {method: "POST", body: JSON.stringify({expected_revision: revision})});
await loadProposals();
} catch (error) {
alert(error.message);
}
}
loadStatus(); loadProposals();
async function loadProposals() {
const box = document.getElementById("proposals");
try {
const rows = await api("v1/automations/proposals");
box.innerHTML = rows.length ? `
<table>
<tr><th>Name</th><th>Status</th><th>Aktion</th></tr>
${rows.map(item => `
<tr>
<td>${item.alias}</td>
<td>${item.status}</td>
<td>${item.status === "draft"
? `<button onclick="decide('${item.proposal_id}',${item.revision},'approve')">Freigeben</button><button class="secondary" onclick="decide('${item.proposal_id}',${item.revision},'reject')">Ablehnen</button>`
: item.status === "approved"
? `<a href="v1/automations/proposals/${item.proposal_id}/yaml">YAML laden</a>`
: "-"}</td>
</tr>
`).join("")}
</table>` : "<p>Keine Entwürfe.</p>";
} catch (error) {
box.textContent = error.message;
}
}
loadOverview();
</script>
</body>
</html>