Compare commits
13 Commits
feature/co
...
v0.7.10
| Author | SHA1 | Date | |
|---|---|---|---|
| c5f42a39a9 | |||
| 309b33b812 | |||
| 9db7cde179 | |||
| 3140f65527 | |||
| 5727053951 | |||
| 658516cd96 | |||
| 8cd8f3e3b7 | |||
| 09e14689a3 | |||
| d87d3abc00 | |||
| 2ae5576b8f | |||
| 51d23e0a9a | |||
| c8f491ba1a | |||
| f9c7c27e00 |
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,5 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.10 - 2026-06-16
|
||||
- WebSocket-State-Changes aktualisieren einen internen Home-Assistant-State-
|
||||
Cache und werten Aktoren direkt gegen diesen frischen Event-Zustand aus.
|
||||
- Event-Auswertungen lösen keine REST-Statusabfrage mehr aus, bevor sie
|
||||
aktive Aktoren schalten.
|
||||
|
||||
## 0.7.9 - 2026-06-15
|
||||
- Event-basierte Vorhersagen verwenden den frischen Sensorzustand direkt aus
|
||||
dem Home-Assistant-WebSocket-Event, damit Kontextwechsel ohne REST-Race sofort
|
||||
bewertet und geschaltet werden können
|
||||
- Regressionstest stellt sicher, dass ein Türsensor-Event trotz veraltetem
|
||||
HA-Snapshot direkt `light.turn_on` auslöst
|
||||
|
||||
## 0.7.8 - 2026-06-15
|
||||
- Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive-
|
||||
Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu
|
||||
aufgebaut werden
|
||||
- Fallback-Auswertung läuft bei getrenntem WebSocket kurzfristig alle 5 Sekunden,
|
||||
damit übernommene Aktoren nicht ohne Steuerung bleiben
|
||||
|
||||
## 0.7.7 - 2026-06-15
|
||||
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
|
||||
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
|
||||
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
|
||||
|
||||
## 0.7.6 - 2026-06-14
|
||||
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
|
||||
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
|
||||
|
||||
## 0.7.5 - 2026-06-14
|
||||
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
|
||||
gemeinsame Geräte/Tokens oder echte globale Außenwerte
|
||||
- Diagnosewerte wie MQTT-, WiFi-, Restart- und Connect-Zähler werden nicht mehr
|
||||
als fachliche Kontextvorschläge angeboten
|
||||
|
||||
## 0.7.4 - 2026-06-14
|
||||
- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
|
||||
Roh-Liste aller Sensoren und Zustände
|
||||
- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und
|
||||
durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden
|
||||
- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne
|
||||
Dropdown-Treffer gespeichert werden können
|
||||
- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne
|
||||
fachlichen Bezug nicht mehr als Standardvorschläge auf
|
||||
|
||||
## 0.7.3 - 2026-06-14
|
||||
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
|
||||
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
|
||||
- Aktor-Auswahl auf tatsächlich sicher steuerbare Domains begrenzt:
|
||||
`light`, `switch`, `cover`, `fan`, `humidifier`
|
||||
- Neue manuelle Kontext-Zuordnung pro Aktor: Haupt-Messsensor optional setzen und
|
||||
mehrere relevante Kontext-Entities wie PIR, Außenhelligkeit, Luftfeuchtigkeit
|
||||
oder andere Lichtzustände auswählen
|
||||
- Dashboard-Dropdown durch echtes Select plus Suche ersetzt; mobile Bedienung und
|
||||
Aktor-Details enthalten Speichern/Neu-laden-Aktionen für manuelle Kontextwahl
|
||||
|
||||
## 0.7.2 - 2026-06-14
|
||||
- Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA-
|
||||
Installationen nicht mehr am Template-Ausgabe-Limit scheitern
|
||||
- Nicht über die HA-Config-API exponierte Automationen werden leise übersprungen,
|
||||
statt wiederholt Warnungen in die Logs zu schreiben
|
||||
- Dashboard für mobile Nutzung optimiert: Sticky-Schnellnavigation, Karten statt
|
||||
breiter Tabelle, größere Touch-Ziele und bessere Detail-/Menüführung
|
||||
- WebSocket-Status ist direkt im Dashboard-Systemstatus sichtbar
|
||||
|
||||
## 0.7.1 - 2026-06-14
|
||||
- Event-basierter Home-Assistant-WebSocket-Listener authentifiziert sich jetzt
|
||||
mit dem echten HA-WebSocket-Protokoll (`auth_required` -> `auth` -> `auth_ok`)
|
||||
- Kompatibilität mit aktuellen `websockets`-Versionen wiederhergestellt
|
||||
- WebSocket-Healthcheck und Event-Listener-Tests laufen ohne zusätzliches
|
||||
Async-Pytest-Plugin
|
||||
- Add-on-Version angehoben, damit Home Assistant das aktualisierte Image baut
|
||||
|
||||
## 0.7.0 - 2026-06-14
|
||||
- Freie Eingabe von Home-Assistant-Entitätsnamen mit Vorschlagsliste
|
||||
- Freigabestatus und Blockadegrund sind in Übersicht und Details immer sichtbar
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "0.7.0"
|
||||
version: "0.7.10"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.actuators.models import (
|
||||
AssignmentSource,
|
||||
LifecycleAuditEntry,
|
||||
LifecycleStatus,
|
||||
ManualOverride,
|
||||
ModelLifecycleState,
|
||||
ReconciliationState,
|
||||
model_id_for_actuator,
|
||||
@@ -47,6 +48,7 @@ _STOPWORDS = frozenset(
|
||||
"light",
|
||||
"licht",
|
||||
"lichtschalter",
|
||||
"monitoring",
|
||||
"power",
|
||||
"sensor",
|
||||
"state",
|
||||
@@ -55,6 +57,7 @@ _STOPWORDS = frozenset(
|
||||
"value",
|
||||
}
|
||||
)
|
||||
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"})
|
||||
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
|
||||
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
|
||||
_NUMERIC_MIN_MARGIN = 0.18
|
||||
@@ -62,6 +65,45 @@ _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
|
||||
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
|
||||
_MAX_CONTEXT_SELECTIONS = 5
|
||||
_AUDIT_LIMIT = 20
|
||||
_MANUAL_CONTEXT_DOMAINS = frozenset({
|
||||
"binary_sensor",
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"light",
|
||||
"person",
|
||||
"sensor",
|
||||
"switch",
|
||||
"weather",
|
||||
})
|
||||
_CONTEXT_SUGGESTION_LIMIT = 120
|
||||
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
|
||||
_DIAGNOSTIC_TOKENS = frozenset({
|
||||
"basic",
|
||||
"battery",
|
||||
"connect",
|
||||
"count",
|
||||
"diagnostic",
|
||||
"firmware",
|
||||
"gesehen",
|
||||
"last",
|
||||
"linkquality",
|
||||
"knoten",
|
||||
"knotens",
|
||||
"mqtt",
|
||||
"node",
|
||||
"reason",
|
||||
"restart",
|
||||
"rssi",
|
||||
"signal",
|
||||
"ssid",
|
||||
"status",
|
||||
"uptime",
|
||||
"wifi",
|
||||
"zuletzt",
|
||||
})
|
||||
|
||||
|
||||
class ActuatorReconciliationService:
|
||||
@@ -88,11 +130,117 @@ class ActuatorReconciliationService:
|
||||
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
|
||||
return self._store.get(actuator_entity_id)
|
||||
|
||||
def suggest_context_options(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
*,
|
||||
limit: int = _CONTEXT_SUGGESTION_LIMIT,
|
||||
) -> list[HaEntitySummary]:
|
||||
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
|
||||
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
|
||||
actuator = entities.get(actuator_entity_id)
|
||||
if actuator is None:
|
||||
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
|
||||
selected_ids = _selected_context_ids(self._store.get(actuator_entity_id))
|
||||
ranked: list[tuple[float, str, HaEntitySummary]] = []
|
||||
for entity in entities.values():
|
||||
if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS:
|
||||
continue
|
||||
role = _manual_context_role(entity, discovered.get(entity.entity_id))
|
||||
score, _ = _score_candidate(
|
||||
actuator,
|
||||
entity,
|
||||
role,
|
||||
context=role is not EntityRole.MEASUREMENT,
|
||||
)
|
||||
selected = entity.entity_id in selected_ids
|
||||
if selected:
|
||||
score = max(score, 1.0)
|
||||
if not selected and (
|
||||
_is_diagnostic_context(entity)
|
||||
or not _has_context_relationship(actuator, entity)
|
||||
):
|
||||
continue
|
||||
if not selected and score < 0.1:
|
||||
continue
|
||||
ranked.append((score, _context_sort_group(entity), entity))
|
||||
ranked.sort(
|
||||
key=lambda item: (
|
||||
-item[0],
|
||||
item[1],
|
||||
item[2].area_name or "",
|
||||
item[2].friendly_name or item[2].entity_id,
|
||||
item[2].entity_id,
|
||||
)
|
||||
)
|
||||
return [entity for _, _, entity in ranked[:limit]]
|
||||
|
||||
def delete_actuator(self, actuator_entity_id: str) -> None:
|
||||
model_id = model_id_for_actuator(actuator_entity_id)
|
||||
self._registry.archive(model_id)
|
||||
self._store.delete(actuator_entity_id)
|
||||
|
||||
def set_manual_assignment(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
*,
|
||||
numeric_entity_id: str | None,
|
||||
context_entity_ids: list[str],
|
||||
note: str | None = None,
|
||||
) -> ActuatorRecord:
|
||||
now = datetime.now(timezone.utc)
|
||||
record = self._store.get(actuator_entity_id)
|
||||
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
|
||||
actuator = entities.get(actuator_entity_id)
|
||||
if actuator is None:
|
||||
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
|
||||
selected_context_ids = list(dict.fromkeys(context_entity_ids))
|
||||
selected_ids = [
|
||||
entity_id
|
||||
for entity_id in [numeric_entity_id, *selected_context_ids]
|
||||
if entity_id
|
||||
]
|
||||
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
|
||||
if missing:
|
||||
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(missing)}")
|
||||
if actuator_entity_id in selected_ids:
|
||||
raise ValueError("Der Aktor selbst kann nicht als Kontextsensor verwendet werden.")
|
||||
|
||||
override = ManualOverride(
|
||||
numeric_entity_id=numeric_entity_id,
|
||||
context_entity_ids=selected_context_ids,
|
||||
updated_at=now,
|
||||
note=note,
|
||||
)
|
||||
assignment = self._manual_assignment(override)
|
||||
lifecycle = self._reconcile_lifecycle(
|
||||
actuator=actuator,
|
||||
assignment=assignment,
|
||||
lifecycle=record.lifecycle.model_copy(update={"last_reconciled_at": now}),
|
||||
now=now,
|
||||
)
|
||||
updated = record.model_copy(
|
||||
update={
|
||||
"assignment": assignment,
|
||||
"manual_override": override,
|
||||
"numeric_candidates": _merge_manual_candidates(
|
||||
record.numeric_candidates,
|
||||
entities,
|
||||
[numeric_entity_id] if numeric_entity_id else [],
|
||||
role=EntityRole.MEASUREMENT,
|
||||
),
|
||||
"context_candidates": _merge_manual_candidates(
|
||||
record.context_candidates,
|
||||
entities,
|
||||
selected_context_ids,
|
||||
role=EntityRole.CONTEXT,
|
||||
),
|
||||
"lifecycle": lifecycle,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return self._store.upsert(updated)
|
||||
|
||||
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
|
||||
state = self._store.load_reconciliation_state().model_copy(
|
||||
update={
|
||||
@@ -198,10 +346,14 @@ class ActuatorReconciliationService:
|
||||
),
|
||||
context=True,
|
||||
)
|
||||
assignment = self._select_assignment(
|
||||
actuator=actuator,
|
||||
numeric_candidates=numeric_candidates,
|
||||
context_candidates=context_candidates,
|
||||
assignment = (
|
||||
self._manual_assignment(record.manual_override)
|
||||
if record.manual_override is not None
|
||||
else self._select_assignment(
|
||||
actuator=actuator,
|
||||
numeric_candidates=numeric_candidates,
|
||||
context_candidates=context_candidates,
|
||||
)
|
||||
)
|
||||
lifecycle = self._reconcile_lifecycle(
|
||||
actuator=actuator,
|
||||
@@ -212,7 +364,7 @@ class ActuatorReconciliationService:
|
||||
updated = record.model_copy(
|
||||
update={
|
||||
"assignment": assignment,
|
||||
"manual_override": None,
|
||||
"manual_override": record.manual_override,
|
||||
"numeric_candidates": numeric_candidates,
|
||||
"context_candidates": context_candidates,
|
||||
"lifecycle": lifecycle,
|
||||
@@ -228,6 +380,23 @@ class ActuatorReconciliationService:
|
||||
)
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _manual_assignment(override: ManualOverride) -> AssignmentSelection:
|
||||
selected_context_ids = list(dict.fromkeys(override.context_entity_ids))
|
||||
selected_count = len(selected_context_ids) + (1 if override.numeric_entity_id else 0)
|
||||
return AssignmentSelection(
|
||||
selected_numeric_entity_id=override.numeric_entity_id,
|
||||
selected_context_entity_ids=selected_context_ids,
|
||||
source=AssignmentSource.MANUAL,
|
||||
confidence=1.0 if selected_count else 0.0,
|
||||
review_required=selected_count == 0,
|
||||
reason=(
|
||||
f"Manuell festgelegt: {selected_count} Kontext-Entity(s) werden verwendet."
|
||||
if selected_count
|
||||
else "Manuelle Zuordnung enthält noch keine Kontext-Entities."
|
||||
),
|
||||
)
|
||||
|
||||
def _select_assignment(
|
||||
self,
|
||||
*,
|
||||
@@ -507,6 +676,73 @@ def _filter_candidates(
|
||||
return result
|
||||
|
||||
|
||||
def _selected_context_ids(record: ActuatorRecord) -> set[str]:
|
||||
result = set(record.assignment.selected_context_entity_ids)
|
||||
if record.assignment.selected_numeric_entity_id:
|
||||
result.add(record.assignment.selected_numeric_entity_id)
|
||||
if record.manual_override is not None:
|
||||
result.update(record.manual_override.context_entity_ids)
|
||||
if record.manual_override.numeric_entity_id:
|
||||
result.add(record.manual_override.numeric_entity_id)
|
||||
return result
|
||||
|
||||
|
||||
def _manual_context_role(
|
||||
entity: HaEntitySummary,
|
||||
discovered: DiscoveredEntity | None,
|
||||
) -> EntityRole:
|
||||
if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED:
|
||||
return discovered.role
|
||||
if entity.domain == "sensor":
|
||||
return EntityRole.MEASUREMENT
|
||||
if entity.domain == "binary_sensor":
|
||||
return EntityRole.BINARY_CONTEXT
|
||||
return EntityRole.CONTEXT
|
||||
|
||||
|
||||
def _context_sort_group(entity: HaEntitySummary) -> str:
|
||||
device_class = entity.device_class or ""
|
||||
if device_class in {"motion", "occupancy", "presence"}:
|
||||
return "01_presence"
|
||||
if device_class in {"illuminance"}:
|
||||
return "02_brightness"
|
||||
if device_class in {"door", "garage_door", "opening", "window"}:
|
||||
return "03_opening"
|
||||
if device_class in {"humidity", "moisture"}:
|
||||
return "04_humidity"
|
||||
if device_class in {"power", "energy", "current", "voltage"}:
|
||||
return "05_power"
|
||||
if entity.domain in {"light", "switch"}:
|
||||
return "06_states"
|
||||
return f"20_{entity.domain}_{device_class}"
|
||||
|
||||
|
||||
def _is_diagnostic_context(entity: HaEntitySummary) -> bool:
|
||||
tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||
return bool(tokens.intersection(_DIAGNOSTIC_TOKENS))
|
||||
|
||||
|
||||
def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary) -> bool:
|
||||
if (
|
||||
actuator.area_name
|
||||
and entity.area_name
|
||||
and actuator.area_name == entity.area_name
|
||||
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
|
||||
):
|
||||
return True
|
||||
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
|
||||
return True
|
||||
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
|
||||
return True
|
||||
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
||||
return True
|
||||
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||
return bool(
|
||||
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
||||
and entity.device_class in {"illuminance", "humidity", "temperature"}
|
||||
)
|
||||
|
||||
|
||||
def _score_candidate(
|
||||
actuator: HaEntitySummary,
|
||||
entity: HaEntitySummary,
|
||||
@@ -522,7 +758,12 @@ def _score_candidate(
|
||||
if overlap:
|
||||
score += min(0.4, 0.1 * len(overlap))
|
||||
evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}")
|
||||
if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name:
|
||||
if (
|
||||
actuator.area_name
|
||||
and entity.area_name
|
||||
and actuator.area_name == entity.area_name
|
||||
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
|
||||
):
|
||||
score += 0.35
|
||||
evidence.append(f"Gleicher Bereich: {actuator.area_name}")
|
||||
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
|
||||
@@ -547,9 +788,59 @@ def _score_candidate(
|
||||
if context and role is EntityRole.BINARY_CONTEXT:
|
||||
score += 0.05
|
||||
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
|
||||
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
|
||||
"illuminance",
|
||||
"humidity",
|
||||
"temperature",
|
||||
}:
|
||||
score += 0.1
|
||||
evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.")
|
||||
return round(min(score, 1.0), 4), evidence
|
||||
|
||||
|
||||
def _merge_manual_candidates(
|
||||
candidates: list[AssignmentCandidate],
|
||||
entities: dict[str, HaEntitySummary],
|
||||
selected_entity_ids: list[str],
|
||||
*,
|
||||
role: EntityRole,
|
||||
) -> list[AssignmentCandidate]:
|
||||
by_id = {candidate.entity_id: candidate for candidate in candidates}
|
||||
for entity_id in selected_entity_ids:
|
||||
existing = by_id.get(entity_id)
|
||||
if existing is not None:
|
||||
by_id[entity_id] = existing.model_copy(
|
||||
update={
|
||||
"auto_accepted": True,
|
||||
"confidence": 1.0,
|
||||
"evidence": [
|
||||
*existing.evidence,
|
||||
"Manuell vom Nutzer als relevant festgelegt.",
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
entity = entities.get(entity_id)
|
||||
if entity is None:
|
||||
continue
|
||||
by_id[entity_id] = AssignmentCandidate(
|
||||
entity_id=entity.entity_id,
|
||||
domain=entity.domain,
|
||||
role=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=1.0,
|
||||
confidence=1.0,
|
||||
auto_accepted=True,
|
||||
evidence=["Manuell vom Nutzer als relevant festgelegt."],
|
||||
)
|
||||
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
|
||||
|
||||
|
||||
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
||||
if context:
|
||||
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
|
||||
@@ -565,7 +856,7 @@ def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
||||
return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
|
||||
|
||||
|
||||
def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
|
||||
def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False) -> set[str]:
|
||||
raw_values = [
|
||||
entity.entity_id,
|
||||
entity.friendly_name,
|
||||
@@ -577,7 +868,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
|
||||
if value is None:
|
||||
continue
|
||||
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
||||
if len(token) < 3 or token in _STOPWORDS:
|
||||
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
|
||||
continue
|
||||
tokens.add(token)
|
||||
return tokens
|
||||
|
||||
@@ -32,6 +32,12 @@ class AutomationControlRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ManualAssignmentRequest(BaseModel):
|
||||
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
context_entity_ids: list[str] = Field(default_factory=list)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
|
||||
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
|
||||
@@ -42,6 +48,19 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
|
||||
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
|
||||
|
||||
|
||||
@router.get("/context-options", response_model=list[HaEntitySummary])
|
||||
def context_options(
|
||||
request: Request,
|
||||
actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"),
|
||||
) -> list[HaEntitySummary]:
|
||||
if actuator_entity_id is None:
|
||||
return []
|
||||
try:
|
||||
return _service(request).suggest_context_options(actuator_entity_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("", response_model=list[ActuatorRecord])
|
||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||
return _service(request).list_configured()
|
||||
@@ -116,6 +135,27 @@ def set_activation(
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{actuator_entity_id}/assignment", response_model=ActuatorRecord)
|
||||
def set_manual_assignment(
|
||||
actuator_entity_id: str,
|
||||
payload: ManualAssignmentRequest,
|
||||
request: Request,
|
||||
) -> ActuatorRecord:
|
||||
try:
|
||||
record = _service(request).set_manual_assignment(
|
||||
actuator_entity_id,
|
||||
numeric_entity_id=payload.numeric_entity_id,
|
||||
context_entity_ids=payload.context_entity_ids,
|
||||
note=payload.note,
|
||||
)
|
||||
_behavior(request).train(record.actuator_entity_id)
|
||||
return _behavior(request).evaluate(record.actuator_entity_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{actuator_entity_id}/related-automations/refresh",
|
||||
response_model=ActuatorRecord,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -18,6 +19,7 @@ from app.actuators.store import ActuatorStore
|
||||
from app.config import Settings
|
||||
from app.ha.exceptions import HaClientError
|
||||
from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
_MAX_PATTERNS = 500
|
||||
@@ -187,23 +189,32 @@ class BehaviorEngine:
|
||||
results.append(record)
|
||||
return results
|
||||
|
||||
def evaluate(self, actuator_entity_id: str) -> ActuatorRecord:
|
||||
def evaluate(
|
||||
self,
|
||||
actuator_entity_id: str,
|
||||
*,
|
||||
context_state_overrides: dict[str, str | None] | None = None,
|
||||
context_changed_at_overrides: dict[str, datetime | None] | None = None,
|
||||
current_entities: Sequence[HaEntitySummary] | None = None,
|
||||
) -> ActuatorRecord:
|
||||
record = self._store.get(actuator_entity_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
|
||||
except HaClientError as exc:
|
||||
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
|
||||
return self._save_behavior(
|
||||
record,
|
||||
record.behavior.model_copy(
|
||||
update={
|
||||
"last_evaluated_at": now,
|
||||
"prediction": None,
|
||||
"reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}",
|
||||
}
|
||||
),
|
||||
)
|
||||
if current_entities is None:
|
||||
try:
|
||||
current_entities = self._ha_reader.read_entities()
|
||||
except HaClientError as exc:
|
||||
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
|
||||
return self._save_behavior(
|
||||
record,
|
||||
record.behavior.model_copy(
|
||||
update={
|
||||
"last_evaluated_at": now,
|
||||
"prediction": None,
|
||||
"reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}",
|
||||
}
|
||||
),
|
||||
)
|
||||
entities = {entity.entity_id: entity for entity in current_entities}
|
||||
actuator = entities.get(actuator_entity_id)
|
||||
if actuator is None:
|
||||
return self._save_behavior(
|
||||
@@ -230,6 +241,22 @@ class BehaviorEngine:
|
||||
entity_id: entities[entity_id].last_changed
|
||||
for entity_id in current_context
|
||||
}
|
||||
selected_context_ids = {
|
||||
entity_id
|
||||
for entity_id in (
|
||||
[
|
||||
record.assignment.selected_numeric_entity_id,
|
||||
*record.assignment.selected_context_entity_ids,
|
||||
]
|
||||
)
|
||||
if entity_id
|
||||
}
|
||||
for entity_id, state in (context_state_overrides or {}).items():
|
||||
if entity_id in selected_context_ids and state is not None:
|
||||
current_context[entity_id] = state
|
||||
for entity_id, changed_at in (context_changed_at_overrides or {}).items():
|
||||
if entity_id in current_context:
|
||||
current_context_changed_at[entity_id] = changed_at or now
|
||||
prediction = predict_behavior(
|
||||
record.behavior.patterns,
|
||||
current_context=current_context,
|
||||
@@ -608,6 +635,72 @@ class BehaviorEngine:
|
||||
)
|
||||
return self._store.upsert(updated)
|
||||
|
||||
def handle_state_change(
|
||||
self,
|
||||
entity_id: str,
|
||||
new_state: dict[str, object] | None,
|
||||
*,
|
||||
current_entities: Sequence[HaEntitySummary] | None = None,
|
||||
) -> None:
|
||||
"""Wird bei jedem HA-State-Change aufgerufen und löst sofortige Vorhersage aus.
|
||||
|
||||
- Wenn entity_id ein Aktor ist: evaluate() direkt.
|
||||
- Wenn entity_id ein Kontext-Entity ist: alle betroffenen Aktoren evaluieren.
|
||||
- Wenn current_entities gesetzt ist, kommt die Auswertung direkt aus dem
|
||||
WebSocket-State-Cache statt aus einer frischen REST-Abfrage.
|
||||
"""
|
||||
# Aktor direkt evaluieren
|
||||
for record in self._store.list():
|
||||
if record.actuator_entity_id == entity_id:
|
||||
try:
|
||||
self.evaluate(record.actuator_entity_id, current_entities=current_entities)
|
||||
except Exception:
|
||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
|
||||
return
|
||||
event_state = _event_state(new_state)
|
||||
event_changed_at = _event_changed_at(new_state) or datetime.now(timezone.utc)
|
||||
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
|
||||
affected_actuators = [
|
||||
record.actuator_entity_id
|
||||
for record in self._store.list()
|
||||
if (
|
||||
record.assignment.selected_numeric_entity_id == entity_id
|
||||
or entity_id in record.assignment.selected_context_entity_ids
|
||||
)
|
||||
]
|
||||
for actuator_entity_id in affected_actuators:
|
||||
try:
|
||||
self.evaluate(
|
||||
actuator_entity_id,
|
||||
context_state_overrides={entity_id: event_state},
|
||||
context_changed_at_overrides={entity_id: event_changed_at},
|
||||
current_entities=current_entities,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
|
||||
|
||||
|
||||
def _event_state(new_state: dict[str, object] | None) -> str | None:
|
||||
if not isinstance(new_state, dict):
|
||||
return None
|
||||
state = new_state.get("state")
|
||||
return state if isinstance(state, str) else None
|
||||
|
||||
|
||||
def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
|
||||
if not isinstance(new_state, dict):
|
||||
return None
|
||||
value = new_state.get("last_changed") or new_state.get("last_updated")
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def predict_behavior(
|
||||
patterns: list[BehaviorPattern],
|
||||
|
||||
@@ -22,6 +22,7 @@ logger = logging.getLogger(__name__)
|
||||
_ENTITY_ID_PATTERN = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_SERVICE_PART_PATTERN = re.compile(r"^[a-z0-9_]+$")
|
||||
_MAX_HISTORY_SECONDS = 31 * 24 * 60 * 60
|
||||
_METADATA_BATCH_SIZE = 200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -141,6 +142,17 @@ class HaClient:
|
||||
return {}
|
||||
if any(not _ENTITY_ID_PATTERN.fullmatch(entity_id) for entity_id in entity_ids):
|
||||
raise ValueError("entity_id enthält ein ungültiges Format.")
|
||||
result: dict[str, dict[str, str | None]] = {}
|
||||
for start in range(0, len(entity_ids), _METADATA_BATCH_SIZE):
|
||||
result.update(
|
||||
self._list_entity_metadata_batch(entity_ids[start:start + _METADATA_BATCH_SIZE])
|
||||
)
|
||||
return result
|
||||
|
||||
def _list_entity_metadata_batch(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
) -> dict[str, dict[str, str | None]]:
|
||||
template = _metadata_template(entity_ids)
|
||||
rendered = self._post_text("/api/template", {"template": template})
|
||||
try:
|
||||
|
||||
@@ -82,18 +82,11 @@ _BINARY_CONTEXT_CLASSES = frozenset({
|
||||
"window",
|
||||
})
|
||||
_ACTUATOR_DOMAINS = frozenset({
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"light",
|
||||
"lock",
|
||||
"scene",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"valve",
|
||||
})
|
||||
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
|
||||
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})
|
||||
|
||||
@@ -6,7 +6,7 @@ from threading import RLock
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
from app.ha.exceptions import HaClientError
|
||||
from app.ha.exceptions import HaClientError, HaHttpError
|
||||
|
||||
from app.ha.client import HaClient
|
||||
from app.ha.discovery import DiscoveredEntity, discover_entities
|
||||
@@ -167,6 +167,19 @@ class HaReader:
|
||||
continue
|
||||
try:
|
||||
config = self._client.get_automation_config(config_id)
|
||||
except HaHttpError as exc:
|
||||
if exc.status_code == 404:
|
||||
logger.info(
|
||||
"Automation config not exposed by Home Assistant for %s.",
|
||||
raw_entity_id,
|
||||
)
|
||||
continue
|
||||
logger.warning(
|
||||
"Automation config unavailable for %s: %s",
|
||||
raw_entity_id,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
except (HaClientError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"Automation config unavailable for %s: %s",
|
||||
|
||||
247
app/main.py
247
app/main.py
@@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import websockets
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -16,17 +20,32 @@ from app.behavior.engine import BehaviorEngine
|
||||
from app.config import load_settings
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
from backend.routes.ml import init_ml_routes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class _WsStatus:
|
||||
"""Einfacher Status-Tracker für den WebSocket-Listener.
|
||||
|
||||
Wird als Attribut an app.state gehängt und enthält:
|
||||
- status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
|
||||
- error: str | None (Fehlermeldung bei status=error)
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self.status: str = "disconnected"
|
||||
self.error: str | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings = app.state.settings
|
||||
client: HaClient | None = None
|
||||
reconcile_task: asyncio.Task[None] | None = None
|
||||
prediction_task: asyncio.Task[None] | None = None
|
||||
event_listener_task: asyncio.Task[None] | None = None
|
||||
fallback_task: asyncio.Task[None] | None = None
|
||||
app.state.registry = ModelRegistry(settings.model_store)
|
||||
app.state.actuator_store = ActuatorStore(settings.actuator_store)
|
||||
if hasattr(app.state, "ha_reader"):
|
||||
@@ -54,11 +73,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
store=app.state.actuator_store,
|
||||
settings=settings,
|
||||
)
|
||||
app.state.ws_status = _WsStatus()
|
||||
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
|
||||
await asyncio.to_thread(app.state.behavior_engine.train_all)
|
||||
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
|
||||
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
||||
prediction_task = asyncio.create_task(_periodic_prediction(app))
|
||||
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
||||
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -66,10 +87,14 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
reconcile_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await reconcile_task
|
||||
if prediction_task is not None:
|
||||
prediction_task.cancel()
|
||||
if event_listener_task is not None:
|
||||
event_listener_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await prediction_task
|
||||
await event_listener_task
|
||||
if fallback_task is not None:
|
||||
fallback_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await fallback_task
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
@@ -77,7 +102,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Next API",
|
||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||
version="0.7.0",
|
||||
version="0.7.10",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
@@ -94,6 +119,19 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/health/websocket")
|
||||
def websocket_health() -> dict[str, object]:
|
||||
"""Gibt den aktuellen Status des WebSocket-Listeners zurück.
|
||||
|
||||
Antwort:
|
||||
- status: "disconnected" | "connecting" | "connected" | "reconnecting" | "error"
|
||||
- error: str | None (nur bei status=error)
|
||||
"""
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
if ws_status is None:
|
||||
return {"status": "unavailable", "error": "WebSocket-Listener nicht initialisiert"}
|
||||
return {"status": ws_status.status, "error": ws_status.error}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> FileResponse:
|
||||
@@ -115,10 +153,195 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
|
||||
await asyncio.to_thread(engine.train_all)
|
||||
|
||||
|
||||
async def _periodic_prediction(app: FastAPI) -> None:
|
||||
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
"""Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus."""
|
||||
settings = app.state.settings
|
||||
engine = app.state.behavior_engine
|
||||
ha_reader = getattr(app.state, "ha_reader", None)
|
||||
store = app.state.actuator_store
|
||||
if (
|
||||
not isinstance(engine, BehaviorEngine)
|
||||
or not isinstance(store, ActuatorStore)
|
||||
or not isinstance(ha_reader, HaReader)
|
||||
):
|
||||
logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert")
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert"
|
||||
return
|
||||
state_cache: dict[str, HaEntitySummary] = {}
|
||||
ha_url = str(settings.ha_url).rstrip("/")
|
||||
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||
auth_token = cast(str, settings.ha_token)
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
while True:
|
||||
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
|
||||
engine = getattr(app.state, "behavior_engine", None)
|
||||
if not isinstance(engine, BehaviorEngine):
|
||||
continue
|
||||
await asyncio.to_thread(engine.evaluate_all)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connecting"
|
||||
try:
|
||||
async with websockets.connect(ws_url, ping_interval=None) as websocket:
|
||||
auth_required_msg = await websocket.recv()
|
||||
auth_required_data = json.loads(auth_required_msg)
|
||||
if auth_required_data.get("type") != "auth_required":
|
||||
logger.error("Unerwartete WebSocket-Authentifizierungsaufforderung")
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = "Unerwartete Authentifizierungsaufforderung"
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps({"type": "auth", "access_token": auth_token}))
|
||||
auth_result_msg = await websocket.recv()
|
||||
auth_result_data = json.loads(auth_result_msg)
|
||||
if auth_result_data.get("type") != "auth_ok":
|
||||
logger.error("WebSocket-Authentifizierung fehlgeschlagen")
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = "Authentifizierung fehlgeschlagen"
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
||||
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connected"
|
||||
ws_status.error = None
|
||||
# Auf alle State Changes subscriben
|
||||
subscribe_msg = {
|
||||
"id": 1,
|
||||
"type": "subscribe_events",
|
||||
"event_type": "state_changed"
|
||||
}
|
||||
await websocket.send(json.dumps(subscribe_msg))
|
||||
while True:
|
||||
message = await websocket.recv()
|
||||
try:
|
||||
data = json.loads(message)
|
||||
if data.get("type") != "event":
|
||||
continue
|
||||
event = data.get("event", {})
|
||||
if event.get("event_type") != "state_changed":
|
||||
continue
|
||||
event_data = event.get("data", {})
|
||||
if not isinstance(event_data, dict):
|
||||
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
|
||||
continue
|
||||
entity_id = event_data.get("entity_id")
|
||||
if not entity_id:
|
||||
continue
|
||||
new_state = event_data.get("new_state")
|
||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||
await asyncio.to_thread(
|
||||
engine.handle_state_change,
|
||||
entity_id,
|
||||
new_state,
|
||||
current_entities=list(state_cache.values()),
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
|
||||
except Exception as exc:
|
||||
logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
|
||||
except (websockets.exceptions.ConnectionClosed, OSError) as exc:
|
||||
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "reconnecting"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(5)
|
||||
except Exception as exc:
|
||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||
async def _fallback_prediction(app: FastAPI) -> None:
|
||||
"""Periodische Vorhersage als Fallback, wenn WebSocket-Listener nicht verbunden ist.
|
||||
|
||||
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
|
||||
"""
|
||||
while True:
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
websocket_connected = ws_status is not None and ws_status.status == "connected"
|
||||
await asyncio.sleep(
|
||||
app.state.settings.prediction_interval_seconds
|
||||
if websocket_connected
|
||||
else min(5, app.state.settings.prediction_interval_seconds)
|
||||
)
|
||||
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
if ws_status is None or ws_status.status != "connected":
|
||||
engine = getattr(app.state, "behavior_engine", None)
|
||||
if isinstance(engine, BehaviorEngine):
|
||||
logger.debug(
|
||||
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
|
||||
ws_status.status if ws_status else "unavailable",
|
||||
)
|
||||
await asyncio.to_thread(engine.evaluate_all)
|
||||
|
||||
|
||||
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:
|
||||
return {entity.entity_id: entity for entity in reader.read_entities()}
|
||||
|
||||
|
||||
def _update_ha_state_cache(
|
||||
state_cache: dict[str, HaEntitySummary],
|
||||
entity_id: str,
|
||||
new_state: object,
|
||||
) -> None:
|
||||
if not isinstance(new_state, dict):
|
||||
state_cache.pop(entity_id, None)
|
||||
return
|
||||
state_cache[entity_id] = _ha_entity_from_event(
|
||||
entity_id,
|
||||
new_state,
|
||||
state_cache.get(entity_id),
|
||||
)
|
||||
|
||||
|
||||
def _ha_entity_from_event(
|
||||
entity_id: str,
|
||||
new_state: dict[str, object],
|
||||
previous: HaEntitySummary | None,
|
||||
) -> HaEntitySummary:
|
||||
attributes = new_state.get("attributes")
|
||||
attr = attributes if isinstance(attributes, dict) else {}
|
||||
state_class = _optional_event_string(attr.get("state_class"))
|
||||
device_class = _optional_event_string(attr.get("device_class"))
|
||||
unit_of_measurement = _optional_event_string(attr.get("unit_of_measurement"))
|
||||
friendly_name = _optional_event_string(attr.get("friendly_name"))
|
||||
return HaEntitySummary(
|
||||
entity_id=entity_id,
|
||||
domain=entity_id.split(".", 1)[0],
|
||||
state=_optional_event_string(new_state.get("state")),
|
||||
last_changed=_event_datetime(new_state.get("last_changed"))
|
||||
or _event_datetime(new_state.get("last_updated")),
|
||||
state_class=state_class or (previous.state_class if previous else None),
|
||||
device_class=device_class or (previous.device_class if previous else None),
|
||||
unit_of_measurement=unit_of_measurement
|
||||
or (previous.unit_of_measurement if previous else None),
|
||||
friendly_name=friendly_name or (previous.friendly_name if previous else None),
|
||||
area_id=previous.area_id if previous else None,
|
||||
area_name=previous.area_name if previous else None,
|
||||
device_id=previous.device_id if previous else None,
|
||||
device_name=previous.device_name if previous else None,
|
||||
)
|
||||
|
||||
|
||||
def _optional_event_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _event_datetime(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SillyHome Next</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; }
|
||||
body { margin: 0; }
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
||||
body { margin: 0; font-size:16px; }
|
||||
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||
h1,h2,h3 { margin: 0 0 12px; }
|
||||
header p { margin: 5px 0; color: #c3d1dc; }
|
||||
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
||||
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
||||
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
||||
.quick-nav a.primary { background:#23715b; }
|
||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
||||
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
||||
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
||||
@@ -21,10 +25,12 @@
|
||||
.warn { color: #f3c969; }
|
||||
.bad { color: #ff8f8f; }
|
||||
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 7px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; }
|
||||
button { margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
||||
select[multiple] { min-height:190px; }
|
||||
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
||||
button.secondary { background: #37495c; }
|
||||
button.danger { background: #7b3434; }
|
||||
button.compact { width:auto; min-width:120px; margin-right:8px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
|
||||
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
|
||||
ul { margin: 8px 0; padding-left: 18px; }
|
||||
@@ -33,6 +39,40 @@
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
||||
.muted { color:#9fb0be; }
|
||||
.card-list { display:grid; gap:12px; }
|
||||
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
||||
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
|
||||
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
|
||||
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
||||
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.actions button { flex:1 1 180px; margin-top:0; }
|
||||
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
|
||||
.manual-entry { min-height:80px; resize:vertical; }
|
||||
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
|
||||
optgroup { color:#cfe0ec; background:#101820; }
|
||||
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
||||
@media (max-width: 760px) {
|
||||
header { padding:18px 14px; }
|
||||
header h1 { font-size:1.55rem; }
|
||||
main { display:block; padding:10px; }
|
||||
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
||||
.steps { grid-template-columns:1fr; }
|
||||
.grid-two { grid-template-columns:1fr; }
|
||||
.metric-grid { grid-template-columns:1fr 1fr; }
|
||||
.actions { display:grid; grid-template-columns:1fr; }
|
||||
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
|
||||
.quick-nav { padding:8px 10px; }
|
||||
.quick-nav a { padding:10px 11px; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.metric-grid { grid-template-columns:1fr; }
|
||||
body { font-size:15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,8 +81,15 @@
|
||||
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
|
||||
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
||||
</header>
|
||||
<nav class="quick-nav" aria-label="Schnellnavigation">
|
||||
<a class="primary" href="#choose">Gerät wählen</a>
|
||||
<a href="#observed">Beobachtet</a>
|
||||
<a href="#detail">Details</a>
|
||||
<a href="#status-section">Status</a>
|
||||
<a href="#guide">Ablauf</a>
|
||||
</nav>
|
||||
<main>
|
||||
<section class="wide">
|
||||
<section class="wide" id="guide">
|
||||
<h2>So gehst du vor</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
@@ -66,7 +113,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="status-section">
|
||||
<h2>Systemstatus</h2>
|
||||
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
|
||||
<div id="status">Prüfung läuft ...</div>
|
||||
@@ -74,23 +121,44 @@
|
||||
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section id="choose">
|
||||
<h2>1. Gerät zum Lernen auswählen</h2>
|
||||
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
|
||||
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
||||
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
||||
<datalist id="actuator-options"></datalist>
|
||||
<div class="inline-controls">
|
||||
<div>
|
||||
<label for="actuator-domain-filter">Typ</label>
|
||||
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
|
||||
<option value="">Alle steuerbaren Typen</option>
|
||||
<option value="light">Lichter</option>
|
||||
<option value="switch">Schalter / Helper</option>
|
||||
<option value="cover">Rollläden / Cover</option>
|
||||
<option value="fan">Lüftung / Ventilatoren</option>
|
||||
<option value="humidifier">Befeuchter / Entfeuchter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="actuator-search">Liste durchsuchen</label>
|
||||
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<label for="actuator-select">Oder aus Liste wählen</label>
|
||||
<select id="actuator-select" onchange="selectActuatorFromList()">
|
||||
<option value="">Geräteliste wird geladen ...</option>
|
||||
</select>
|
||||
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
|
||||
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
|
||||
</section>
|
||||
|
||||
<section class="wide">
|
||||
<section class="wide" id="observed">
|
||||
<h2>2. Beobachtete Geräte</h2>
|
||||
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
|
||||
<div id="configured-actuators">Noch nicht geladen.</div>
|
||||
</section>
|
||||
|
||||
<section class="wide">
|
||||
<section class="wide" id="detail">
|
||||
<h2>3. Lernfortschritt und Freigabe</h2>
|
||||
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
|
||||
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
|
||||
@@ -104,6 +172,9 @@ const escapeHtml = value => String(value ?? "")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
let currentActuatorId = null;
|
||||
let actuatorChoices = [];
|
||||
let contextOptions = [];
|
||||
let manualContextState = {options: [], selected: new Set()};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
||||
@@ -139,12 +210,74 @@ function behaviorLabel(record) {
|
||||
return "sammelt Handlungen";
|
||||
}
|
||||
|
||||
function predictionLabel(record) {
|
||||
return record.behavior.prediction
|
||||
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
||||
: "Keine fällige Aktion";
|
||||
}
|
||||
|
||||
function entityLabel(entity) {
|
||||
const area = entity.area_name || "Ohne Bereich";
|
||||
const name = entity.friendly_name || entity.entity_id;
|
||||
return `${area} - ${name} (${entity.entity_id})`;
|
||||
}
|
||||
|
||||
function normalizedSearch(value) {
|
||||
return String(value || "").toLowerCase().replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function matchesSearch(entity, query) {
|
||||
if (!query) return true;
|
||||
return normalizedSearch([
|
||||
entity.entity_id,
|
||||
entity.friendly_name,
|
||||
entity.area_name,
|
||||
entity.device_name,
|
||||
entity.device_class,
|
||||
entity.domain,
|
||||
].filter(Boolean).join(" ")).includes(query);
|
||||
}
|
||||
|
||||
function categoryForEntity(entity) {
|
||||
const cls = entity.device_class || "";
|
||||
if (entity.domain === "light") return "Lichtzustände";
|
||||
if (entity.domain === "switch") return "Schalter / Helper";
|
||||
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
|
||||
if (["illuminance"].includes(cls)) return "Helligkeit";
|
||||
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
|
||||
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
|
||||
if (["temperature"].includes(cls)) return "Temperatur";
|
||||
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
|
||||
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
|
||||
if (entity.domain === "sensor") return "Weitere Messsensoren";
|
||||
return "Weitere Zustände";
|
||||
}
|
||||
|
||||
function optionGroups(entities, selectedIds = new Set()) {
|
||||
const groups = new Map();
|
||||
for (const entity of entities) {
|
||||
const category = categoryForEntity(entity);
|
||||
if (!groups.has(category)) groups.set(category, []);
|
||||
groups.get(category).push(entity);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([label, items]) => `
|
||||
<optgroup label="${escapeHtml(label)}">
|
||||
${items.map(entity => `
|
||||
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
|
||||
${escapeHtml(entityLabel(entity))}
|
||||
</option>
|
||||
`).join("")}
|
||||
</optgroup>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
const status = document.getElementById("status");
|
||||
const chips = document.getElementById("status-chips");
|
||||
try {
|
||||
const [health, ml, reconciliation, actuators] = await Promise.all([
|
||||
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
||||
api("health"),
|
||||
api("health/websocket"),
|
||||
api("ml/health"),
|
||||
api("v1/actuators/reconciliation/state"),
|
||||
api("v1/actuators"),
|
||||
@@ -152,6 +285,7 @@ async function loadOverview() {
|
||||
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
||||
chips.innerHTML = [
|
||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
||||
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
||||
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
||||
@@ -165,23 +299,101 @@ async function loadOverview() {
|
||||
|
||||
async function loadActuatorDiscovery() {
|
||||
const options = document.getElementById("actuator-options");
|
||||
const select = document.getElementById("actuator-select");
|
||||
try {
|
||||
const [available, configured] = await Promise.all([
|
||||
api("v1/actuators/discovery"),
|
||||
api("v1/actuators"),
|
||||
]);
|
||||
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
|
||||
const choices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
||||
options.innerHTML = choices.map(entity =>
|
||||
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
||||
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
|
||||
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
|
||||
).join("");
|
||||
renderActuatorSelect();
|
||||
} catch (error) {
|
||||
options.innerHTML = "";
|
||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function actuatorGroupLabel(domain) {
|
||||
const labels = {
|
||||
light: "Lichter",
|
||||
switch: "Schalter / Helper",
|
||||
cover: "Rollläden / Cover",
|
||||
fan: "Lüftung / Ventilatoren",
|
||||
humidifier: "Befeuchter / Entfeuchter",
|
||||
};
|
||||
return labels[domain] || domain;
|
||||
}
|
||||
|
||||
function renderActuatorSelect() {
|
||||
const select = document.getElementById("actuator-select");
|
||||
if (!select) return;
|
||||
const domain = document.getElementById("actuator-domain-filter")?.value || "";
|
||||
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
|
||||
const filtered = actuatorChoices
|
||||
.filter(entity => !domain || entity.domain === domain)
|
||||
.filter(entity => matchesSearch(entity, query))
|
||||
.slice(0, 120);
|
||||
const domains = [...new Set(filtered.map(entity => entity.domain))].sort();
|
||||
select.innerHTML = [
|
||||
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`,
|
||||
...domains.map(group => `
|
||||
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
|
||||
${filtered
|
||||
.filter(entity => entity.domain === group)
|
||||
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
|
||||
.join("")}
|
||||
</optgroup>
|
||||
`),
|
||||
].join("");
|
||||
}
|
||||
|
||||
async function loadContextOptions(actuatorId) {
|
||||
try {
|
||||
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
|
||||
} catch (error) {
|
||||
contextOptions = [];
|
||||
}
|
||||
}
|
||||
|
||||
function selectActuatorFromList() {
|
||||
const value = document.getElementById("actuator-select").value;
|
||||
if (value) document.getElementById("actuator-input").value = value;
|
||||
}
|
||||
|
||||
function renderManualContextSelect() {
|
||||
const select = document.getElementById("manual-context-select");
|
||||
if (!select) return;
|
||||
const category = document.getElementById("manual-context-category")?.value || "";
|
||||
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
|
||||
const selectedNow = new Set([
|
||||
...manualContextState.selected,
|
||||
...Array.from(select.selectedOptions).map(option => option.value),
|
||||
]);
|
||||
const filtered = manualContextState.options
|
||||
.filter(entity => !category || categoryForEntity(entity) === category)
|
||||
.filter(entity => matchesSearch(entity, query))
|
||||
.slice(0, 80);
|
||||
select.innerHTML = filtered.length
|
||||
? optionGroups(filtered, selectedNow)
|
||||
: `<option value="">Keine passenden Vorschläge</option>`;
|
||||
}
|
||||
|
||||
function parseEntityIds(value) {
|
||||
return String(value || "")
|
||||
.split(/[\s,;]+/)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function configureActuator() {
|
||||
const actuatorId = document.getElementById("actuator-input").value.trim();
|
||||
const actuatorId = (
|
||||
document.getElementById("actuator-input").value.trim()
|
||||
|| document.getElementById("actuator-select").value.trim()
|
||||
);
|
||||
const result = document.getElementById("actuator-config-result");
|
||||
if (!actuatorId) return;
|
||||
result.textContent = "Kontext wird automatisch analysiert ...";
|
||||
@@ -194,6 +406,7 @@ async function configureActuator() {
|
||||
result.textContent = `${record.actuator_entity_id}: ${lifecycleLabel(record)}.`;
|
||||
await loadOverview();
|
||||
await showActuator(record.actuator_entity_id);
|
||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
} catch (error) {
|
||||
result.textContent = error.message;
|
||||
}
|
||||
@@ -204,29 +417,33 @@ async function loadConfiguredActuators() {
|
||||
try {
|
||||
const rows = await api("v1/actuators");
|
||||
box.innerHTML = rows.length ? `
|
||||
<table>
|
||||
<tr><th>Gerät</th><th>Lernstatus</th><th>Freigabe</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
|
||||
<div class="card-list">
|
||||
${rows.map(record => `
|
||||
<tr>
|
||||
<td>${escapeHtml(record.actuator_entity_id)}</td>
|
||||
<td class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</td>
|
||||
<td class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</td>
|
||||
<td>${record.behavior.sample_count}</td>
|
||||
<td>${record.behavior.prediction
|
||||
? `${escapeHtml(record.behavior.prediction.target_state)} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
||||
: "-"}</td>
|
||||
<td>
|
||||
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details</button>
|
||||
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
||||
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
||||
</div>
|
||||
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
|
||||
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div>
|
||||
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
|
||||
${record.behavior.mode === "active"
|
||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
||||
: record.behavior.activation_ready
|
||||
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
||||
: ""}
|
||||
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</article>
|
||||
`).join("")}
|
||||
</table>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||
</div>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||
} catch (error) {
|
||||
box.textContent = error.message;
|
||||
}
|
||||
@@ -242,6 +459,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
} catch (_) {
|
||||
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
||||
}
|
||||
await loadContextOptions(actuatorId);
|
||||
const contexts = [
|
||||
record.assignment.selected_numeric_entity_id,
|
||||
...record.assignment.selected_context_entity_ids,
|
||||
@@ -255,6 +473,55 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
pattern => pattern.source === "automation",
|
||||
).length;
|
||||
const relatedAutomations = record.behavior.related_automations || [];
|
||||
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
|
||||
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
|
||||
const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
|
||||
const manualOnlyIds = [
|
||||
record.assignment.selected_numeric_entity_id,
|
||||
...manualContextIds,
|
||||
].filter(entityId => entityId && !suggestedIds.has(entityId));
|
||||
const contextCategories = [...new Set(contextOptions
|
||||
.filter(entity => entity.entity_id !== record.actuator_entity_id)
|
||||
.map(categoryForEntity))]
|
||||
.sort();
|
||||
manualContextState = {
|
||||
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
|
||||
selected: manualContextIds,
|
||||
};
|
||||
const manualAssignment = `
|
||||
<div class="manual-context">
|
||||
<h3>Kontext selbst festlegen</h3>
|
||||
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
|
||||
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
||||
<select id="manual-numeric-select">
|
||||
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
||||
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
|
||||
</select>
|
||||
<div class="inline-controls">
|
||||
<div>
|
||||
<label for="manual-context-category">Kategorie</label>
|
||||
<select id="manual-context-category" onchange="renderManualContextSelect()">
|
||||
<option value="">Alle relevanten Vorschläge</option>
|
||||
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="manual-context-filter">Vorschläge durchsuchen</label>
|
||||
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
|
||||
<select id="manual-context-select" multiple>
|
||||
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
|
||||
</select>
|
||||
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
|
||||
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
|
||||
<div class="actions">
|
||||
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
|
||||
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const activationButton = record.behavior.mode === "active"
|
||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
|
||||
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
|
||||
@@ -268,13 +535,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<strong>${escapeHtml(automation.friendly_name)}</strong>
|
||||
<code>${escapeHtml(automation.entity_id)}</code>:
|
||||
<span class="${automation.enabled ? "ok" : "warn"}">${automation.enabled ? "aktiv" : "pausiert"}</span>
|
||||
<button class="secondary" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
||||
<button class="secondary compact" onclick="setRelatedAutomation('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(automation.entity_id)}', ${automation.enabled ? "false" : "true"})">${automation.enabled ? "Pausieren" : "Fortsetzen"}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: "<p class='muted'>Keine eindeutig passende HA-Automation gefunden.</p>";
|
||||
box.innerHTML = `
|
||||
<div class="grid-two">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
||||
<p class="muted">Alle wichtigen Aktionen für dieses Gerät.</p>
|
||||
</div>
|
||||
<button class="secondary compact" onclick="loadOverview()">Alles aktualisieren</button>
|
||||
</div>
|
||||
<div class="grid-two">
|
||||
<div>
|
||||
<h3>Zuordnung</h3>
|
||||
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
||||
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
||||
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
|
||||
@@ -290,7 +564,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
|
||||
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
||||
<p><strong>Freigabestatus:</strong> <span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_reason)}</span></p>
|
||||
${activationButton}
|
||||
<div class="actions">${activationButton}</div>
|
||||
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
|
||||
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
|
||||
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
|
||||
@@ -305,12 +579,40 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
${automationControls}
|
||||
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
|
||||
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
|
||||
${manualAssignment}
|
||||
`;
|
||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||
} catch (error) {
|
||||
box.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManualAssignment(actuatorId) {
|
||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||
const selectedContextIds = Array.from(
|
||||
document.getElementById("manual-context-select").selectedOptions,
|
||||
).map(option => option.value).filter(value => value.includes("."));
|
||||
const freeformContextIds = parseEntityIds(
|
||||
document.getElementById("manual-context-freeform").value,
|
||||
);
|
||||
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
|
||||
.filter(entityId => entityId !== numericEntityId);
|
||||
try {
|
||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
numeric_entity_id: numericEntityId,
|
||||
context_entity_ids: contextEntityIds,
|
||||
note: "Manuell im Dashboard gesetzt",
|
||||
}),
|
||||
});
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateActuator(actuatorId) {
|
||||
try {
|
||||
const record = await api(
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "0.7.0"
|
||||
version = "0.7.10"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
"pydantic>=2.6.0",
|
||||
"requests>=2.31.0",
|
||||
"websockets>=12.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -7,7 +7,6 @@ from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import (
|
||||
AssignmentSource,
|
||||
LifecycleStatus,
|
||||
ManualOverride,
|
||||
model_id_for_actuator,
|
||||
)
|
||||
from app.actuators.store import ActuatorStore
|
||||
@@ -79,7 +78,7 @@ def _service(
|
||||
model_store=str(tmp_path / "models"),
|
||||
automation_store=str(tmp_path / "automations"),
|
||||
actuator_store=str(tmp_path / "actuators"),
|
||||
history_days=14,
|
||||
history_days=31,
|
||||
min_training_points=5,
|
||||
retrain_stale_hours=24,
|
||||
reconcile_interval_seconds=900,
|
||||
@@ -240,7 +239,45 @@ def test_reconciliation_does_not_cross_assign_other_room_light_energy(
|
||||
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
|
||||
|
||||
|
||||
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None:
|
||||
def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="light.abstellkammer",
|
||||
domain="light",
|
||||
friendly_name="Licht Abstellkammer",
|
||||
area_name="Monitoring",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.disk_overheating",
|
||||
domain="binary_sensor",
|
||||
device_class="problem",
|
||||
friendly_name="Max. fehlerhafte Sektoren ueberschritten",
|
||||
area_name="Monitoring",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.router_power",
|
||||
domain="sensor",
|
||||
device_class="power",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="W",
|
||||
friendly_name="Router Leistung",
|
||||
area_name="Monitoring",
|
||||
),
|
||||
]
|
||||
service = _service(tmp_path, entities, {"sensor.router_power": _points(8, start, 1.0)})
|
||||
|
||||
record = service.configure_actuator("light.abstellkammer")
|
||||
|
||||
assert record.assignment.selected_numeric_entity_id is None
|
||||
assert record.assignment.selected_context_entity_ids == []
|
||||
assert record.assignment.review_required is True
|
||||
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
|
||||
|
||||
|
||||
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
@@ -273,21 +310,18 @@ def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path:
|
||||
"sensor.abstellkammer_power": _points(8, start, 30.0),
|
||||
}
|
||||
service = _service(tmp_path, entities, history)
|
||||
configured = service.configure_actuator("light.abstellkammer")
|
||||
legacy = configured.model_copy(
|
||||
update={
|
||||
"manual_override": ManualOverride(
|
||||
numeric_entity_id="sensor.abstellkammer_power",
|
||||
context_entity_ids=[],
|
||||
note="Alte manuelle Zuordnung",
|
||||
)
|
||||
}
|
||||
service.configure_actuator("light.abstellkammer")
|
||||
service.set_manual_assignment(
|
||||
"light.abstellkammer",
|
||||
numeric_entity_id="sensor.abstellkammer_power",
|
||||
context_entity_ids=["sensor.abstellkammer_illuminance"],
|
||||
note="Manuell wichtiger Sensor",
|
||||
)
|
||||
service._store.upsert(legacy)
|
||||
|
||||
restarted = _service(tmp_path, entities, history)
|
||||
record = restarted.reconcile_actuator("light.abstellkammer")
|
||||
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
|
||||
assert record.assignment.source.value == "automatic"
|
||||
assert record.manual_override is None
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
|
||||
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
|
||||
assert record.assignment.source is AssignmentSource.MANUAL
|
||||
assert record.manual_override is not None
|
||||
|
||||
@@ -115,6 +115,14 @@ def _install_service(tmp_path: Path) -> None:
|
||||
friendly_name="Abstellkammer Bewegung",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
||||
domain="sensor",
|
||||
device_class="data_size",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="KiB",
|
||||
friendly_name="pfSense Interface VPN inbytes",
|
||||
),
|
||||
]
|
||||
settings = Settings(
|
||||
ha_url="http://ha.local",
|
||||
@@ -179,14 +187,43 @@ def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
|
||||
assert client.get("/v1/actuators").json() == []
|
||||
|
||||
|
||||
def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None:
|
||||
def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
|
||||
response = client.post(
|
||||
"/v1/actuators/light.abstellkammer/override",
|
||||
json={"numeric_entity_id": "sensor.abstellkammer_illuminance"},
|
||||
"/v1/actuators/light.abstellkammer/assignment",
|
||||
json={
|
||||
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||
"note": "Manuell gesetzt",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["assignment"]["source"] == "manual"
|
||||
assert payload["assignment"]["selected_numeric_entity_id"] == (
|
||||
"sensor.abstellkammer_illuminance"
|
||||
)
|
||||
assert payload["assignment"]["selected_context_entity_ids"] == [
|
||||
"binary_sensor.abstellkammer_motion"
|
||||
]
|
||||
|
||||
|
||||
def test_context_options_returns_learnable_entities(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/context-options",
|
||||
params={"actuator_entity_id": "light.abstellkammer"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
entity_ids = {item["entity_id"] for item in response.json()}
|
||||
assert "sensor.abstellkammer_illuminance" in entity_ids
|
||||
assert "binary_sensor.abstellkammer_motion" in entity_ids
|
||||
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids
|
||||
|
||||
@@ -523,3 +523,137 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
|
||||
min_support=1,
|
||||
window_minutes=30,
|
||||
) is None
|
||||
|
||||
|
||||
def test_state_change_uses_websocket_context_state_for_immediate_action(
|
||||
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="off",
|
||||
last_changed=now - timedelta(minutes=5),
|
||||
),
|
||||
],
|
||||
history=[],
|
||||
logbook=[],
|
||||
)
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
engine.handle_state_change(
|
||||
"binary_sensor.storage_door",
|
||||
{"state": "on", "last_changed": now.isoformat()},
|
||||
)
|
||||
|
||||
assert reader.service_calls == [
|
||||
("light", "turn_on", {"entity_id": "light.storage"})
|
||||
]
|
||||
|
||||
|
||||
def test_state_change_uses_event_cache_without_rest_state_query(
|
||||
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=[],
|
||||
history=[],
|
||||
logbook=[],
|
||||
)
|
||||
|
||||
def fail_read_entities() -> list[HaEntitySummary]:
|
||||
raise AssertionError("Event-Auswertung darf keinen REST-State lesen.")
|
||||
|
||||
reader.read_entities = fail_read_entities # type: ignore[method-assign]
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
engine.handle_state_change(
|
||||
"binary_sensor.storage_door",
|
||||
{"state": "on", "last_changed": now.isoformat()},
|
||||
current_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,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert reader.service_calls == [
|
||||
("light", "turn_on", {"entity_id": "light.storage"})
|
||||
]
|
||||
|
||||
@@ -108,6 +108,27 @@ def test_list_entity_metadata_calls_template_api() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_list_entity_metadata_batches_template_calls() -> None:
|
||||
responses = []
|
||||
for index in range(3):
|
||||
response = _response()
|
||||
response.text = (
|
||||
f'[{{"entity_id":"sensor.test_{index}",'
|
||||
f'"area_name":"Area {index}","device_name":"Device {index}"}}]'
|
||||
)
|
||||
responses.append(response)
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||
client._session.post = Mock(side_effect=responses) # type: ignore[method-assign]
|
||||
|
||||
entity_ids = [f"sensor.test_{index}" for index in range(401)]
|
||||
metadata = client.list_entity_metadata(entity_ids)
|
||||
|
||||
assert client._session.post.call_count == 3
|
||||
assert metadata["sensor.test_0"]["area_name"] == "Area 0"
|
||||
assert metadata["sensor.test_1"]["device_name"] == "Device 1"
|
||||
assert metadata["sensor.test_2"]["device_name"] == "Device 2"
|
||||
|
||||
|
||||
def test_get_logbook_filters_entity_and_period() -> None:
|
||||
response = _response(payload=[{"entity_id": "light.office"}])
|
||||
client = _client_with_response(response)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.exceptions import HaHttpError
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
|
||||
@@ -150,3 +151,23 @@ def test_ha_reader_finds_automation_that_targets_entity() -> None:
|
||||
assert len(matches) == 1
|
||||
assert matches[0].entity_id == "automation.storage_light"
|
||||
assert matches[0].enabled is True
|
||||
|
||||
|
||||
def test_ha_reader_ignores_automation_configs_not_exposed_by_ha() -> None:
|
||||
client = FakeHaClient()
|
||||
client.list_entities = lambda: [ # type: ignore[method-assign]
|
||||
{
|
||||
"entity_id": "automation.storage_light",
|
||||
"state": "on",
|
||||
"attributes": {
|
||||
"id": "123",
|
||||
"friendly_name": "Storage light",
|
||||
},
|
||||
}
|
||||
]
|
||||
client.get_automation_config = lambda automation_id: (_ for _ in ()).throw( # type: ignore[method-assign]
|
||||
HaHttpError(404, "Resource not found")
|
||||
)
|
||||
reader = HaReader(client)
|
||||
|
||||
assert reader.find_automations_for_entity("light.storage") == []
|
||||
|
||||
@@ -12,6 +12,8 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
assert "So gehst du vor" in response.text
|
||||
assert "Gerät zum Lernen auswählen" in response.text
|
||||
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
|
||||
assert "Oder aus Liste wählen" in response.text
|
||||
assert "Liste durchsuchen" in response.text
|
||||
assert "Wie gewohnt bedienen" in response.text
|
||||
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
|
||||
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
|
||||
@@ -21,6 +23,11 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
assert "Pausieren" in response.text
|
||||
assert "Davon erkannte HA-Automationen" in response.text
|
||||
assert "Aktuelle Situation auswerten" in response.text
|
||||
assert "Kontext selbst festlegen" in response.text
|
||||
assert "Entity-IDs manuell ergänzen" in response.text
|
||||
assert "manual-context-freeform" in response.text
|
||||
assert "Diese Kontext-Auswahl speichern" in response.text
|
||||
assert "manual-context-select" in response.text
|
||||
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
|
||||
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
|
||||
assert "Vorhersage jetzt prüfen" not in response.text
|
||||
|
||||
148
tests/test_main.py
Normal file
148
tests/test_main.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import anyio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.main import _ha_event_listener, app as fastapi_app, lifespan
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self, messages: list[str | BaseException]) -> None:
|
||||
self._messages = messages
|
||||
self.sent: list[dict[str, object]] = []
|
||||
|
||||
async def __aenter__(self) -> "_FakeWebSocket":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def recv(self) -> str:
|
||||
message = self._messages.pop(0)
|
||||
if isinstance(message, BaseException):
|
||||
raise message
|
||||
return message
|
||||
|
||||
async def send(self, message: str) -> None:
|
||||
import json
|
||||
|
||||
self.sent.append(json.loads(message))
|
||||
|
||||
|
||||
class _RecordingBehaviorEngine(BehaviorEngine):
|
||||
def __init__(self, tmp_path: Path) -> None:
|
||||
super().__init__(
|
||||
ha_reader=MagicMock(),
|
||||
store=ActuatorStore(tmp_path / "actuators"),
|
||||
settings=MagicMock(),
|
||||
)
|
||||
self.state_changes: list[
|
||||
tuple[str, dict[str, object] | None, Sequence[HaEntitySummary] | None]
|
||||
] = []
|
||||
|
||||
def handle_state_change(
|
||||
self,
|
||||
entity_id: str,
|
||||
new_state: dict[str, object] | None,
|
||||
*,
|
||||
current_entities: Sequence[HaEntitySummary] | None = None,
|
||||
) -> None:
|
||||
self.state_changes.append((entity_id, new_state, current_entities))
|
||||
|
||||
|
||||
class _FakeHaReader(HaReader):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def read_entities(self) -> list[HaEntitySummary]:
|
||||
return [
|
||||
HaEntitySummary(
|
||||
entity_id="light.test",
|
||||
domain="light",
|
||||
state="off",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_ha_event_listener_processes_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":"light.test","new_state":{"state":"on"}}}}'
|
||||
),
|
||||
asyncio.CancelledError(),
|
||||
]
|
||||
)
|
||||
|
||||
with patch("websockets.connect", return_value=fake_ws) as connect:
|
||||
try:
|
||||
await _ha_event_listener(mock_app, mock_client)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
connect.assert_called_once_with(
|
||||
"ws://homeassistant:8123/api/websocket",
|
||||
ping_interval=None,
|
||||
)
|
||||
assert fake_ws.sent == [
|
||||
{"type": "auth", "access_token": "test-token"},
|
||||
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
||||
]
|
||||
|
||||
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_app.state.actuator_store = mock_store
|
||||
mock_client = MagicMock()
|
||||
|
||||
anyio.run(run_test)
|
||||
assert len(mock_engine.state_changes) == 1
|
||||
entity_id, new_state, current_entities = mock_engine.state_changes[0]
|
||||
assert entity_id == "light.test"
|
||||
assert new_state == {"state": "on"}
|
||||
assert current_entities == [
|
||||
HaEntitySummary(entity_id="light.test", domain="light", state="on")
|
||||
]
|
||||
assert mock_app.state.ws_status.status == "connected"
|
||||
assert mock_app.state.ws_status.error is None
|
||||
|
||||
|
||||
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
||||
app = FastAPI()
|
||||
app.state.settings = MagicMock()
|
||||
app.state.settings.ha_configured = False
|
||||
|
||||
async def run_test() -> None:
|
||||
async with lifespan(app):
|
||||
pass
|
||||
|
||||
anyio.run(run_test)
|
||||
|
||||
|
||||
def test_websocket_health_returns_unavailable_without_listener() -> None:
|
||||
with TestClient(fastapi_app) as client:
|
||||
response = client.get("/health/websocket")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "unavailable",
|
||||
"error": "WebSocket-Listener nicht initialisiert",
|
||||
}
|
||||
Reference in New Issue
Block a user