Compare commits

...

11 Commits

Author SHA1 Message Date
1788f9d963 Expand room planning management
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 23:12:49 +02:00
9ff005f089 Add room management settings
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:44:56 +02:00
954c4511a7 Fix complete dashboard i18n refresh
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:25:13 +02:00
33cce32098 Release SillyHome Next 1.7.4
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:59:21 +02:00
08e41b0198 Improve learning discovery and dashboard i18n
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:57:57 +02:00
1b9db62294 Add simulation apply workflow
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 20:10:53 +02:00
5ca0c53f6a Reduce websocket reconnect load
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:17:50 +02:00
8070a85b52 Add actuator simulation tuning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:06:47 +02:00
575211f0db Add production diagnostics and planning features
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 11:53:53 +02:00
d9dc186f9b Fix HA websocket keepalive regression
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 07:48:46 +02:00
214b384b70 Release v1.6.0 dashboard architecture cleanup
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 01:02:29 +02:00
22 changed files with 3540 additions and 168 deletions

View File

@@ -1,5 +1,83 @@
# Changelog # Changelog
## 1.7.7 - 2026-07-26
- Raumverwaltung erzeugt jetzt eine vollständige Übersicht aus allen
Home-Assistant-Bereichen, nicht nur aus bereits konfigurierten Aktoren.
- Räume zeigen Sensoren, unverwaltete Aktoren und passende Handlungs-
Vorschläge für Licht, Strom, Schalter, Heizung, Wasser, Belüftung,
Sicherheit, Rollos und weitere steuerbare Geräte.
- Jede vorgeschlagene Handlung liefert Bedingung, Aktion, Begründung,
Sicherheit und Lernbarkeit, damit klar ist, was wann warum eintreten könnte.
- Startup- und geplante Reconciliation aktualisieren nun auch Evaluation und
Planungs-Insights kontinuierlich.
## 1.7.6 - 2026-07-26
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
einer gemeinsamen Ansicht.
- Neue API `/v1/actuators/settings/rooms` liefert kompakte Verwaltungsdaten
für Raumkarten, Sensorvorschläge, Aktoren und noch nicht verwaltete
Vorschläge.
- Licht-/Schalter-Zuordnung darf bei eindeutigem Tür-/Öffnungskontext ohne
numerischen Helligkeitssensor arbeiten, z. B. Tür auf -> Licht an und Tür zu
-> Licht aus.
## 1.7.5 - 2026-07-26
- Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte
Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte.
- Aufklapp-Hinweise (`expand`/`collapse`) kommen nicht mehr fest aus CSS auf
Deutsch, sondern werden pro Sprache gesetzt.
- Detail-Cache wird beim Sprachwechsel geleert, damit keine alten deutschen
HTML-Fragmente in der englischen Oberfläche sichtbar bleiben.
## 1.7.4 - 2026-07-26
- Dashboard-Sprachumschaltung aktualisiert statische Texte, Labels,
Platzhalter und wichtige Laufzeittexte direkt beim Wechsel.
- Verhaltenslernen speichert Lichtattribute wie Helligkeit und Farbwerte aus
der Home-Assistant-Historie und gibt sie bei Lichtvorhersagen an den
`light.turn_on` Service weiter.
- Kontext-Discovery erkennt Umlaute/Raumvarianten robuster, ignoriert
Markenwörter wie `lidl` als falsche Gemeinsamkeit und bevorzugt Raum-
Präsenzsensoren für Lidl-/Treppenlichter.
- Lüftungen bevorzugen Luftfeuchte und Belegungs-/Präsenzkontext; gelernte
Sensorwechsel dürfen jetzt eine Verzögerung haben, z. B. WC besetzt -> nach
2-3 Minuten Lüftung an.
- Briefkasten-Reset-Buttons können Schrank-/Entnahme-Türen als Kontext
erkennen; Button-Aktoren können im aktiven Modus per `press` ausgeführt
werden.
- PV-/Akku-/Verbrauchssensoren werden als Energiemanagement-Kontext stärker
einsortiert.
## 1.7.0 - 2026-06-18
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
Event-Latenzmessungen und Dry-run pro Aktor.
- Backup-/Restore-API fuer Aktor-Konfigurationen, Reconciliation-Status und
sichtbare Job-Historie.
- Feedback kann jetzt konkrete Korrekturtypen wie `too_early`, `too_late` und
`never_automate` speichern; `never_automate` setzt eine manuelle Sperre.
- Planungs-Refresh erzeugt Raum-/Aktorgruppen, einfache Szenenvorschlaege und
lokale Agent-Insights aus vorhandenen Daten.
- Event-Verarbeitung laedt Aktor-Konfigurationen nur noch einmal pro
Home-Assistant-State-Change.
## 1.6.1 - 2026-06-18
- Home-Assistant-WebSocket nutzt wieder keinen clientseitigen Keepalive-Ping.
Damit bleibt das Event-Verhalten aus 0.7.8 stabil und Sensorwechsel fuehren
nicht erst ueber Fallback oder manuelle Statusabfrage zu Schaltungen.
## 1.6.0 - 2026-06-18
- `/v1/actuators/dashboard/system` und `/dashboard/start` lesen fuer
Cache-Status nur noch SQLite-Metadaten statt den kompletten Entity-Cache zu
materialisieren.
- Aktor-Summaries lesen benoetigte Entity-Metadaten gezielt aus SQLite anhand
der Aktor-IDs.
- Ingress-Dashboard bereinigt: weniger Erklaertexte, kein Ablauf-Menue, kein
Versions-Chip im Einrichtungsbereich.
- Detailansicht ergaenzt Zurueck-Navigation, Aktualisieren und Auswahl eines
anderen beobachteten Geraets.
- Frontend bleibt Anzeige- und Bedienebene; Backend liefert schlanke
View-Daten, Worker aktualisieren HA-/Discovery-Cache im Hintergrund.
## 1.5.4 - 2026-06-18 ## 1.5.4 - 2026-06-18
- Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt - Add-on-Start vertraut Ingress-Proxy-Headern nicht mehr blind. Uvicorn loggt
damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`. damit den direkten Docker-/Ingress-Peer statt LAN-IPs aus `X-Forwarded-For`.

View File

@@ -31,6 +31,8 @@ nach einer ausdrücklichen Freigabe ausführen.
[`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md) [`docs/V1_5_2_OPERATING_GUIDE.md`](docs/V1_5_2_OPERATING_GUIDE.md)
- Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard: - Version 1.5.3 SQLite-Cache fuer Ingress-Dashboard:
[`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md) [`docs/V1_5_3_OPERATING_GUIDE.md`](docs/V1_5_3_OPERATING_GUIDE.md)
- Version 1.7.0 Diagnose, Backup, Dry-run und Planung:
[`docs/V1_7_0_OPERATING_GUIDE.md`](docs/V1_7_0_OPERATING_GUIDE.md)
- Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md)
## Reifegrad ## Reifegrad

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "1.5.4" version: "1.7.7"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -33,6 +33,38 @@ class DashboardCache:
"entities": [json.loads(row[1]) for row in rows], "entities": [json.loads(row[1]) for row in rows],
} }
def load_status(self) -> dict[str, object]:
with self._lock, self._connect() as connection:
updated_at = self._get_meta(connection, "ha_entities_updated_at")
groups_json = self._get_meta(connection, "discovery_groups") or "[]"
entity_count = connection.execute("select count(*) from ha_entities").fetchone()[0]
try:
groups = json.loads(groups_json)
except ValueError:
groups = []
return {
"updated_at": updated_at,
"discovery_groups": groups if isinstance(groups, list) else [],
"entity_count": int(entity_count or 0),
}
def load_entity_map(self, entity_ids: set[str]) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
placeholders = ",".join("?" for _ in entity_ids)
with self._lock, self._connect() as connection:
rows = connection.execute(
f"select entity_id, payload from ha_entities where entity_id in ({placeholders})",
tuple(sorted(entity_ids)),
).fetchall()
result: dict[str, HaEntitySummary] = {}
for entity_id, payload in rows:
try:
result[str(entity_id)] = HaEntitySummary.model_validate(json.loads(payload))
except (TypeError, ValueError):
continue
return result
def save_entities_payload( def save_entities_payload(
self, self,
*, *,

View File

@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
"entity", "entity",
"humidity", "humidity",
"illuminance", "illuminance",
"led",
"lidl",
"light", "light",
"licht", "licht",
"lichtschalter", "lichtschalter",
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
"presence", "presence",
"window", "window",
}) })
_PRESENCE_TOKENS = frozenset({
"besetzt",
"occupied",
"occupancy",
"presence",
"prasenz",
"praesenz",
"motion",
"bewegung",
"bewegungsmelder",
})
_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"})
_CABINET_TOKENS = frozenset({"schrank", "cabinet"})
_PV_TOKENS = frozenset({
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
"netzbezug",
"grid",
"verbrauch",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -517,6 +546,25 @@ class ActuatorReconciliationService:
if candidate.auto_accepted if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS] ][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts] top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if (
top_numeric is not None
and actuator.domain in {"light", "switch"}
and any(
(candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in accepted_contexts
)
):
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Tür-/Öffnungskontext automatisch erkannt. Für diese "
"direkte Schaltlogik ist kein Helligkeitssensor erforderlich."
),
)
if top_numeric is None: if top_numeric is None:
if accepted_contexts: if accepted_contexts:
return AssignmentSelection( return AssignmentSelection(
@@ -864,6 +912,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
return True return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)): if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity):
return True
if actuator.domain in {"fan", "humidifier"} and (
_is_presence_context(entity) or entity.device_class in {"humidity", "moisture"}
):
return True
if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and (
entity_tokens.intersection(_PV_TOKENS)
):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True) entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool( return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS) entity_tokens.intersection(_OUTDOOR_TOKENS)
@@ -878,6 +938,18 @@ def _eligible_for_auto_context(
device_class = candidate.device_class or "" device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES: if device_class in _AUTO_CONTEXT_CLASSES:
return True return True
if actuator.domain in {"fan", "humidifier"} and device_class in {
"humidity",
"moisture",
"temperature",
}:
return True
if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate):
return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
candidate_tokens = _candidate_tokens(candidate, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate):
return True
if ( if (
actuator.device_name actuator.device_name
and candidate.device_name and candidate.device_name
@@ -899,6 +971,7 @@ def _score_candidate(
score = 0.0 score = 0.0
actuator_tokens = _metadata_tokens(actuator) actuator_tokens = _metadata_tokens(actuator)
entity_tokens = _metadata_tokens(entity) entity_tokens = _metadata_tokens(entity)
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
overlap = sorted(actuator_tokens.intersection(entity_tokens)) overlap = sorted(actuator_tokens.intersection(entity_tokens))
if overlap: if overlap:
score += min(0.4, 0.1 * len(overlap)) score += min(0.4, 0.1 * len(overlap))
@@ -924,6 +997,31 @@ def _score_candidate(
if entity.device_class in preferred_device_classes: if entity.device_class in preferred_device_classes:
score += 0.2 score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}") evidence.append(f"Passende device_class: {entity.device_class}")
if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.")
if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.")
if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity):
score += 0.3
evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.")
if context and _is_mailbox_reset_candidate(
_metadata_tokens(actuator, include_stopwords=True),
_metadata_tokens(entity, include_stopwords=True),
entity,
):
score += 0.45
evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.")
if full_entity_tokens.intersection(_PV_TOKENS):
score += 0.12 if context else 0.18
evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.")
if not context and actuator.domain == "light" and entity.device_class == "illuminance": if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2 score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.") evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
@@ -1068,11 +1166,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False
for value in raw_values: for value in raw_values:
if value is None: if value is None:
continue continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS): if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue continue
tokens.add(token) tokens.add(token)
return tokens return _expand_room_tokens(tokens)
def _candidate_tokens(
candidate: AssignmentCandidate,
*,
include_stopwords: bool = False,
) -> set[str]:
raw_values = [
candidate.entity_id,
candidate.friendly_name,
candidate.area_name,
candidate.device_name,
]
tokens: set[str] = set()
for value in raw_values:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return _expand_room_tokens(tokens)
def _expand_room_tokens(tokens: set[str]) -> set[str]:
expanded = set(tokens)
if "gaste" in expanded:
expanded.add("gaeste")
if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded):
expanded.add("gaestewc")
if {"gaeste", "zimmer"}.issubset(expanded):
expanded.add("gaestezimmer")
return expanded
def _normalize_text(value: str) -> str:
return (
value.lower()
.replace("_", " ")
.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
)
def _is_presence_context(entity: HaEntitySummary) -> bool:
if entity.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_presence_candidate(candidate: AssignmentCandidate) -> bool:
if candidate.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_mailbox_reset_candidate(
actuator_tokens: set[str],
context_tokens: set[str],
entity: HaEntitySummary | AssignmentCandidate,
) -> bool:
if not actuator_tokens.intersection(_MAILBOX_TOKENS):
return False
if not context_tokens.intersection(_CABINET_TOKENS):
return False
return entity.domain == "binary_sensor" and entity.device_class in {
"door",
"garage_door",
"opening",
}
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str: def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:

View File

@@ -52,6 +52,14 @@ class JobStatus(StrEnum):
FAILED = "failed" FAILED = "failed"
class FeedbackKind(StrEnum):
CORRECT = "correct"
WRONG = "wrong"
TOO_EARLY = "too_early"
TOO_LATE = "too_late"
NEVER_AUTOMATE = "never_automate"
class AssignmentCandidate(BaseModel): class AssignmentCandidate(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
@@ -115,12 +123,14 @@ class ModelLifecycleState(BaseModel):
class BehaviorPattern(BaseModel): class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100) target_state: str = Field(min_length=1, max_length=100)
target_attributes: dict[str, object] = Field(default_factory=dict)
minute_of_day: int = Field(ge=0, le=1439) minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6) weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict) context_states: dict[str, str] = Field(default_factory=dict)
trigger_entity_id: str | None = None trigger_entity_id: str | None = None
trigger_from_state: str | None = None trigger_from_state: str | None = None
trigger_to_state: str | None = None trigger_to_state: str | None = None
trigger_delay_seconds: int | None = Field(default=None, ge=0)
source: str = Field(default="observed", max_length=40) source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0) weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime observed_at: datetime
@@ -128,6 +138,7 @@ class BehaviorPattern(BaseModel):
class BehaviorPrediction(BaseModel): class BehaviorPrediction(BaseModel):
target_state: str target_state: str
target_attributes: dict[str, object] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0) confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime generated_at: datetime
reason: str reason: str
@@ -146,6 +157,43 @@ class DecisionFactor(BaseModel):
evidence: list[str] = Field(default_factory=list) evidence: list[str] = Field(default_factory=list)
class SimulationOutcome(BaseModel):
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
actuator_entity_id: str
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
prediction: BehaviorPrediction | None = None
decision_factors: list[DecisionFactor] = Field(default_factory=list)
would_execute: bool = False
blockers: list[str] = Field(default_factory=list)
score: float = Field(default=0.0, ge=0.0, le=1.0)
recommendation: str = Field(default="", max_length=700)
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
trigger_state: str | None = None
target_state: str | None = None
confidence: float | None = Field(default=None, ge=0.0, le=1.0)
executed: bool = False
blocked: bool = False
reason: str = Field(default="", max_length=700)
blockers: list[str] = Field(default_factory=list)
duration_ms: int | None = Field(default=None, ge=0)
class LatencyMeasurement(BaseModel):
measured_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
trigger_entity_id: str | None = None
event_to_decision_ms: int | None = Field(default=None, ge=0)
decision_to_service_ms: int | None = Field(default=None, ge=0)
event_to_done_ms: int | None = Field(default=None, ge=0)
executed: bool = False
source: str = Field(default="manual", max_length=40)
class AdaptiveWeightUpdate(BaseModel): class AdaptiveWeightUpdate(BaseModel):
entity_id: str entity_id: str
previous_weight: float = Field(ge=0.0, le=1.0) previous_weight: float = Field(ge=0.0, le=1.0)
@@ -248,6 +296,32 @@ class RelatedAutomation(BaseModel):
enabled: bool enabled: bool
class ActuatorGroup(BaseModel):
group_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
name: str = Field(min_length=1, max_length=120)
area_name: str | None = Field(default=None, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
reason: str = Field(default="", max_length=300)
class SceneSuggestion(BaseModel):
scene_id: str = Field(pattern=r"^[a-z0-9_-]{1,64}$")
label: str = Field(min_length=1, max_length=120)
member_entity_ids: list[str] = Field(default_factory=list)
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
reason: str = Field(default="", max_length=500)
last_seen_at: datetime | None = None
class AgentInsight(BaseModel):
insight_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
severity: str = Field(default="info", max_length=20)
title: str = Field(min_length=1, max_length=160)
detail: str = Field(min_length=1, max_length=700)
action: str | None = Field(default=None, max_length=300)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class BehaviorState(BaseModel): class BehaviorState(BaseModel):
mode: BehaviorMode = BehaviorMode.SHADOW mode: BehaviorMode = BehaviorMode.SHADOW
status: BehaviorStatus = BehaviorStatus.COLLECTING status: BehaviorStatus = BehaviorStatus.COLLECTING
@@ -281,6 +355,16 @@ class BehaviorState(BaseModel):
automation_conflicts: list[AutomationConflict] = Field(default_factory=list) automation_conflicts: list[AutomationConflict] = Field(default_factory=list)
time_profiles: list[TimeProfile] = Field(default_factory=list) time_profiles: list[TimeProfile] = Field(default_factory=list)
anomalies: list[AnomalyEvent] = Field(default_factory=list) anomalies: list[AnomalyEvent] = Field(default_factory=list)
decision_timeline: list[DecisionTrace] = Field(default_factory=list)
latency_measurements: list[LatencyMeasurement] = Field(default_factory=list)
feedback_log: list[FeedbackKind] = Field(default_factory=list)
dry_run_enabled: bool = False
dry_run_started_at: datetime | None = None
dry_run_sample_count: int = Field(default=0, ge=0)
dry_run_hit_count: int = Field(default=0, ge=0)
actuator_groups: list[ActuatorGroup] = Field(default_factory=list)
scene_suggestions: list[SceneSuggestion] = Field(default_factory=list)
agent_insights: list[AgentInsight] = Field(default_factory=list)
class ActuatorRecord(BaseModel): class ActuatorRecord(BaseModel):

View File

@@ -100,6 +100,11 @@ class ActuatorStore:
except ValueError as exc: except ValueError as exc:
raise ValueError("Ungültiger Job-Queue-Status.") from exc raise ValueError("Ungültiger Job-Queue-Status.") from exc
def save_job_queue(self, queue: JobQueueState) -> JobQueueState:
with self._lock:
self._persist_job_queue(queue)
return queue
def start_job( def start_job(
self, self,
*, *,

View File

@@ -10,7 +10,16 @@ from pydantic import BaseModel, Field
from app.actuators.cache_db import DashboardCache from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, AnomalyEvent, ReconciliationState, SensorWeightGroup from app.actuators.models import (
ActuatorRecord,
AnomalyEvent,
AssignmentCandidate,
BehaviorPattern,
FeedbackKind,
ReconciliationState,
SensorWeightGroup,
SimulationOutcome,
)
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
@@ -52,9 +61,40 @@ class WeightOverrideRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) note: str | None = Field(default=None, max_length=500)
class SimulationRequest(BaseModel):
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
state_options: dict[str, list[str]] = Field(default_factory=dict)
include_current: bool = True
max_results: int = Field(default=8, ge=1, le=20)
class FeedbackRequest(BaseModel): class FeedbackRequest(BaseModel):
correct: bool correct: bool
expected_state: str | None = Field(default=None, max_length=100) expected_state: str | None = Field(default=None, max_length=100)
kind: FeedbackKind | None = None
class DryRunRequest(BaseModel):
enabled: bool
class BackupPayload(BaseModel):
exported_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
records: list[ActuatorRecord] = Field(default_factory=list)
reconciliation: ReconciliationState = Field(default_factory=ReconciliationState)
jobs: JobQueueState = Field(default_factory=JobQueueState)
class RestoreRequest(BaseModel):
backup: BackupPayload
replace_existing: bool = False
class RestoreResult(BaseModel):
restored_records: int = 0
skipped_existing: int = 0
restored_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class SafetyProfileRequest(BaseModel): class SafetyProfileRequest(BaseModel):
@@ -139,6 +179,67 @@ class AnomalyOverview(BaseModel):
anomalies: list[AnomalyEvent] = Field(default_factory=list) anomalies: list[AnomalyEvent] = Field(default_factory=list)
class RoomManagementSensor(BaseModel):
entity_id: str
domain: str
role: str
category: str
friendly_name: str | None = None
device_class: str | None = None
state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
active: bool = False
optional: bool = False
not_required: bool = False
reason: str
class RoomManagementAction(BaseModel):
action_id: str
category: str
actuator_entity_id: str
title: str
when: str
then: str
why: str
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
learnable: bool = True
sort_key: str = ""
class RoomManagementActuator(BaseModel):
actuator_entity_id: str
friendly_name: str | None = None
domain: str
behavior_mode: str
behavior_status: str
lifecycle_status: str
sample_count: int = 0
selected_numeric_entity_id: str | None = None
selected_context_entity_ids: list[str] = Field(default_factory=list)
sensors: list[RoomManagementSensor] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
suggested_actions: list[RoomManagementAction] = Field(default_factory=list)
management_hint: str
class RoomManagementGroup(BaseModel):
room: str
actuator_count: int
sensor_count: int = 0
action_count: int = 0
sensors: list[RoomManagementSensor] = Field(default_factory=list)
actuators: list[RoomManagementActuator] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
suggested_actions: list[RoomManagementAction] = Field(default_factory=list)
continuous_hint: str = "Wird bei Discovery, Reconciliation und Lernrefresh automatisch neu bewertet."
class RoomManagementOverview(BaseModel):
rooms: list[RoomManagementGroup] = Field(default_factory=list)
unmanaged_actuators: list[ActuatorSuggestion] = Field(default_factory=list)
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators( def discover_actuators(
request: Request, request: Request,
@@ -326,13 +427,10 @@ def _dashboard_overview(
include_background: bool, include_background: bool,
include_actuators: bool, include_actuators: bool,
) -> DashboardOverview: ) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request) cache_status = _load_entity_cache_status(request)
raw_entities = cache_payload.get("entities", []) raw_updated_at = cache_status.get("updated_at")
if not isinstance(raw_entities, list):
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
raw_groups = cache_payload.get("discovery_groups", []) raw_groups = cache_status.get("discovery_groups", [])
cached_groups = [ cached_groups = [
DashboardDiscoveryGroup.model_validate(group) DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups for group in raw_groups
@@ -350,6 +448,9 @@ def _dashboard_overview(
job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs) job_p95_duration_ms, slow_job_count, performance_status = _performance_status(jobs)
anomaly_count = sum(record.anomaly_count for record in actuators) anomaly_count = sum(record.anomaly_count for record in actuators)
critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators) critical_anomaly_count = sum(record.critical_anomaly_count for record in actuators)
entity_count = cache_status.get("entity_count")
if not isinstance(entity_count, int):
entity_count = 0
return DashboardOverview( return DashboardOverview(
system=DashboardSystemStatus( system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"), websocket_status=getattr(ws_status, "status", "unavailable"),
@@ -373,9 +474,9 @@ def _dashboard_overview(
critical_anomaly_count=critical_anomaly_count, critical_anomaly_count=critical_anomaly_count,
), ),
cache=EntityCacheStatus( cache=EntityCacheStatus(
available=bool(raw_entities), available=bool(entity_count),
updated_at=updated_at, updated_at=updated_at,
entity_count=len(raw_entities), entity_count=entity_count,
), ),
actuators=actuators, actuators=actuators,
discovery_groups=cached_groups, discovery_groups=cached_groups,
@@ -406,6 +507,191 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
return overview return overview
@router.get("/settings/rooms", response_model=RoomManagementOverview)
def room_management_overview(request: Request) -> RoomManagementOverview:
service = _service(request)
records = service.list_configured()
try:
entities = {entity.entity_id: entity for entity in service._ha_reader.read_entities()}
except Exception:
entities = _load_cached_entity_map(
request,
{
entity_id
for record in records
for entity_id in [
record.actuator_entity_id,
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
*[candidate.entity_id for candidate in record.numeric_candidates[:8]],
*[candidate.entity_id for candidate in record.context_candidates[:12]],
]
if entity_id
},
)
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
configured_ids = {record.actuator_entity_id for record in records}
rooms = _build_room_shells(entities, discovered)
for record in records:
actuator = entities.get(record.actuator_entity_id)
room = (
actuator.area_name
if actuator is not None and actuator.area_name
else _candidate_room(record)
) or "Ohne Raum"
selected_context_ids = set(record.assignment.selected_context_entity_ids)
selected_numeric_id = record.assignment.selected_numeric_entity_id
selected_ids = {selected_numeric_id, *selected_context_ids} - {None}
ranked_candidates = _rank_management_candidates(record)
has_opening_context = any(
candidate.entity_id in selected_context_ids
and (candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in ranked_candidates
)
sensors = [
_management_sensor(
candidate,
entities.get(candidate.entity_id),
active=candidate.entity_id in selected_ids,
optional=(
candidate.role is EntityRole.MEASUREMENT
and candidate.entity_id != selected_numeric_id
),
not_required=(
record.actuator_entity_id.startswith(("light.", "switch."))
and has_opening_context
and (candidate.device_class or "") == "illuminance"
),
)
for candidate in ranked_candidates[:12]
]
actuator_group = RoomManagementActuator(
actuator_entity_id=record.actuator_entity_id,
friendly_name=actuator.friendly_name if actuator is not None else None,
domain=record.actuator_entity_id.split(".", 1)[0],
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
sample_count=record.behavior.sample_count,
selected_numeric_entity_id=selected_numeric_id,
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
sensors=sensors,
prediction_rules=_prediction_rule_lines(record, ranked_candidates),
suggested_actions=_suggest_room_actions(
actuator_id=record.actuator_entity_id,
domain=record.actuator_entity_id.split(".", 1)[0],
sensors=sensors,
configured=True,
),
management_hint=_management_hint(record, has_opening_context),
)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
rooms[room].actuators.append(actuator_group)
rooms[room].actuator_count += 1
rooms[room].prediction_rules = _unique_lines([
*rooms[room].prediction_rules,
*actuator_group.prediction_rules,
])[:8]
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors)
rooms[room].suggested_actions = _merge_room_actions(
rooms[room].suggested_actions,
actuator_group.suggested_actions,
)
for entity_id, descriptor in discovered.items():
if descriptor.role is not EntityRole.ACTUATOR or entity_id in configured_ids:
continue
actuator = entities.get(entity_id)
if actuator is None:
continue
room = _entity_room(actuator)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
room_sensors = _room_sensors_for_actuator(actuator, rooms[room].sensors)
actions = _suggest_room_actions(
actuator_id=entity_id,
domain=actuator.domain,
sensors=room_sensors,
configured=False,
)
rooms[room].actuators.append(
RoomManagementActuator(
actuator_entity_id=entity_id,
friendly_name=actuator.friendly_name,
domain=actuator.domain,
behavior_mode="unmanaged",
behavior_status="suggested",
lifecycle_status="unconfigured",
sensors=room_sensors,
prediction_rules=[_action_rule_line(action) for action in actions[:5]],
suggested_actions=actions,
management_hint=(
"Noch nicht verwaltet: übernehmen, wenn diese Handlung gelernt oder vorgeschlagen werden soll."
),
)
)
rooms[room].actuator_count += 1
rooms[room].prediction_rules = _unique_lines([
*rooms[room].prediction_rules,
*[_action_rule_line(action) for action in actions],
])[:8]
rooms[room].suggested_actions = _merge_room_actions(rooms[room].suggested_actions, actions)
for room in rooms.values():
room.sensors = _merge_room_sensors([], room.sensors)
room.sensor_count = len(room.sensors)
room.action_count = len(room.suggested_actions)
unmanaged = [
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
if suggestion.entity_id not in configured_ids
][:10]
return RoomManagementOverview(
rooms=sorted(rooms.values(), key=lambda item: item.room.lower()),
unmanaged_actuators=unmanaged,
)
@router.get("/backup/export", response_model=BackupPayload)
def export_backup(request: Request) -> BackupPayload:
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 BackupPayload(
records=store.list(),
reconciliation=store.load_reconciliation_state(),
jobs=store.load_job_queue(),
)
@router.post("/backup/restore", response_model=RestoreResult)
def restore_backup(payload: RestoreRequest, request: Request) -> RestoreResult:
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.",
)
existing_ids = {record.actuator_entity_id for record in store.list()}
restored = 0
skipped = 0
for record in payload.backup.records:
if record.actuator_entity_id in existing_ids and not payload.replace_existing:
skipped += 1
continue
store.upsert(record)
restored += 1
store.save_reconciliation_state(payload.backup.reconciliation)
store.save_job_queue(payload.backup.jobs)
return RestoreResult(restored_records=restored, skipped_existing=skipped)
@router.post("/planning/refresh", response_model=list[ActuatorRecord])
def refresh_planning_insights(request: Request) -> list[ActuatorRecord]:
return _behavior(request).refresh_planning_insights()
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -502,6 +788,28 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/simulate", response_model=list[SimulationOutcome])
def simulate_actuator(
actuator_entity_id: str,
payload: SimulationRequest,
request: Request,
) -> list[SimulationOutcome]:
try:
_validate_simulation_payload(payload)
return _behavior(request).simulate(
actuator_entity_id,
sensor_states=payload.sensor_states,
sensor_weights=payload.sensor_weights,
state_options=payload.state_options,
include_current=payload.include_current,
max_results=payload.max_results,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback( def record_feedback(
actuator_entity_id: str, actuator_entity_id: str,
@@ -513,11 +821,24 @@ def record_feedback(
actuator_entity_id, actuator_entity_id,
correct=payload.correct, correct=payload.correct,
expected_state=payload.expected_state, expected_state=payload.expected_state,
kind=payload.kind,
) )
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/dry-run", response_model=ActuatorRecord)
def set_dry_run(
actuator_entity_id: str,
payload: DryRunRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).set_dry_run(actuator_entity_id, enabled=payload.enabled)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/safety", response_model=ActuatorRecord)
def set_safety_profile( def set_safety_profile(
actuator_entity_id: str, actuator_entity_id: str,
@@ -807,6 +1128,465 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}") raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _build_room_shells(
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
) -> dict[str, RoomManagementGroup]:
rooms: dict[str, RoomManagementGroup] = {}
for entity in entities.values():
room = _entity_room(entity)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
descriptor = discovered.get(entity.entity_id)
if descriptor is None or descriptor.role is EntityRole.ACTUATOR:
continue
sensor = _entity_management_sensor(entity, descriptor)
if sensor is not None:
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, [sensor])
return rooms
def _entity_room(entity: HaEntitySummary) -> str:
room = entity.area_name or _room_from_text(entity.friendly_name or entity.device_name or entity.entity_id)
return room or "Ohne Raum"
def _room_from_text(value: str) -> str | None:
normalized = value.replace("_", " ").replace("-", " ").strip()
if not normalized:
return None
known_rooms = {
"abstellkammer": "Abstellkammer",
"abstellraum": "Abstellkammer",
"bad": "Bad",
"badezimmer": "Bad",
"buro": "Büro",
"buero": "Büro",
"flur": "Flur",
"gaeste wc": "Gäste WC",
"gaste wc": "Gäste WC",
"keller": "Keller",
"kuche": "Küche",
"kueche": "Küche",
"schlafzimmer": "Schlafzimmer",
"terrasse": "Terrasse",
"wohnbereich": "Wohnbereich",
"wohnzimmer": "Wohnbereich",
}
lowered = normalized.lower()
for token, room in known_rooms.items():
if token in lowered:
return room
return None
def _entity_management_sensor(
entity: HaEntitySummary,
descriptor: DiscoveredEntity,
) -> RoomManagementSensor | None:
if descriptor.role not in {EntityRole.MEASUREMENT, EntityRole.BINARY_CONTEXT, EntityRole.CONTEXT}:
return None
candidate = AssignmentCandidate(
entity_id=entity.entity_id,
domain=entity.domain,
role=descriptor.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=0.55,
confidence=0.55,
evidence=["Gehört laut Home Assistant zu diesem Bereich."],
)
return _management_sensor(
candidate,
entity,
active=False,
optional=descriptor.role is EntityRole.MEASUREMENT,
not_required=False,
)
def _room_sensors_for_actuator(
actuator: HaEntitySummary,
sensors: list[RoomManagementSensor],
) -> list[RoomManagementSensor]:
preferred = _preferred_sensor_categories(actuator.domain)
ranked = sorted(
sensors,
key=lambda sensor: (
sensor.category not in preferred,
preferred.index(sensor.category) if sensor.category in preferred else 99,
-sensor.confidence,
sensor.friendly_name or sensor.entity_id,
),
)
return ranked[:12]
def _preferred_sensor_categories(domain: str) -> list[str]:
mapping = {
"climate": ["Temperatur", "Luftfeuchtigkeit", "Tür/Fenster", "Präsenz", "Energie"],
"cover": ["Helligkeit", "Präsenz", "Tür/Fenster", "Temperatur"],
"fan": ["Luftfeuchtigkeit", "Präsenz", "Temperatur", "Tür/Fenster", "Energie"],
"humidifier": ["Luftfeuchtigkeit", "Temperatur", "Präsenz"],
"light": ["Präsenz", "Tür/Fenster", "Helligkeit", "Zone/Person"],
"lock": ["Tür/Fenster", "Präsenz", "Zone/Person"],
"siren": ["Sicherheit", "Tür/Fenster", "Präsenz"],
"switch": ["Präsenz", "Tür/Fenster", "Energie", "Luftfeuchtigkeit", "Helligkeit"],
"valve": ["Wasser", "Luftfeuchtigkeit", "Temperatur", "Tür/Fenster"],
}
return mapping.get(domain, ["Präsenz", "Tür/Fenster", "Energie", "Kontext"])
def _candidate_room(record: ActuatorRecord) -> str | None:
for candidate in [*record.context_candidates, *record.numeric_candidates]:
if candidate.area_name:
return candidate.area_name
return None
def _rank_management_candidates(record: ActuatorRecord) -> list[AssignmentCandidate]:
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.context_candidates, *record.numeric_candidates]
}
ranked = sorted(
candidates.values(),
key=lambda item: (
item.entity_id not in selected_ids,
_management_sort_group(item),
-item.confidence,
-item.score,
item.entity_id,
),
)
return ranked
def _management_sort_group(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "01_opening"
if device_class in {"motion", "occupancy", "presence"}:
return "02_presence"
if device_class == "illuminance":
return "03_brightness"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if candidate.role is EntityRole.MEASUREMENT:
return "08_measurement"
return f"20_{candidate.domain}_{device_class}"
def _management_sensor(
candidate: AssignmentCandidate,
entity: HaEntitySummary | None,
*,
active: bool,
optional: bool,
not_required: bool,
) -> RoomManagementSensor:
if not_required:
reason = "Nicht nötig, weil ein Tür-/Öffnungskontakt die Lichtlogik direkt erklärt."
elif active:
reason = "Wird aktuell für Lernen und Vorhersage verwendet."
elif optional:
reason = "Optionaler Messwert; nur verwenden, wenn Helligkeit oder Verbrauch wirklich steuern soll."
else:
reason = ", ".join(candidate.evidence[:2]) or "Naheliegender Kontext aus Raum, Gerät oder Namen."
return RoomManagementSensor(
entity_id=candidate.entity_id,
domain=candidate.domain,
role=candidate.role.value,
category=_sensor_category_label(candidate),
friendly_name=candidate.friendly_name,
device_class=candidate.device_class,
state=entity.state if entity is not None else None,
confidence=candidate.confidence,
active=active,
optional=optional,
not_required=not_required,
reason=reason,
)
def _sensor_category_label(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "Tür/Fenster"
if device_class in {"motion", "occupancy", "presence"}:
return "Präsenz"
if device_class == "illuminance":
return "Helligkeit"
if device_class in {"humidity", "moisture"}:
return "Luftfeuchtigkeit"
if device_class == "temperature":
return "Temperatur"
if device_class in {"power", "energy", "current", "voltage"}:
return "Energie"
if device_class in {"gas", "water"} or candidate.unit_of_measurement in {"m3", "L", "l"}:
return "Wasser"
if device_class in {"problem", "safety", "smoke", "vibration"}:
return "Sicherheit"
if candidate.domain in {"cover"}:
return "Rollo/Cover"
if candidate.domain in {"zone", "person", "device_tracker"}:
return "Zone/Person"
return "Kontext"
def _merge_room_sensors(
existing: list[RoomManagementSensor],
incoming: list[RoomManagementSensor],
) -> list[RoomManagementSensor]:
by_id = {sensor.entity_id: sensor for sensor in existing}
for sensor in incoming:
current = by_id.get(sensor.entity_id)
if current is None:
by_id[sensor.entity_id] = sensor
continue
by_id[sensor.entity_id] = current.model_copy(
update={
"active": current.active or sensor.active,
"optional": current.optional and sensor.optional,
"not_required": current.not_required and sensor.not_required,
"confidence": max(current.confidence, sensor.confidence),
}
)
return sorted(
by_id.values(),
key=lambda item: (
not item.active,
item.not_required,
item.category,
item.friendly_name or item.entity_id,
),
)[:18]
def _prediction_rule_lines(
record: ActuatorRecord,
candidates: list[AssignmentCandidate],
) -> list[str]:
lines = _pattern_rule_lines(record.behavior.patterns)
if lines:
return lines[:8]
selected_contexts = [
candidate
for candidate in candidates
if candidate.entity_id in set(record.assignment.selected_context_entity_ids)
]
result: list[str] = []
for candidate in selected_contexts:
label = candidate.friendly_name or candidate.entity_id
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
result.extend([
f"{label} geöffnet -> {record.actuator_entity_id} an.",
f"{label} geschlossen -> {record.actuator_entity_id} aus.",
])
elif device_class in {"motion", "occupancy", "presence"}:
result.extend([
f"{label} erkannt -> {record.actuator_entity_id} an, bei Licht bevorzugt gedimmt.",
f"{label} aus -> {record.actuator_entity_id} verzögert ausschalten.",
])
elif device_class in {"humidity", "moisture"}:
result.append(f"{label} hoch -> {record.actuator_entity_id} einschalten, bis Feuchte wieder normal ist.")
if record.assignment.selected_numeric_entity_id:
result.append(
f"{record.assignment.selected_numeric_entity_id} nur als Messwert verwenden, nicht als Pflichtsensor."
)
return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."]
def _suggest_room_actions(
*,
actuator_id: str,
domain: str,
sensors: list[RoomManagementSensor],
configured: bool,
) -> list[RoomManagementAction]:
sensor_categories = {sensor.category for sensor in sensors}
sensor_labels = {
sensor.category: sensor.friendly_name or sensor.entity_id
for sensor in sensors
}
confidence_base = 0.78 if configured else 0.58
actions: list[RoomManagementAction] = []
def add(category: str, title: str, when: str, then: str, why: str, confidence: float) -> None:
actions.append(
RoomManagementAction(
action_id=f"{actuator_id}:{category}:{len(actions)}",
category=category,
actuator_entity_id=actuator_id,
title=title,
when=when,
then=then,
why=why,
confidence=round(min(1.0, confidence), 4),
learnable=True,
sort_key=f"{category}:{actuator_id}:{len(actions):02d}",
)
)
presence = sensor_labels.get("Präsenz")
opening = sensor_labels.get("Tür/Fenster")
brightness = sensor_labels.get("Helligkeit")
humidity = sensor_labels.get("Luftfeuchtigkeit")
temperature = sensor_labels.get("Temperatur")
energy = sensor_labels.get("Energie")
water = sensor_labels.get("Wasser")
safety = sensor_labels.get("Sicherheit")
zone = sensor_labels.get("Zone/Person")
if domain == "light":
if opening:
add("licht", "Türlicht", f"{opening} öffnet oder schließt", "Licht passend an/aus schalten.", "Türkontakt erklärt kleine Räume ohne Helligkeitssensor.", confidence_base + 0.12)
if presence:
when = f"{presence} erkennt Anwesenheit"
if brightness:
when += f" und {brightness} ist dunkel"
add("licht", "Präsenzlicht", when, "Licht gedimmt einschalten und bei Abwesenheit verzögert ausschalten.", "Anwesenheit plus Helligkeit vermeidet unnötiges Licht.", confidence_base + (0.12 if brightness else 0.04))
if zone:
add("licht", "Zonenstimmung", f"{zone} wird betreten oder verlassen", "Beim Betreten dimmen, beim Aufstehen heller/weiß stellen und später vorherige Stimmung wiederherstellen.", "Zonen wie Sofa brauchen andere Helligkeit als Durchgang oder Aktivität.", confidence_base)
elif domain in {"switch", "input_boolean"}:
if energy:
add("strom", "Verbrauchssteuerung", f"{energy} zeigt Standby oder Last", "Steckdose/Schalter bei Bedarf schalten oder Standby reduzieren.", "Stromwerte zeigen, ob ein Verbraucher wirklich gebraucht wird.", confidence_base + 0.1)
if presence:
add("strom", "Anwesenheitsschalter", f"{presence} aus", "Verbraucher verzögert ausschalten.", "Schalter und Steckdosen sollen Räume nicht unnötig versorgen.", confidence_base)
if opening:
add("schalter", "Kontaktlogik", f"{opening} wechselt", "Schalter passend zum Öffnen/Schließen setzen.", "Kontaktzustände sind direkte, leicht prüfbare Auslöser.", confidence_base)
elif domain == "climate":
if temperature:
add("heizung", "Temperaturregelung", f"{temperature} weicht vom Ziel ab", "Heizung nach Lernprofil anpassen.", "Temperaturverlauf und Anwesenheit erklären Heizbedarf.", confidence_base + 0.12)
if opening:
add("heizung", "Fenster-Offen-Schutz", f"{opening} offen", "Heizung pausieren oder Sollwert senken.", "Offene Fenster/Türen sollen nicht gegen die Heizung arbeiten.", confidence_base + 0.1)
if presence:
add("heizung", "Anwesenheitswärme", f"{presence} an/aus", "Komforttemperatur nur bei Nutzung halten.", "Anwesenheit macht Heizprofile einfacher und sparsamer.", confidence_base)
elif domain in {"fan", "humidifier"}:
if humidity:
add("belueftung", "Feuchteführung", f"{humidity} steigt oder bleibt hoch", "Lüftung/Entfeuchtung einschalten, später zurücknehmen.", "Feuchtigkeit ist der wichtigste Kontext für Lüftung.", confidence_base + 0.16)
if presence:
add("belueftung", "Nutzungsabhängige Lüftung", f"{presence} aktiv", "Lüftung leise/bedarfsgerecht führen.", "Nutzung erklärt Gerüche, Feuchte und Komfort.", confidence_base)
elif domain == "cover":
if brightness:
add("rollo", "Sonnen-/Dunkellogik", f"{brightness} sehr hell oder dunkel", "Rollo passend beschatten oder öffnen.", "Helligkeit steuert Blendung, Wärme und Tageslicht.", confidence_base + 0.12)
if presence:
add("rollo", "Privatsphäre", f"{presence} und Abend/Dunkelheit", "Rollo für Privatsphäre schließen.", "Anwesenheit und Lichtlage erklären Rollo-Bedarf.", confidence_base)
elif domain in {"valve"}:
if water or humidity:
add("wasser", "Wasser-/Leckschutz", f"{water or humidity} auffällig", "Ventil schließen oder Sperre vorschlagen.", "Wasser- und Feuchtesensoren sind Sicherheitskontext.", confidence_base + 0.14)
elif domain in {"lock", "siren"}:
if opening or safety:
add("sicherheit", "Sicherheitszustand", f"{opening or safety} meldet Änderung", "Sicherheitsaktion vorschlagen, aber nicht ohne Freigabe aktiv ausführen.", "Sicherheitsaktionen brauchen hohe Sicherheit und klare Erklärung.", confidence_base)
if not actions:
add(
domain,
"Allgemeine Lernregel",
"passende Sensoren in diesem Raum ändern sich",
"Aktor im Shadow-Modus beobachten und Vorschläge sammeln.",
"Noch fehlen eindeutige Kontextsensoren; Discovery prüft den Raum weiter.",
max(0.35, confidence_base - 0.18),
)
return sorted(actions, key=lambda item: (-item.confidence, item.sort_key))[:8]
def _merge_room_actions(
existing: list[RoomManagementAction],
incoming: list[RoomManagementAction],
) -> list[RoomManagementAction]:
by_key = {action.action_id: action for action in existing}
for action in incoming:
current = by_key.get(action.action_id)
if current is None or action.confidence > current.confidence:
by_key[action.action_id] = action
return sorted(by_key.values(), key=lambda item: (-item.confidence, item.sort_key))[:18]
def _action_rule_line(action: RoomManagementAction) -> str:
return f"{action.when} -> {action.then}"
def _pattern_rule_lines(patterns: list[BehaviorPattern]) -> list[str]:
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
for pattern in patterns[-120:]:
context = tuple(sorted(pattern.context_states.items()))
key = (pattern.target_state, context)
buckets[key] = buckets.get(key, 0) + 1
attrs[key] = pattern.target_attributes
ordered = sorted(buckets.items(), key=lambda item: (-item[1], item[0]))
lines: list[str] = []
for (target_state, context), count in ordered[:8]:
conditions = ", ".join(f"{entity}={state}" for entity, state in context[:3])
if not conditions:
conditions = "aktueller Zeit-/Nutzungskontext passt"
attr_text = _attribute_text(attrs.get((target_state, context), {}))
lines.append(f"{conditions} -> {target_state}{attr_text} ({count}x gelernt).")
return lines
def _attribute_text(attributes: dict[str, object]) -> str:
if not attributes:
return ""
brightness = attributes.get("brightness")
if isinstance(brightness, int | float):
percent = round(max(0, min(255, float(brightness))) / 255 * 100)
return f", Helligkeit {percent} %"
return ""
def _management_hint(record: ActuatorRecord, has_opening_context: bool) -> str:
if has_opening_context and record.actuator_entity_id.startswith(("light.", "switch.")):
return "Direkte Türlogik: kein Helligkeitssensor nötig, Sensor und Aktor reichen."
if record.behavior.activation_ready:
return "Regeln sind lernbereit; vor Aktivierung Vorhersagen prüfen."
if record.assignment.review_required:
return "Kontext prüfen: Vorschläge übernehmen oder unpassende Sensoren entfernen."
return "Weiter beobachten, bis genug eindeutige Schaltbeispiele vorhanden sind."
def _unique_lines(lines: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for line in lines:
normalized = line.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _validate_simulation_payload(payload: SimulationRequest) -> None:
for entity_id in [
*payload.sensor_states.keys(),
*payload.sensor_weights.keys(),
*payload.state_options.keys(),
]:
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
for entity_id, weight in payload.sensor_weights.items():
if not 0.0 <= weight <= 1.0:
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
for entity_id, states in payload.state_options.items():
if not states:
raise ValueError(f"Keine Zustände für {entity_id} angegeben.")
def _reconciliation_state_or_default(request: Request) -> ReconciliationState: def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None) store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore): if not isinstance(store, ActuatorStore):
@@ -846,6 +1626,11 @@ def _load_cached_entity_map(
) -> dict[str, HaEntitySummary]: ) -> dict[str, HaEntitySummary]:
if not entity_ids: if not entity_ids:
return {} return {}
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
cached_result = cache.load_entity_map(entity_ids)
if cached_result:
return cached_result
payload = _load_entity_cache_payload(request) payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", []) raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list): if not isinstance(raw_entities, list):
@@ -864,6 +1649,29 @@ def _load_cached_entity_map(
return result return result
def _load_entity_cache_status(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache):
status_payload = cache.load_status()
if status_payload.get("entity_count"):
return status_payload
path = _entity_cache_path(request)
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, TypeError, ValueError):
return {}
if not isinstance(payload, dict):
return {}
raw_entities = payload.get("entities", [])
return {
"updated_at": payload.get("updated_at"),
"discovery_groups": payload.get("discovery_groups", []),
"entity_count": len(raw_entities) if isinstance(raw_entities, list) else 0,
}
def _load_entity_cache_payload(request: Request) -> dict[str, object]: def _load_entity_cache_payload(request: Request) -> dict[str, object]:
cache = getattr(request.app.state, "dashboard_cache", None) cache = getattr(request.app.state, "dashboard_cache", None)
if isinstance(cache, DashboardCache): if isinstance(cache, DashboardCache):

View File

@@ -1,14 +1,18 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from itertools import product
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from time import perf_counter
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from app.actuators.models import ( from app.actuators.models import (
ActuatorRecord, ActuatorRecord,
AdaptiveWeightUpdate, AdaptiveWeightUpdate,
AgentInsight,
AnomalyEvent, AnomalyEvent,
ActuatorGroup,
AutomationConflict, AutomationConflict,
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
@@ -16,12 +20,17 @@ from app.actuators.models import (
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
DecisionFactor, DecisionFactor,
DecisionTrace,
ExecutionEvent, ExecutionEvent,
FeedbackKind,
LatencyMeasurement,
ManualOverride, ManualOverride,
ModelSnapshot, ModelSnapshot,
RelatedAutomation, RelatedAutomation,
SafetyProfile, SafetyProfile,
SafetyStage, SafetyStage,
SceneSuggestion,
SimulationOutcome,
TimeProfile, TimeProfile,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
@@ -35,11 +44,31 @@ _MAX_PATTERNS = 500
_MAX_MODEL_SNAPSHOTS = 3 _MAX_MODEL_SNAPSHOTS = 3
_MAX_SNAPSHOT_PATTERNS = 120 _MAX_SNAPSHOT_PATTERNS = 120
_MAX_EXECUTION_EVENTS = 100 _MAX_EXECUTION_EVENTS = 100
_MAX_DECISION_TRACES = 30
_MAX_LATENCY_MEASUREMENTS = 50
_MAX_FEEDBACK_LOG = 50
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3) _CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20) _OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"}) _SAFE_ACTIVE_DOMAINS = frozenset({
"button",
"cover",
"fan",
"humidifier",
"input_button",
"light",
"switch",
})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"}) _AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
_LIGHT_TARGET_ATTRIBUTES = frozenset({
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
})
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -254,7 +283,11 @@ class BehaviorEngine:
context_state_overrides: dict[str, str | None] | None = None, context_state_overrides: dict[str, str | None] | None = None,
context_changed_at_overrides: dict[str, datetime | None] | None = None, context_changed_at_overrides: dict[str, datetime | None] | None = None,
current_entities: Sequence[HaEntitySummary] | None = None, current_entities: Sequence[HaEntitySummary] | None = None,
trigger_entity_id: str | None = None,
trigger_state: str | None = None,
event_received_at: datetime | None = None,
) -> ActuatorRecord: ) -> ActuatorRecord:
started_perf = perf_counter()
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if current_entities is None: if current_entities is None:
@@ -319,10 +352,11 @@ class BehaviorEngine:
record.behavior.patterns, record.behavior.patterns,
current_context=current_context, current_context=current_context,
current_context_changed_at=current_context_changed_at, current_context_changed_at=current_context_changed_at,
context_weights=_context_weights_for(record),
now=now, now=now,
min_support=self._settings.min_behavior_actions, min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes, window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2, causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240),
timezone_name=self._settings.timezone, timezone_name=self._settings.timezone,
) )
if prediction is not None: if prediction is not None:
@@ -344,6 +378,7 @@ class BehaviorEngine:
else: else:
safety_allowed = False safety_allowed = False
safety_blockers = ["Keine fällige Vorhersage."] safety_blockers = ["Keine fällige Vorhersage."]
decision_to_service_ms: int | None = None
decision_factors = _decision_factors_for(record, current_context, prediction) decision_factors = _decision_factors_for(record, current_context, prediction)
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
@@ -383,12 +418,51 @@ class BehaviorEngine:
domain = actuator_entity_id.split(".", 1)[0] domain = actuator_entity_id.split(".", 1)[0]
service = service_for_state(domain, prediction.target_state) service = service_for_state(domain, prediction.target_state)
if service is not None: if service is not None:
if record.behavior.dry_run_enabled:
behavior = behavior.model_copy(
update={
"prediction": prediction.model_copy(
update={
"executed": False,
"execution_reason": (
"Dry-run: Aktion wäre ausgeführt worden."
),
}
),
"dry_run_sample_count": record.behavior.dry_run_sample_count + 1,
"reason": (
f"Dry-run hätte {prediction.target_state!r} mit "
f"{prediction.confidence:.0%} Sicherheit ausgeführt."
),
}
)
return self._save_behavior(
record,
_append_decision_trace(
behavior,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
prediction=prediction,
safety_blockers=safety_blockers,
duration_ms=_elapsed_ms(started_perf),
event_received_at=event_received_at,
decision_to_service_ms=None,
executed=False,
source="event" if event_received_at is not None else "manual",
),
)
try: try:
service_started_perf = perf_counter()
self._ha_reader.call_service( self._ha_reader.call_service(
domain, domain,
service, service,
{"entity_id": actuator_entity_id}, _service_data_for_prediction(
actuator_entity_id,
domain,
prediction,
),
) )
decision_to_service_ms = _elapsed_ms(service_started_perf)
except (HaClientError, ValueError) as exc: except (HaClientError, ValueError) as exc:
logger.error( logger.error(
"Predicted action failed for %s: %s", "Predicted action failed for %s: %s",
@@ -400,7 +474,21 @@ class BehaviorEngine:
"reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}" "reason": f"Vorhersage wurde aus Sicherheitsgründen nicht ausgeführt: {exc}"
} }
) )
return self._save_behavior(record, behavior) return self._save_behavior(
record,
_append_decision_trace(
behavior,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
prediction=prediction,
safety_blockers=[str(exc)],
duration_ms=_elapsed_ms(started_perf),
event_received_at=event_received_at,
decision_to_service_ms=None,
executed=False,
source="event" if event_received_at is not None else "manual",
),
)
event = ExecutionEvent( event = ExecutionEvent(
target_state=prediction.target_state, target_state=prediction.target_state,
executed_at=now, executed_at=now,
@@ -434,14 +522,139 @@ class BehaviorEngine:
) )
} }
) )
behavior = _append_decision_trace(
behavior,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
prediction=prediction,
safety_blockers=safety_blockers,
duration_ms=_elapsed_ms(started_perf),
event_received_at=event_received_at,
decision_to_service_ms=(
decision_to_service_ms
),
executed=bool(prediction is not None and behavior.prediction is not None and behavior.prediction.executed),
source="event" if event_received_at is not None else "manual",
)
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
def simulate(
self,
actuator_entity_id: str,
*,
sensor_states: dict[str, str],
sensor_weights: dict[str, float],
state_options: dict[str, list[str]],
max_results: int,
include_current: bool = True,
) -> list[SimulationOutcome]:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
current_entities = self._ha_reader.read_entities()
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
selected_context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
if not selected_context_ids:
return []
base_context = {
entity_id: entities[entity_id].state
for entity_id in selected_context_ids
if entity_id in entities and entities[entity_id].state is not None
}
base_changed_at = {
entity_id: entities[entity_id].last_changed
for entity_id in base_context
}
context_weights = _context_weights_for(record)
for entity_id, weight in sensor_weights.items():
if entity_id in selected_context_ids:
context_weights[entity_id] = max(0.0, min(1.0, weight))
scenarios = _simulation_contexts(
base_context,
sensor_states=sensor_states,
state_options=state_options,
selected_context_ids=selected_context_ids,
include_current=include_current,
)
outcomes: list[SimulationOutcome] = []
for index, context in enumerate(scenarios[:64], start=1):
prediction_context: dict[str, str | None] = dict(context)
changed_at = dict(base_changed_at)
for entity_id, state in context.items():
if base_context.get(entity_id) != state:
changed_at[entity_id] = now
prediction = predict_behavior(
record.behavior.patterns,
current_context=prediction_context,
current_context_changed_at=changed_at,
context_weights=context_weights,
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
timezone_name=self._settings.timezone,
)
if prediction is not None:
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
recommendation = (
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
if would_execute
else (
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
"aber blockiert: " + " ".join(blockers)
)
)
else:
would_execute = False
blockers = ["Keine fällige Vorhersage."]
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
outcomes.append(
SimulationOutcome(
scenario_id=f"scenario-{index}",
actuator_entity_id=actuator_entity_id,
sensor_states=context,
sensor_weights={
entity_id: round(context_weights.get(entity_id, 1.0), 4)
for entity_id in context
},
prediction=prediction,
decision_factors=_decision_factors_for(
record,
prediction_context,
prediction,
context_weights=context_weights,
),
would_execute=would_execute,
blockers=blockers,
score=round(prediction.confidence if prediction is not None else 0.0, 4),
recommendation=recommendation,
)
)
return sorted(
outcomes,
key=lambda item: (
item.prediction is None,
-item.score,
item.scenario_id,
),
)[:max_results]
def record_feedback( def record_feedback(
self, self,
actuator_entity_id: str, actuator_entity_id: str,
*, *,
correct: bool, correct: bool,
expected_state: str | None = None, expected_state: str | None = None,
kind: FeedbackKind | None = None,
) -> ActuatorRecord: ) -> ActuatorRecord:
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -485,6 +698,7 @@ class BehaviorEngine:
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt." reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
correct_count = record.behavior.correct_feedback_count + 1 correct_count = record.behavior.correct_feedback_count + 1
incorrect_count = record.behavior.incorrect_feedback_count incorrect_count = record.behavior.incorrect_feedback_count
feedback_kind = kind or FeedbackKind.CORRECT
else: else:
target = prediction.target_state if prediction is not None else None target = prediction.target_state if prediction is not None else None
if target: if target:
@@ -515,11 +729,24 @@ class BehaviorEngine:
reason = "Vorhersage wurde vom Nutzer als falsch markiert." reason = "Vorhersage wurde vom Nutzer als falsch markiert."
correct_count = record.behavior.correct_feedback_count correct_count = record.behavior.correct_feedback_count
incorrect_count = record.behavior.incorrect_feedback_count + 1 incorrect_count = record.behavior.incorrect_feedback_count + 1
feedback_kind = kind or FeedbackKind.WRONG
if feedback_kind is FeedbackKind.NEVER_AUTOMATE:
safety = record.behavior.safety.model_copy(
update={
"manual_block": True,
"updated_at": now,
"note": "Durch Nutzerfeedback dauerhaft blockiert.",
}
)
else:
safety = record.behavior.safety
adaptive_updates, manual_override = _adapt_sensor_weights( adaptive_updates, manual_override = _adapt_sensor_weights(
record, record,
current_context, current_context,
correct=correct, correct=correct,
) )
if correct and prediction is not None:
safety = record.behavior.safety
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
update={ update={
"patterns": patterns[-_MAX_PATTERNS:], "patterns": patterns[-_MAX_PATTERNS:],
@@ -532,6 +759,11 @@ class BehaviorEngine:
"last_trained_at": now, "last_trained_at": now,
"correct_feedback_count": correct_count, "correct_feedback_count": correct_count,
"incorrect_feedback_count": incorrect_count, "incorrect_feedback_count": incorrect_count,
"feedback_log": [
*record.behavior.feedback_log,
feedback_kind,
][-_MAX_FEEDBACK_LOG:],
"safety": safety,
"adaptive_weight_updates": [ "adaptive_weight_updates": [
*record.behavior.adaptive_weight_updates, *record.behavior.adaptive_weight_updates,
*adaptive_updates, *adaptive_updates,
@@ -557,6 +789,43 @@ class BehaviorEngine:
) )
return self._save_behavior(record_for_save, behavior) return self._save_behavior(record_for_save, behavior)
def set_dry_run(self, actuator_entity_id: str, *, enabled: bool) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
behavior = record.behavior.model_copy(
update={
"dry_run_enabled": enabled,
"dry_run_started_at": now if enabled else record.behavior.dry_run_started_at,
"reason": (
"Dry-run aktiv; freigegebene Aktionen werden protokolliert, aber nicht geschaltet."
if enabled
else "Dry-run beendet."
),
}
)
return self._save_behavior(record, behavior)
def refresh_planning_insights(self) -> list[ActuatorRecord]:
records = self._store.list()
groups = _derive_actuator_groups(records)
scenes = _derive_scene_suggestions(records)
insights_by_actuator = _derive_agent_insights(records)
updated: list[ActuatorRecord] = []
for record in records:
behavior = record.behavior.model_copy(
update={
"actuator_groups": [
group for group in groups if record.actuator_entity_id in group.member_entity_ids
],
"scene_suggestions": [
scene for scene in scenes if record.actuator_entity_id in scene.member_entity_ids
],
"agent_insights": insights_by_actuator.get(record.actuator_entity_id, []),
}
)
updated.append(self._save_behavior(record, behavior))
return updated
def rollback_model( def rollback_model(
self, self,
actuator_entity_id: str, actuator_entity_id: str,
@@ -859,7 +1128,7 @@ class BehaviorEngine:
blockers.append( blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}." f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
) )
if current_state == prediction.target_state: if _target_reached(record.actuator_entity_id, current_state, prediction):
blockers.append("Zielzustand ist bereits erreicht.") blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed( if not self._cooldown_elapsed(
record.behavior, record.behavior,
@@ -902,12 +1171,16 @@ class BehaviorEngine:
patterns.append( patterns.append(
BehaviorPattern( BehaviorPattern(
target_state=point.state, target_state=point.state,
target_attributes=_target_attributes_for(point),
minute_of_day=local.hour * 60 + local.minute, minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(), weekday=local.weekday(),
context_states=contexts, context_states=contexts,
trigger_entity_id=trigger[0] if trigger else None, trigger_entity_id=trigger[1] if trigger else None,
trigger_from_state=trigger[1] if trigger else None, trigger_from_state=trigger[2] if trigger else None,
trigger_to_state=trigger[2] if trigger else None, trigger_to_state=trigger[3] if trigger else None,
trigger_delay_seconds=(
int(trigger[0].total_seconds()) if trigger else None
),
source=source, source=source,
weight=weight, weight=weight,
observed_at=point.timestamp, observed_at=point.timestamp,
@@ -966,11 +1239,19 @@ class BehaviorEngine:
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem - Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
WebSocket-State-Cache statt aus einer frischen REST-Abfrage. WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
""" """
event_received_at = datetime.now(timezone.utc)
records = self._store.list()
# Aktor direkt evaluieren # Aktor direkt evaluieren
for record in self._store.list(): for record in records:
if record.actuator_entity_id == entity_id: if record.actuator_entity_id == entity_id:
try: try:
self.evaluate(record.actuator_entity_id, current_entities=current_entities) self.evaluate(
record.actuator_entity_id,
current_entities=current_entities,
trigger_entity_id=entity_id,
trigger_state=_event_state(new_state),
event_received_at=event_received_at,
)
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
return return
@@ -979,7 +1260,7 @@ class BehaviorEngine:
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen # Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
affected_actuators = [ affected_actuators = [
record.actuator_entity_id record.actuator_entity_id
for record in self._store.list() for record in records
if ( if (
record.assignment.selected_numeric_entity_id == entity_id record.assignment.selected_numeric_entity_id == entity_id
or entity_id in record.assignment.selected_context_entity_ids or entity_id in record.assignment.selected_context_entity_ids
@@ -992,6 +1273,9 @@ class BehaviorEngine:
context_state_overrides={entity_id: event_state}, context_state_overrides={entity_id: event_state},
context_changed_at_overrides={entity_id: event_changed_at}, context_changed_at_overrides={entity_id: event_changed_at},
current_entities=current_entities, current_entities=current_entities,
trigger_entity_id=entity_id,
trigger_state=event_state,
event_received_at=event_received_at,
) )
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
@@ -1019,6 +1303,208 @@ def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
return parsed return parsed
def _elapsed_ms(started_perf: float) -> int:
return max(0, int((perf_counter() - started_perf) * 1000))
def _append_decision_trace(
behavior: BehaviorState,
*,
trigger_entity_id: str | None,
trigger_state: str | None,
prediction: BehaviorPrediction | None,
safety_blockers: list[str],
duration_ms: int,
event_received_at: datetime | None,
decision_to_service_ms: int | None,
executed: bool,
source: str,
) -> BehaviorState:
now = datetime.now(timezone.utc)
blocked = prediction is None or bool(safety_blockers)
trace = DecisionTrace(
trace_id=f"{now.strftime('%Y%m%d%H%M%S%f')}.{trigger_entity_id or 'manual'}",
created_at=now,
trigger_entity_id=trigger_entity_id,
trigger_state=trigger_state,
target_state=prediction.target_state if prediction is not None else None,
confidence=prediction.confidence if prediction is not None else None,
executed=executed,
blocked=blocked,
reason=(
prediction.execution_reason
if prediction is not None
else behavior.reason
),
blockers=safety_blockers if prediction is not None else ["Keine fällige Vorhersage."],
duration_ms=duration_ms,
)
updated = behavior.model_copy(
update={
"decision_timeline": [
*behavior.decision_timeline,
trace,
][-_MAX_DECISION_TRACES:],
}
)
if event_received_at is None:
return updated
return _append_latency_measurement(
updated,
trigger_entity_id=trigger_entity_id,
event_received_at=event_received_at,
event_to_decision_ms=duration_ms,
decision_to_service_ms=decision_to_service_ms,
executed=executed,
source=source,
)
def _append_latency_measurement(
behavior: BehaviorState,
*,
trigger_entity_id: str | None,
event_received_at: datetime | None,
event_to_decision_ms: int | None,
decision_to_service_ms: int | None,
executed: bool,
source: str,
) -> BehaviorState:
if event_received_at is None:
return behavior
now = datetime.now(timezone.utc)
event_to_done_ms = max(0, int((now - event_received_at).total_seconds() * 1000))
measurement = LatencyMeasurement(
measured_at=now,
trigger_entity_id=trigger_entity_id,
event_to_decision_ms=event_to_decision_ms,
decision_to_service_ms=decision_to_service_ms,
event_to_done_ms=event_to_done_ms,
executed=executed,
source=source,
)
return behavior.model_copy(
update={
"latency_measurements": [
*behavior.latency_measurements,
measurement,
][-_MAX_LATENCY_MEASUREMENTS:],
}
)
def _derive_actuator_groups(records: list[ActuatorRecord]) -> list[ActuatorGroup]:
by_area: dict[str, list[str]] = {}
for record in records:
area = _area_hint(record)
if area:
by_area.setdefault(area, []).append(record.actuator_entity_id)
return [
ActuatorGroup(
group_id=_slug(f"area_{area}"),
name=f"Raum {area}",
area_name=area,
member_entity_ids=sorted(entity_ids),
reason="Aktor-Gruppe aus gemeinsamer Raum-/Kontextzuordnung abgeleitet.",
)
for area, entity_ids in sorted(by_area.items())
if len(entity_ids) >= 2
]
def _derive_scene_suggestions(records: list[ActuatorRecord]) -> list[SceneSuggestion]:
scenes: list[SceneSuggestion] = []
by_context: dict[tuple[str, str], list[str]] = {}
for record in records:
for pattern in record.behavior.patterns:
for entity_id, state in pattern.context_states.items():
by_context.setdefault((entity_id, state), []).append(record.actuator_entity_id)
for (entity_id, state), members in sorted(by_context.items()):
unique_members = sorted(set(members))
if len(unique_members) < 2:
continue
scenes.append(
SceneSuggestion(
scene_id=_slug(f"{entity_id}_{state}"),
label=f"{entity_id} ist {state}",
member_entity_ids=unique_members,
confidence=min(1.0, len(members) / max(3, len(unique_members) * 2)),
reason="Mehrere Aktoren reagieren historisch auf denselben Kontext.",
last_seen_at=max(
(
pattern.observed_at
for record in records
for pattern in record.behavior.patterns
if pattern.context_states.get(entity_id) == state
),
default=None,
),
)
)
return scenes[-20:]
def _derive_agent_insights(records: list[ActuatorRecord]) -> dict[str, list[AgentInsight]]:
result: dict[str, list[AgentInsight]] = {}
for record in records:
insights: list[AgentInsight] = []
if record.behavior.automation_conflicts:
insights.append(
AgentInsight(
insight_id=f"{record.actuator_entity_id}.automation_conflict",
severity="warning",
title="Automation-Konflikt prüfen",
detail="Eine passende HA-Automation kann parallel zu SillyHome schalten.",
action="Automation pausieren oder SillyHome im Shadow-Modus lassen.",
)
)
if record.behavior.latency_measurements:
durations = [
item.event_to_done_ms
for item in record.behavior.latency_measurements
if item.event_to_done_ms is not None
]
if durations and max(durations) > 1500:
insights.append(
AgentInsight(
insight_id=f"{record.actuator_entity_id}.latency",
severity="warning",
title="Schalt-Latenz beobachten",
detail=f"Letzte maximale Event-Latenz: {max(durations)} ms.",
action="WebSocket-Status, HA-Servicezeit und Sensor-Routing pruefen.",
)
)
if record.behavior.incorrect_feedback_count > record.behavior.correct_feedback_count:
insights.append(
AgentInsight(
insight_id=f"{record.actuator_entity_id}.feedback",
severity="warning",
title="Viele negative Feedbacks",
detail="Das Modell trifft aktuell mehr falsche als richtige Entscheidungen.",
action="Kontextzuordnung, Gewichtung oder Modell-Rollback pruefen.",
)
)
result[record.actuator_entity_id] = insights[:5]
return result
def _area_hint(record: ActuatorRecord) -> str | None:
for candidate in [*record.context_candidates, *record.numeric_candidates]:
if candidate.area_name:
return candidate.area_name
return None
def _slug(value: str) -> str:
result = []
for char in value.lower():
if char.isalnum():
result.append(char)
elif char in {".", "_", "-", " "}:
result.append("_")
return "".join(result).strip("_")[:64] or "item"
def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float: def _confidence_threshold_for(profile: SafetyProfile, target_state: str) -> float:
if target_state == "on" and profile.min_confidence_on is not None: if target_state == "on" and profile.min_confidence_on is not None:
return profile.min_confidence_on return profile.min_confidence_on
@@ -1031,15 +1517,21 @@ def _decision_factors_for(
record: ActuatorRecord, record: ActuatorRecord,
current_context: dict[str, str | None], current_context: dict[str, str | None],
prediction: BehaviorPrediction | None, prediction: BehaviorPrediction | None,
*,
context_weights: dict[str, float] | None = None,
) -> list[DecisionFactor]: ) -> list[DecisionFactor]:
factors: list[DecisionFactor] = [] factors: list[DecisionFactor] = []
weights = context_weights or {}
candidates = { candidates = {
candidate.entity_id: candidate candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates] for candidate in [*record.numeric_candidates, *record.context_candidates]
} }
for entity_id, state in current_context.items(): for entity_id, state in current_context.items():
candidate = candidates.get(entity_id) candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0 weight = weights.get(
entity_id,
candidate.effective_weight if candidate is not None else 1.0,
)
relevance = candidate.confidence if candidate is not None else 0.5 relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4) contribution = round(min(1.0, weight * relevance), 4)
factors.append( factors.append(
@@ -1075,6 +1567,62 @@ def _decision_factors_for(
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12] return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
weights = {
candidate.entity_id: candidate.effective_weight
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
override = record.manual_override
if override is not None:
for entity_id, weight in override.sensor_weights.items():
weights[entity_id] = max(0.0, min(1.0, weight))
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
weights[entity_id] = max(0.0, min(1.0, group.weight))
return weights
def _simulation_contexts(
base_context: dict[str, str | None],
*,
sensor_states: dict[str, str],
state_options: dict[str, list[str]],
selected_context_ids: list[str],
include_current: bool,
) -> list[dict[str, str]]:
selected = set(selected_context_ids)
base = {
entity_id: state
for entity_id, state in base_context.items()
if entity_id in selected and state is not None
}
for entity_id, state in sensor_states.items():
if entity_id in selected:
base[entity_id] = state
option_items = [
(
entity_id,
list(dict.fromkeys(state for state in states if state))[:6],
)
for entity_id, states in state_options.items()
if entity_id in selected and states
][:6]
contexts: list[dict[str, str]] = []
if include_current or not option_items:
contexts.append(dict(base))
if option_items:
keys = [item[0] for item in option_items]
value_lists = [item[1] for item in option_items]
for values in product(*value_lists):
context = dict(base)
context.update(dict(zip(keys, values, strict=True)))
if context not in contexts:
contexts.append(context)
if len(contexts) >= 64:
break
return contexts
def _knowledge_lines( def _knowledge_lines(
record: ActuatorRecord, record: ActuatorRecord,
sample_count: int, sample_count: int,
@@ -1408,6 +1956,7 @@ def predict_behavior(
min_support: int, min_support: int,
window_minutes: int, window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None, current_context_changed_at: dict[str, datetime | None] | None = None,
context_weights: dict[str, float] | None = None,
causal_window_seconds: int = 120, causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin", timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None: ) -> BehaviorPrediction | None:
@@ -1417,6 +1966,7 @@ def predict_behavior(
minute_of_day = local.hour * 60 + local.minute minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {} changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {} by_state: dict[str, list[float]] = {}
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
causal_support_by_state: dict[str, int] = {} causal_support_by_state: dict[str, int] = {}
for pattern in patterns: for pattern in patterns:
if pattern.trigger_entity_id and pattern.trigger_to_state: if pattern.trigger_entity_id and pattern.trigger_to_state:
@@ -1430,7 +1980,11 @@ def predict_behavior(
current_context.get(pattern.trigger_entity_id) current_context.get(pattern.trigger_entity_id)
== pattern.trigger_to_state == pattern.trigger_to_state
and trigger_age is not None and trigger_age is not None
and 0 <= trigger_age <= causal_window_seconds and _trigger_age_matches(
trigger_age,
pattern.trigger_delay_seconds,
causal_window_seconds,
)
): ):
continue continue
comparable = [ comparable = [
@@ -1438,17 +1992,16 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items() for entity_id, expected in pattern.context_states.items()
if entity_id in current_context if entity_id in current_context
] ]
context_score = ( context_score = _weighted_context_score(
sum( comparable,
current_context[entity_id] == expected current_context,
for entity_id, expected in comparable context_weights or {},
)
/ len(comparable)
if comparable
else 0.5
) )
score = pattern.weight * (0.85 + 0.15 * context_score) score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score) by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
causal_support_by_state[pattern.target_state] = ( causal_support_by_state[pattern.target_state] = (
causal_support_by_state.get(pattern.target_state, 0) + 1 causal_support_by_state.get(pattern.target_state, 0) + 1
) )
@@ -1469,16 +2022,18 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items() for entity_id, expected in pattern.context_states.items()
if entity_id in current_context if entity_id in current_context
] ]
context_score = ( context_score = _weighted_context_score(
sum(current_context[entity_id] == expected for entity_id, expected in comparable) comparable,
/ len(comparable) current_context,
if comparable context_weights or {},
else 0.5
) )
score = pattern.weight * ( score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score 0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
) )
by_state.setdefault(pattern.target_state, []).append(score) by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
if not by_state: if not by_state:
return None return None
target_state, scores = max( target_state, scores = max(
@@ -1492,6 +2047,9 @@ def predict_behavior(
return None return None
return BehaviorPrediction( return BehaviorPrediction(
target_state=target_state, target_state=target_state,
target_attributes=_aggregate_target_attributes(
attributes_by_state.get(target_state, [])
),
confidence=round(confidence, 4), confidence=round(confidence, 4),
generated_at=now, generated_at=now,
matching_patterns=support, matching_patterns=support,
@@ -1506,9 +2064,106 @@ def predict_behavior(
) )
def _weighted_context_score(
comparable: list[tuple[str, str]],
current_context: dict[str, str | None],
context_weights: dict[str, float],
) -> float:
if not comparable:
return 0.5
total_weight = 0.0
matched_weight = 0.0
for entity_id, expected in comparable:
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
total_weight += weight
if current_context.get(entity_id) == expected:
matched_weight += weight
if total_weight <= 0:
return 0.5
return matched_weight / total_weight
def _trigger_age_matches(
trigger_age_seconds: float,
expected_delay_seconds: int | None,
causal_window_seconds: int,
) -> bool:
if trigger_age_seconds < 0:
return False
if expected_delay_seconds is None or expected_delay_seconds <= 10:
return trigger_age_seconds <= causal_window_seconds
tolerance = max(30, min(90, causal_window_seconds // 2))
return abs(trigger_age_seconds - expected_delay_seconds) <= tolerance
def _aggregate_target_attributes(
weighted_attributes: list[tuple[float, dict[str, object]]],
) -> dict[str, object]:
if not weighted_attributes:
return {}
result: dict[str, object] = {}
numeric_values: dict[str, list[tuple[float, float]]] = {}
categorical_values: dict[str, dict[str, float]] = {}
for score, attributes in weighted_attributes:
for key, value in attributes.items():
if key not in _LIGHT_TARGET_ATTRIBUTES:
continue
if isinstance(value, bool) or value is None:
continue
if isinstance(value, (int, float)):
numeric_values.setdefault(key, []).append((score, float(value)))
else:
categorical_values.setdefault(key, {}).setdefault(str(value), 0.0)
categorical_values[key][str(value)] += score
for key, values in numeric_values.items():
total_weight = sum(score for score, _ in values)
if total_weight <= 0:
continue
result[key] = round(sum(score * value for score, value in values) / total_weight)
for key, values in categorical_values.items():
if key in result:
continue
result[key] = max(values.items(), key=lambda item: (item[1], item[0]))[0]
return result
def _target_attributes_for(point: StateHistoryPoint) -> dict[str, object]:
if point.state != "on":
return {}
return {
key: value
for key, value in point.attributes.items()
if key in _LIGHT_TARGET_ATTRIBUTES and value is not None
}
def _service_data_for_prediction(
actuator_entity_id: str,
domain: str,
prediction: BehaviorPrediction,
) -> dict[str, object]:
data: dict[str, object] = {"entity_id": actuator_entity_id}
if domain == "light" and prediction.target_state == "on":
data.update(prediction.target_attributes)
return data
def _target_reached(
actuator_entity_id: str,
current_state: str,
prediction: BehaviorPrediction,
) -> bool:
domain = actuator_entity_id.split(".", 1)[0]
if domain == "light" and prediction.target_state == "on" and prediction.target_attributes:
return False
return current_state == prediction.target_state
def service_for_state(domain: str, target_state: str) -> str | None: def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}: if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state) return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain in {"button", "input_button"}:
return "press"
if domain == "scene": if domain == "scene":
return "turn_on" if target_state == "on" else None return "turn_on" if target_state == "on" else None
if domain == "cover": if domain == "cover":
@@ -1574,7 +2229,7 @@ def _recent_context_transition(
history: dict[str, StateHistorySeries], history: dict[str, StateHistorySeries],
context_ids: list[str], context_ids: list[str],
timestamp: datetime, timestamp: datetime,
) -> tuple[str, str, str] | None: ) -> tuple[timedelta, str, str, str] | None:
nearest: tuple[timedelta, str, str, str] | None = None nearest: tuple[timedelta, str, str, str] | None = None
for entity_id in context_ids: for entity_id in context_ids:
series = history.get(entity_id) series = history.get(entity_id)
@@ -1593,7 +2248,7 @@ def _recent_context_transition(
previous_state = point.state previous_state = point.state
if nearest is None: if nearest is None:
return None return None
return nearest[1], nearest[2], nearest[3] return nearest
def _circular_minute_distance(left: int, right: int) -> int: def _circular_minute_distance(left: int, right: int) -> int:

View File

@@ -78,7 +78,6 @@ class HaClient:
"filter_entity_id": ",".join(entity_ids), "filter_entity_id": ",".join(entity_ids),
"end_time": end_time.isoformat(), "end_time": end_time.isoformat(),
"minimal_response": "1", "minimal_response": "1",
"no_attributes": "1",
}, },
) )
if not isinstance(payload, list): if not isinstance(payload, list):

View File

@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
class StateHistoryPoint(BaseModel): class StateHistoryPoint(BaseModel):
timestamp: datetime timestamp: datetime
state: str state: str
attributes: dict[str, object] = {}
class StateHistorySeries(BaseModel): class StateHistorySeries(BaseModel):
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
timestamp = _parse_timestamp( timestamp = _parse_timestamp(
raw_entry.get("last_changed") or raw_entry.get("last_updated") raw_entry.get("last_changed") or raw_entry.get("last_updated")
) )
if not points or points[-1].state != raw_state: attributes = raw_entry.get("attributes")
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state)) if not isinstance(attributes, dict):
attributes = {}
if (
not points
or points[-1].state != raw_state
or _relevant_state_attributes(points[-1].attributes)
!= _relevant_state_attributes(attributes)
):
points.append(
StateHistoryPoint(
timestamp=timestamp,
state=raw_state,
attributes=_relevant_state_attributes(attributes),
)
)
if entity_id is not None and points: if entity_id is not None and points:
points.sort(key=lambda point: point.timestamp) points.sort(key=lambda point: point.timestamp)
normalized.append(StateHistorySeries(entity_id=entity_id, points=points)) normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
@@ -176,3 +191,16 @@ def _optional_string(value: object) -> str | None:
if value is None or value == "": if value is None or value == "":
return None return None
return str(value) return str(value)
def _relevant_state_attributes(attributes: dict[str, object]) -> dict[str, object]:
keys = {
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
}
return {key: attributes[key] for key in keys if key in attributes}

View File

@@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.5.4", version="1.7.7",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -167,6 +167,8 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
engine = getattr(app.state, "behavior_engine", None) engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine): if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
await asyncio.to_thread(engine.refresh_planning_insights)
except Exception: except Exception:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.") logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
@@ -221,6 +223,7 @@ async def _startup_reconciliation(app: FastAPI) -> None:
await asyncio.to_thread(service.reconcile_all, "startup") await asyncio.to_thread(service.reconcile_all, "startup")
await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all) await asyncio.to_thread(engine.evaluate_all)
await asyncio.to_thread(engine.refresh_planning_insights)
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.") logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
return return
except Exception as exc: except Exception as exc:
@@ -255,15 +258,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket" ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token) auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
reconnect_delay = 1.0
relevant_entity_ids: set[str] = set()
relevant_loaded_at = 0.0
while True: while True:
if ws_status is not None: if ws_status is not None:
ws_status.status = "connecting" ws_status.status = "connecting"
try: try:
async with websockets.connect( async with websockets.connect(ws_url, ping_interval=None) as websocket:
ws_url,
ping_interval=30,
ping_timeout=30,
) as websocket:
auth_required_msg = await websocket.recv() auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg) auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required": if auth_required_data.get("type") != "auth_required":
@@ -287,6 +289,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader) state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = asyncio.get_running_loop().time()
reconnect_delay = 1.0
if ws_status is not None: if ws_status is not None:
ws_status.status = "connected" ws_status.status = "connected"
ws_status.error = None ws_status.error = None
@@ -313,10 +318,14 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
entity_id = event_data.get("entity_id") entity_id = event_data.get("entity_id")
if not entity_id: if not entity_id:
continue continue
loop_time = asyncio.get_running_loop().time()
if loop_time - relevant_loaded_at >= 10:
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = loop_time
if entity_id not in relevant_entity_ids:
continue
new_state = event_data.get("new_state") new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state) _update_ha_state_cache(state_cache, entity_id, new_state)
if not _is_relevant_state_change(store, str(entity_id)):
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist # Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen # Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread( await asyncio.to_thread(
@@ -334,17 +343,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
websockets.exceptions.InvalidStatus, websockets.exceptions.InvalidStatus,
OSError, OSError,
) as exc: ) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc) delay = reconnect_delay
logger.warning(
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
exc,
delay,
)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(1) await asyncio.sleep(delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
except Exception as exc: except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc) logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(1) await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt # Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -359,7 +375,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
await asyncio.sleep( await asyncio.sleep(
app.state.settings.prediction_interval_seconds app.state.settings.prediction_interval_seconds
if websocket_connected if websocket_connected
else min(5, app.state.settings.prediction_interval_seconds) else max(30, app.state.settings.prediction_interval_seconds)
) )
# Nur ausführen, wenn WebSocket nicht verbunden ist # Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
@@ -395,15 +411,14 @@ def _update_ha_state_cache(
) )
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool: def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
result: set[str] = set()
for record in store.list(): for record in store.list():
if record.actuator_entity_id == entity_id: result.add(record.actuator_entity_id)
return True if record.assignment.selected_numeric_entity_id:
if record.assignment.selected_numeric_entity_id == entity_id: result.add(record.assignment.selected_numeric_entity_id)
return True result.update(record.assignment.selected_context_entity_ids)
if entity_id in record.assignment.selected_context_entity_ids: return result
return True
return False
def _ha_entity_from_event( def _ha_entity_from_event(

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
# SillyHome Next v1.6.0 Operating Guide
v1.6.0 trennt Startansicht, Aktoruebersicht, Discovery und Detaildaten staerker.
## API-Pfade
- `GET /v1/actuators/dashboard/system`
- nur System- und Cache-Metadaten
- keine Aktorenliste
- kein vollstaendiges Entity-Payload aus SQLite
- `GET /v1/actuators/dashboard/start`
- Aktor-Summaries
- Entity-Metadaten nur fuer konfigurierte Aktoren
- keine Discovery-Gruppen und keine Jobliste
- `GET /v1/actuators/discovery`
- steuerbare HA-Entities
- nutzt SQLite-Cache, liest HA nur bei Cache-Miss oder `refresh=true`
- `GET /v1/actuators/{id}/detail`
- genau ein ausgewaehlter Aktor
- kompakte Modell-/Kontextdaten
## Dashboard
- Frontend zeigt Daten an und loest gezielte Aktionen aus.
- Backend liefert schlanke View-Daten.
- Worker aktualisieren HA-Entity-/Discovery-Cache beim Start und danach
stündlich.
- Die Detailansicht gehoert zu einem Aktor und hat eigene Navigation:
Zurueck, anderes Geraet, Aktualisieren.
## Erwartete Wirkung
- Systemstart muss ohne Entity-Materialisierung reagieren.
- Lernen und Details laden nur ihren eigenen Datenkern.
- Discovery bleibt ein eigener Bedarfspfad.
- Texte im Dashboard sind kurz und handlungsnah.

View File

@@ -0,0 +1,45 @@
# SillyHome Next v1.7.0 Operating Guide
v1.7.0 erweitert den Produktivbetrieb um Diagnose, Backup, Dry-run und
Planungshilfen.
## Diagnose
- Jede Auswertung speichert eine kompakte `decision_timeline` am Aktor.
- Event-basierte Auswertungen speichern zusaetzlich `latency_measurements`.
- Die Timeline beantwortet: was war der Ausloeser, welches Ziel wurde
vorhergesagt, wurde geschaltet oder blockiert, und warum.
## Backup und Restore
- `GET /v1/actuators/backup/export` exportiert Aktoren, Reconciliation-Status
und Job-Historie als JSON.
- `POST /v1/actuators/backup/restore` spielt diesen Stand wieder ein.
- Ohne `replace_existing=true` werden vorhandene Aktoren nicht ueberschrieben.
## Dry-run
- `POST /v1/actuators/{entity_id}/dry-run` aktiviert oder beendet den Testmodus.
- Im Dry-run werden freigegebene Aktionen bewertet und protokolliert, aber nicht
an Home Assistant gesendet.
## Feedback
Feedback akzeptiert neben `correct`/`expected_state` nun optionale Typen:
- `correct`
- `wrong`
- `too_early`
- `too_late`
- `never_automate`
`never_automate` setzt eine manuelle Sicherheitssperre am Aktor.
## Planung
`POST /v1/actuators/planning/refresh` berechnet lokale Hinweise:
- Aktorgruppen aus gemeinsamen Raum-/Kontextdaten
- einfache Szenenvorschlaege aus gemeinsamem Kontextverhalten
- Agent-Insights fuer Konflikte, Latenz und auffaelliges Feedback

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "1.5.3" version = "1.7.7"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -87,7 +87,7 @@ def _service(
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None: def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
entity_id="light.abstellkammer", entity_id="light.abstellkammer",
@@ -141,6 +141,46 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_light_with_opening_context_does_not_require_brightness_sensor(tmp_path: Path) -> None:
start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_tuer",
domain="binary_sensor",
device_class="door",
friendly_name="Tür Abstellkammer",
area_name="Abstellkammer",
),
]
service = _service(
tmp_path,
entities,
{"sensor.abstellkammer_illuminance": _points(8, start, 10.0)},
)
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_tuer"]
assert record.assignment.review_required is False
assert "kein Helligkeitssensor erforderlich" in record.assignment.reason
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None: def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
@@ -352,6 +392,100 @@ def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit" assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.lidl_kuche",
domain="light",
friendly_name="Lidl Küche",
),
HaEntitySummary(
entity_id="light.lidl_wohnzimmer",
domain="light",
friendly_name="Lidl Wohnzimmer",
),
HaEntitySummary(
entity_id="binary_sensor.pir_kuche_motion_detection",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Küche",
),
HaEntitySummary(
entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Wohnzimmer",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.lidl_kuche")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.pir_kuche_motion_detection"
]
def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="button.smart_mailbox_als_geleert_markieren",
domain="button",
friendly_name="Smart Mailbox Als geleert markieren",
),
HaEntitySummary(
entity_id="binary_sensor.schrank_strasse_open",
domain="binary_sensor",
device_class="door",
friendly_name="Schrank Straße",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.schrank_strasse_open"
]
assert record.assignment.review_required is False
def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="humidifier.gastewc_luftung",
domain="humidifier",
friendly_name="GästeWC Lüftung",
),
HaEntitySummary(
entity_id="sensor.pir_gastewc_humidity",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Gäste WC Luftfeuchtigkeit",
),
HaEntitySummary(
entity_id="input_boolean.gaste_wc_occupied",
domain="input_boolean",
friendly_name="gaste_wc_occupied",
),
]
service = _service(
tmp_path,
entities,
{"sensor.pir_gastewc_humidity": _points(8, start, 55.0)},
)
record = service.configure_actuator("humidifier.gastewc_luftung")
assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity"
assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [

View File

@@ -1,18 +1,20 @@
from __future__ import annotations from __future__ import annotations
from time import perf_counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from time import perf_counter
from zoneinfo import ZoneInfo
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.actuators.cache_db import DashboardCache from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import JobStatus, ModelSnapshot from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import ( from app.ha.history import (
@@ -125,6 +127,22 @@ def _install_service(tmp_path: Path) -> None:
area_name="Abstellkammer", area_name="Abstellkammer",
state="off", state="off",
), ),
HaEntitySummary(
entity_id="fan.bad_luefter",
domain="fan",
friendly_name="Bad Lüfter",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_luftfeuchtigkeit",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Bad Luftfeuchtigkeit",
area_name="Bad",
state="68",
),
HaEntitySummary( HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes", entity_id="sensor.pfsense_interface_vpn_inbytes",
domain="sensor", domain="sensor",
@@ -271,6 +289,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75 assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
def test_actuator_simulation_ranks_sensor_states_without_switching(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
},
)
store = app.state.actuator_store
record = store.get("light.abstellkammer")
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Europe/Berlin"))
local_minute = local.hour * 60 + local.minute
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "on",
},
source="user",
weight=1.0,
observed_at=now,
)
for _ in range(3)
]
patterns.extend(
[
BehaviorPattern(
target_state="off",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "off",
},
source="user",
weight=0.5,
observed_at=now,
)
for _ in range(3)
]
)
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"patterns": patterns,
"sample_count": len(patterns),
"high_confidence_sample_count": len(patterns),
"activation_ready": True,
"activation_reason": "Testfreigabe.",
}
)
}
)
)
response = client.post(
"/v1/actuators/light.abstellkammer/simulate",
json={
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
"sensor_weights": {
"binary_sensor.abstellkammer_motion": 1.0,
"sensor.abstellkammer_illuminance": 0.25,
},
"max_results": 2,
},
)
assert response.status_code == 200
payload = response.json()
assert len(payload) == 2
assert payload[0]["prediction"]["target_state"] == "on"
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
assert app.state.ha_reader.service_calls == []
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None: def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
@@ -353,6 +456,51 @@ def test_feedback_adapts_sensor_weights_and_model_can_rollback(tmp_path: Path) -
assert rollback.json()["behavior"]["active_model_version"] == version_id assert rollback.json()["behavior"]["active_model_version"] == version_id
def test_feedback_never_automate_sets_manual_block(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post(
"/v1/actuators",
json={"actuator_entity_id": "light.abstellkammer"},
)
feedback = client.post(
"/v1/actuators/light.abstellkammer/feedback",
json={"correct": False, "kind": "never_automate"},
)
assert feedback.status_code == 200
payload = feedback.json()
assert payload["behavior"]["safety"]["manual_block"] is True
assert payload["behavior"]["feedback_log"][-1] == "never_automate"
def test_backup_export_restore_and_planning_refresh(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
backup = client.get("/v1/actuators/backup/export")
dry_run = client.post(
"/v1/actuators/light.abstellkammer/dry-run",
json={"enabled": True},
)
planning = client.post("/v1/actuators/planning/refresh")
restore = client.post(
"/v1/actuators/backup/restore",
json={"backup": backup.json(), "replace_existing": True},
)
assert backup.status_code == 200
assert backup.json()["records"][0]["actuator_entity_id"] == "light.abstellkammer"
assert dry_run.status_code == 200
assert dry_run.json()["behavior"]["dry_run_enabled"] is True
assert planning.status_code == 200
assert "agent_insights" in planning.json()[0]["behavior"]
assert restore.status_code == 200
assert restore.json()["restored_records"] == 1
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None: def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
@@ -384,7 +532,7 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N
assert reader.read_entities_calls == calls_before assert reader.read_entities_calls == calls_before
payload = response.json() payload = response.json()
assert payload["cache"]["available"] is True assert payload["cache"]["available"] is True
assert payload["cache"]["entity_count"] == 4 assert payload["cache"]["entity_count"] == 6
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht" assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
assert payload["discovery_groups"] assert payload["discovery_groups"]
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery" assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
@@ -467,6 +615,56 @@ def test_dashboard_reports_performance_budget_and_anomalies(tmp_path: Path) -> N
assert anomalies_response.json() assert anomalies_response.json()
def test_dashboard_system_and_start_do_not_materialize_entity_cache(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.get("/v1/actuators/discovery")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
def fail_full_payload(self: DashboardCache) -> dict[str, object]:
raise AssertionError("full entity payload must not be loaded")
monkeypatch.setattr(DashboardCache, "load_entities_payload", fail_full_payload)
system_response = client.get("/v1/actuators/dashboard/system")
start_response = client.get("/v1/actuators/dashboard/start")
assert system_response.status_code == 200
assert system_response.json()["actuators"] == []
assert system_response.json()["cache"]["entity_count"] == 6
assert start_response.status_code == 200
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/settings/rooms")
assert response.status_code == 200
payload = response.json()
room = payload["rooms"][0]
assert room["room"] == "Abstellkammer"
assert room["actuator_count"] == 1
actuator = room["actuators"][0]
assert actuator["actuator_entity_id"] == "light.abstellkammer"
assert actuator["sensors"]
assert actuator["prediction_rules"]
assert room["suggested_actions"]
assert room["sensor_count"] >= 2
assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"])
bad = next(item for item in payload["rooms"] if item["room"] == "Bad")
assert bad["actuator_count"] == 1
assert bad["actuators"][0]["lifecycle_status"] == "unconfigured"
assert any(action["category"] == "belueftung" for action in bad["suggested_actions"])
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None: def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)

View File

@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
) is None ) is None
def test_prediction_respects_learned_context_delay() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"input_boolean.gaste_wc_occupied": "on"},
trigger_entity_id="input_boolean.gaste_wc_occupied",
trigger_from_state="off",
trigger_to_state="on",
trigger_delay_seconds=180,
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
]
early = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=30)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
due = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=185)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
assert early is None
assert due is not None
assert due.target_state == "on"
def test_light_prediction_carries_brightness_attributes() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
target_attributes={"brightness": brightness},
minute_of_day=now.astimezone().hour * 60 + now.astimezone().minute,
weekday=now.astimezone().weekday(),
context_states={"binary_sensor.pir_kuche_motion_detection": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago, brightness in zip((3, 2, 1), (80, 90, 100), strict=True)
]
prediction = predict_behavior(
patterns,
current_context={"binary_sensor.pir_kuche_motion_detection": "on"},
now=now,
min_support=3,
window_minutes=30,
)
assert prediction is not None
assert prediction.target_attributes["brightness"] == 90
def test_state_change_uses_websocket_context_state_for_immediate_action( def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -778,3 +853,129 @@ def test_state_change_uses_event_cache_without_rest_state_query(
assert reader.service_calls == [ assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"}) ("light", "turn_on", {"entity_id": "light.storage"})
] ]
def test_event_evaluation_records_decision_timeline_and_latency(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="on",
last_changed=now,
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.evaluate(
"light.storage",
trigger_entity_id="binary_sensor.storage_door",
trigger_state="on",
event_received_at=now,
)
trace = result.behavior.decision_timeline[-1]
latency = result.behavior.latency_measurements[-1]
assert trace.trigger_entity_id == "binary_sensor.storage_door"
assert trace.target_state == "on"
assert trace.executed is True
assert latency.trigger_entity_id == "binary_sensor.storage_door"
assert latency.executed is True
def test_dry_run_records_without_calling_service(tmp_path: Path) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={"selected_context_entity_ids": ["binary_sensor.storage_door"]}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"dry_run_enabled": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="on",
last_changed=now,
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.evaluate("light.storage")
assert reader.service_calls == []
assert result.behavior.dry_run_sample_count == 1
assert result.behavior.decision_timeline[-1].executed is False

View File

@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
assert [point.state for point in result[0].points] == ["off", "on"] assert [point.state for point in result[0].points] == ["off", "on"]
def test_normalize_state_history_keeps_light_attribute_changes() -> None:
result = normalize_state_history_payload(
[
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {"brightness": 80, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:00:00+00:00",
},
{
"state": "on",
"attributes": {"brightness": 120, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:05:00+00:00",
},
]
]
)
assert [point.attributes["brightness"] for point in result[0].points] == [80, 120]
def test_normalize_logbook_preserves_action_origin() -> None: def test_normalize_logbook_preserves_action_origin() -> None:
result = normalize_logbook_payload( result = normalize_logbook_payload(
[ [

View File

@@ -9,19 +9,23 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text assert "Geräte, Lernen, Freigaben und Systemzustand" in response.text
assert "So gehst du vor" in response.text assert "So gehst du vor" not in response.text
assert "Steuerung" in response.text assert "Discovery & Einrichtung" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text assert "Entity-ID" in response.text
assert "Oder aus Liste wählen" in response.text assert "Geräteliste" in response.text
assert "Liste durchsuchen" in response.text assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text assert "Vorschläge können Home Assistant stark abfragen" not in response.text
assert '<option value="detail">Details</option>' not in response.text assert '<option value="detail">Details</option>' not in response.text
assert "Wie gewohnt bedienen" in response.text assert "Wie gewohnt bedienen" not in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
assert "Freigabestatus" in response.text assert "Freigabestatus" in response.text
assert "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort." in response.text
assert "room-management" in response.text
assert 'api("v1/actuators/settings/rooms")' in response.text
assert "Auswahl speichern" in response.text
assert "SillyHome übernehmen lassen" in response.text assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" in response.text assert "Pausieren" in response.text
@@ -29,10 +33,12 @@ def test_dashboard_is_served_at_root() -> None:
assert "Aktuelle Situation auswerten" in response.text assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text assert "Entity-IDs manuell ergänzen" in response.text
assert "Zurück zur Übersicht" in response.text
assert "Anderes Gerät" in response.text
assert "manual-context-freeform" in response.text assert "manual-context-freeform" in response.text
assert "Diese Kontext-Auswahl speichern" in response.text assert "Diese Kontext-Auswahl speichern" in response.text
assert "manual-context-select" in response.text assert "manual-context-select" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" not in response.text
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert "record.behavior.activation_ready" in response.text assert "record.behavior.activation_ready" in response.text
@@ -41,7 +47,7 @@ def test_dashboard_is_served_at_root() -> None:
assert 'api("v1/actuators")' not in response.text assert 'api("v1/actuators")' not in response.text
assert 'api("v1/actuators/summary")' in response.text assert 'api("v1/actuators/summary")' in response.text
assert 'api("v1/entities")' not in response.text assert 'api("v1/entities")' not in response.text
assert 'details class="collapsible"' in response.text assert 'details class="collapsible"' not in response.text
assert 'class="group-panel"' in response.text assert 'class="group-panel"' in response.text
assert "cachedDetailHtml" in response.text assert "cachedDetailHtml" in response.text
assert "refreshOverviewInBackground" in response.text assert "refreshOverviewInBackground" in response.text

View File

@@ -94,8 +94,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
connect.assert_called_once_with( connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket", "ws://homeassistant:8123/api/websocket",
ping_interval=30, ping_interval=None,
ping_timeout=30,
) )
assert fake_ws.sent == [ assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"}, {"type": "auth", "access_token": "test-token"},
@@ -127,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
assert mock_app.state.ws_status.error is None assert mock_app.state.ws_status.error is None
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
async def run_test() -> None:
fake_ws = _FakeWebSocket(
[
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
mock_app = MagicMock()
mock_app.state.settings = MagicMock()
mock_app.state.settings.ha_url = "http://homeassistant:8123"
mock_app.state.settings.ha_token = "test-token"
mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store")
mock_store.configure("light.test")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()
anyio.run(run_test)
assert mock_engine.state_changes == []
def test_lifespan_skips_event_listener_without_ha_config() -> None: def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI() app = FastAPI()
app.state.settings = MagicMock() app.state.settings = MagicMock()