Compare commits

...

9 Commits

15 changed files with 1276 additions and 55 deletions

View File

@@ -1,5 +1,66 @@
# Changelog # Changelog
## 0.7.16 - 2026-06-16
- Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und
mit Friendly Name angezeigt.
## 0.7.15 - 2026-06-16
- Add-on-Start ist robust gegen Home-Assistant-Core-502 beim Systemboot:
API und WebSocket-Listener starten trotzdem, Reconciliation/Training werden
im Hintergrund mit Retry nachgeholt.
- Periodische Reconciliation und Fallback-Auswertung beenden den Dienst nicht
mehr bei temporären HA-Fehlern.
- Add-on-Watchdog prüft `/health`, damit Supervisor den Dienst nach Absturz
wieder starten kann.
## 0.7.14 - 2026-06-16
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
## 0.7.13 - 2026-06-16
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
übernommen.
- Verwendete Kontext-Entities können pro Aktor direkt entfernt und damit als
manuelle Zuordnung überschrieben werden.
- Onboarding-Vorschläge zeigen passende, noch nicht eingerichtete Aktoren aus
bestehenden Automationen und naheliegenden Kontexten.
- TV-/Medien-Aktoren über `media_player` und Fernbedienungen über `remote`
werden in Discovery und Auswahl berücksichtigt.
## 0.7.12 - 2026-06-16
- Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei
größeren Mengen zum Eingrenzen per Suche oder Typfilter auf.
## 0.7.11 - 2026-06-16
- Aktor-Discovery erkennt weitere steuerbare HA-Domains wie Buttons, Helper,
Heizungen, Schlösser, Ventile und numerische Helper.
- Aktor-Auswahl dedupliziert Licht-/Schalter-Doppelungen pro Gerät und gruppiert
zusätzliche Typen im Dashboard.
- Discovery liefert Kategorien für Mess-, Binär-, Kontext- und Aktor-Entities.
- Nutzerfeedback kann Vorhersagen als korrekt oder falsch markieren und direkt
als Lernsignal speichern.
## 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 ## 0.7.7 - 2026-06-15
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat - WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort (`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.7" version: "0.7.16"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next
@@ -7,6 +7,7 @@ arch:
- amd64 - amd64
startup: application startup: application
boot: auto boot: auto
watchdog: http://[HOST]:[PORT:8000]/health
init: false init: false
ingress: true ingress: true
ingress_port: 8000 ingress_port: 8000

View File

@@ -57,7 +57,7 @@ _STOPWORDS = frozenset(
"value", "value",
} }
) )
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"}) _GENERIC_AREA_NAMES = frozenset({"energie", "monitoring", "power", "strom", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5 _NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
@@ -88,6 +88,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
"diagnostic", "diagnostic",
"firmware", "firmware",
"gesehen", "gesehen",
"heat",
"last", "last",
"linkquality", "linkquality",
"knoten", "knoten",
@@ -100,10 +101,28 @@ _DIAGNOSTIC_TOKENS = frozenset({
"signal", "signal",
"ssid", "ssid",
"status", "status",
"overheat",
"overheating",
"overload",
"uptime", "uptime",
"uberhitzung",
"ueberhitzung",
"ueberlast",
"überhitzung",
"überlast",
"wifi", "wifi",
"zuletzt", "zuletzt",
}) })
_AUTO_CONTEXT_CLASSES = frozenset({
"door",
"garage_door",
"illuminance",
"motion",
"occupancy",
"opening",
"presence",
"window",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -628,8 +647,12 @@ class ActuatorReconciliationService:
if context if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
) )
can_auto_accept_context = (
not context or _eligible_for_auto_context(actuator, candidate)
)
auto_accepted = ( auto_accepted = (
candidate.score >= minimum_score can_auto_accept_context
and candidate.score >= minimum_score
and confidence >= auto_score and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN) and (context or margin >= _NUMERIC_MIN_MARGIN)
) )
@@ -743,6 +766,23 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
) )
def _eligible_for_auto_context(
actuator: HaEntitySummary,
candidate: AssignmentCandidate,
) -> bool:
device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES:
return True
if (
actuator.device_name
and candidate.device_name
and actuator.device_name == candidate.device_name
and candidate.domain in {"light", "switch"}
):
return True
return False
def _score_candidate( def _score_candidate(
actuator: HaEntitySummary, actuator: HaEntitySummary,
entity: HaEntitySummary, entity: HaEntitySummary,

View File

@@ -8,7 +8,7 @@ from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -38,16 +38,98 @@ class ManualAssignmentRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) note: str | None = Field(default=None, max_length=500)
class FeedbackRequest(BaseModel):
correct: bool
expected_state: str | None = Field(default=None, max_length=100)
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
confidence: float
reason: str
related_automation_count: int = 0
likely_context_count: int = 0
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> 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()} entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = ha_reader.discover() discovered = ha_reader.discover()
actuator_ids = sorted( actuator_ids = _deduplicate_actuator_ids(
entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR [
(entity.entity_id, entity.category)
for entity in discovered
if entity.role is EntityRole.ACTUATOR
],
entities,
) )
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("/suggestions", response_model=list[ActuatorSuggestion])
def suggest_actuators(
request: Request,
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[ActuatorSuggestion]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in ha_reader.discover()}
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
actuator_ids = _deduplicate_actuator_ids(
[
(entity.entity_id, entity.category)
for entity in discovered.values()
if entity.role is EntityRole.ACTUATOR
],
entities,
)
suggestions: list[ActuatorSuggestion] = []
for entity_id in actuator_ids:
if entity_id in configured_ids:
continue
entity = entities.get(entity_id)
if entity is None:
continue
try:
automations = ha_reader.find_automations_for_entity(entity_id)
except Exception:
automations = []
context_count = _likely_context_count(entity, entities, discovered)
if not automations and context_count == 0:
continue
confidence = 1.0 if automations else min(0.85, 0.35 + context_count * 0.1)
reason_parts = []
if automations:
reason_parts.append(f"{len(automations)} passende HA-Automation(en)")
if context_count:
reason_parts.append(f"{context_count} naheliegende Kontext-Entity(s)")
suggestions.append(
ActuatorSuggestion(
entity_id=entity.entity_id,
domain=entity.domain,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
confidence=round(confidence, 4),
reason=", ".join(reason_parts),
related_automation_count=len(automations),
likely_context_count=context_count,
)
)
return sorted(
suggestions,
key=lambda item: (
-item.related_automation_count,
-item.confidence,
item.area_name or "",
item.friendly_name or item.entity_id,
),
)[:30]
@router.get("/context-options", response_model=list[HaEntitySummary]) @router.get("/context-options", response_model=list[HaEntitySummary])
def context_options( def context_options(
request: Request, request: Request,
@@ -116,6 +198,22 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback(
actuator_entity_id: str,
payload: FeedbackRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).record_feedback(
actuator_entity_id,
correct=payload.correct,
expected_state=payload.expected_state,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation( def set_activation(
actuator_entity_id: str, actuator_entity_id: str,
@@ -233,3 +331,102 @@ def _behavior(request: Request) -> BehaviorEngine:
detail="Verhaltenslernen ist nicht initialisiert.", detail="Verhaltenslernen ist nicht initialisiert.",
) )
return engine return engine
def _deduplicate_actuator_ids(
discovered: list[tuple[str, str]],
entities: dict[str, HaEntitySummary],
) -> list[str]:
priority = {
"light": 0,
"cover_shutter": 1,
"heating": 2,
"lock": 3,
"fan": 4,
"switch_socket": 5,
"button": 6,
"helper": 7,
}
selected: dict[str, tuple[int, str]] = {}
for entity_id, category in discovered:
entity = entities.get(entity_id)
if entity is None:
continue
key = _actuator_duplicate_key(entity, category)
rank = priority.get(category, 50)
current = selected.get(key)
if current is None or (rank, entity_id) < current:
selected[key] = (rank, entity_id)
return sorted(entity_id for _, entity_id in selected.values())
def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str:
if entity.device_id and category in {"light", "switch_socket", "button"}:
return f"device:{entity.device_id}:control"
if entity.device_name and category in {"light", "switch_socket", "button"}:
return f"device-name:{entity.device_name.lower()}:control"
return f"entity:{entity.entity_id}"
def _likely_context_count(
actuator: HaEntitySummary,
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
) -> int:
actuator_tokens = _tokens(actuator)
count = 0
for entity in entities.values():
if entity.entity_id == actuator.entity_id:
continue
descriptor = discovered.get(entity.entity_id)
role = descriptor.role if descriptor is not None else None
if role not in {
EntityRole.MEASUREMENT,
EntityRole.BINARY_CONTEXT,
EntityRole.CONTEXT,
}:
continue
if entity.device_class not in {
"door",
"energy",
"garage_door",
"humidity",
"illuminance",
"motion",
"occupancy",
"opening",
"power",
"presence",
"temperature",
"window",
}:
continue
same_area = bool(
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
)
same_device = bool(
actuator.device_id
and entity.device_id
and actuator.device_id == entity.device_id
)
token_match = bool(actuator_tokens.intersection(_tokens(entity)))
if same_area or same_device or token_match:
count += 1
return count
def _tokens(entity: HaEntitySummary) -> set[str]:
values = [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
tokens: set[str] = set()
for value in values:
if not value:
continue
tokens.update(token for token in value.lower().replace("_", " ").split() if len(token) > 2)
return tokens

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -18,6 +19,7 @@ from app.actuators.store import ActuatorStore
from app.config import Settings from app.config import Settings
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries from app.ha.history import LogbookEntry, StateHistoryPoint, StateHistorySeries
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
_MAX_PATTERNS = 500 _MAX_PATTERNS = 500
@@ -187,11 +189,19 @@ class BehaviorEngine:
results.append(record) results.append(record)
return results 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) record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if current_entities is None:
try: try:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} current_entities = self._ha_reader.read_entities()
except HaClientError as exc: except HaClientError as exc:
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc) logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
return self._save_behavior( return self._save_behavior(
@@ -204,6 +214,7 @@ class BehaviorEngine:
} }
), ),
) )
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id) actuator = entities.get(actuator_entity_id)
if actuator is None: if actuator is None:
return self._save_behavior( return self._save_behavior(
@@ -230,6 +241,22 @@ class BehaviorEngine:
entity_id: entities[entity_id].last_changed entity_id: entities[entity_id].last_changed
for entity_id in current_context 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( prediction = predict_behavior(
record.behavior.patterns, record.behavior.patterns,
current_context=current_context, current_context=current_context,
@@ -329,6 +356,95 @@ class BehaviorEngine:
) )
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
def record_feedback(
self,
actuator_entity_id: str,
*,
correct: bool,
expected_state: str | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
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("Aktor ist aktuell nicht in Home Assistant verfügbar.")
context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
current_context = {
entity_id: entities[entity_id].state
for entity_id in context_ids
if entity_id in entities and entities[entity_id].state is not None
}
prediction = record.behavior.prediction
patterns = list(record.behavior.patterns)
reason = "Nutzerfeedback gespeichert."
if correct and prediction is not None:
local = now.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=prediction.target_state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states={
entity_id: state
for entity_id, state in current_context.items()
if state is not None
},
source="user_feedback",
weight=1.0,
observed_at=now,
)
)
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
else:
target = prediction.target_state if prediction is not None else None
if target:
patterns = [
pattern.model_copy(update={"weight": 0.1})
if pattern.target_state == target
and _pattern_context_matches(pattern, current_context)
else pattern
for pattern in patterns
]
if expected_state:
local = now.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=expected_state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states={
entity_id: state
for entity_id, state in current_context.items()
if state is not None
},
source="user_correction",
weight=1.0,
observed_at=now,
)
)
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
behavior = record.behavior.model_copy(
update={
"patterns": patterns[-_MAX_PATTERNS:],
"prediction": (
prediction.model_copy(update={"execution_reason": reason})
if prediction is not None
else None
),
"reason": reason,
"last_trained_at": now,
}
)
return self._save_behavior(record, behavior)
def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord: def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord:
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
related = [ related = [
@@ -608,20 +724,30 @@ class BehaviorEngine:
) )
return self._store.upsert(updated) return self._store.upsert(updated)
def handle_state_change(self, entity_id: str, new_state: dict[str, object] | None) -> None: 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. """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 Aktor ist: evaluate() direkt.
- Wenn entity_id ein Kontext-Entity ist: alle betroffenen Aktoren evaluieren. - 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 # Aktor direkt evaluieren
for record in self._store.list(): for record in self._store.list():
if record.actuator_entity_id == entity_id: if record.actuator_entity_id == entity_id:
try: try:
self.evaluate(record.actuator_entity_id) self.evaluate(record.actuator_entity_id, current_entities=current_entities)
except Exception: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
return return
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 # Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
affected_actuators = [ affected_actuators = [
record.actuator_entity_id record.actuator_entity_id
@@ -633,11 +759,38 @@ class BehaviorEngine:
] ]
for actuator_entity_id in affected_actuators: for actuator_entity_id in affected_actuators:
try: try:
self.evaluate(actuator_entity_id) 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: except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id) logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
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( def predict_behavior(
patterns: list[BehaviorPattern], patterns: list[BehaviorPattern],
*, *,
@@ -745,7 +898,7 @@ def predict_behavior(
def service_for_state(domain: str, target_state: str) -> str | None: def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "switch"}: if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state) return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "cover": if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state) return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
@@ -792,6 +945,20 @@ def _matches_own_execution(
) )
def _pattern_context_matches(
pattern: BehaviorPattern,
current_context: dict[str, str | None],
) -> bool:
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
if not comparable:
return False
return all(current_context[entity_id] == expected for entity_id, expected in comparable)
def _recent_context_transition( def _recent_context_transition(
history: dict[str, StateHistorySeries], history: dict[str, StateHistorySeries],
context_ids: list[str], context_ids: list[str],

View File

@@ -21,6 +21,7 @@ class DiscoveredEntity(BaseModel):
device_class: str | None = None device_class: str | None = None
state_class: str | None = None state_class: str | None = None
unit_of_measurement: str | None = None unit_of_measurement: str | None = None
category: str
role: EntityRole role: EntityRole
learnable: bool learnable: bool
reason: str reason: str
@@ -82,14 +83,41 @@ _BINARY_CONTEXT_CLASSES = frozenset({
"window", "window",
}) })
_ACTUATOR_DOMAINS = frozenset({ _ACTUATOR_DOMAINS = frozenset({
"button",
"climate",
"cover", "cover",
"fan", "fan",
"humidifier", "humidifier",
"input_boolean",
"input_button",
"lock",
"light", "light",
"media_player",
"number",
"remote",
"siren",
"switch", "switch",
"valve",
})
_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"input_boolean",
"input_datetime",
"input_number",
"input_select",
"person",
"sun",
"weather",
"zone",
})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"input_boolean",
"input_number",
"input_select",
"person",
"weather",
}) })
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})
_NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"}) _NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"})
@@ -102,6 +130,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.MEASUREMENT, EntityRole.MEASUREMENT,
category=_measurement_category(entity),
learnable=True, learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.", reason="Numerischer Messsensor für Zeitreihen und Training.",
) )
@@ -110,6 +139,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.BINARY_CONTEXT, EntityRole.BINARY_CONTEXT,
category=_binary_category(entity),
learnable=True, learnable=True,
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.", reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
) )
@@ -119,6 +149,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.CONTEXT, EntityRole.CONTEXT,
category=_context_category(entity),
learnable=learnable, learnable=learnable,
reason=( reason=(
"Kontextquelle für Training und Erklärungen." "Kontextquelle für Training und Erklärungen."
@@ -131,6 +162,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.ACTUATOR, EntityRole.ACTUATOR,
category=_actuator_category(entity),
learnable=False, learnable=False,
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.", reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
) )
@@ -138,6 +170,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.UNSUPPORTED, EntityRole.UNSUPPORTED,
category="unsupported",
learnable=False, learnable=False,
reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.", reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.",
) )
@@ -162,6 +195,7 @@ def _result(
entity: HaEntitySummary, entity: HaEntitySummary,
role: EntityRole, role: EntityRole,
*, *,
category: str,
learnable: bool, learnable: bool,
reason: str, reason: str,
) -> DiscoveredEntity: ) -> DiscoveredEntity:
@@ -171,7 +205,64 @@ def _result(
device_class=entity.device_class, device_class=entity.device_class,
state_class=entity.state_class, state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement, unit_of_measurement=entity.unit_of_measurement,
category=category,
role=role, role=role,
learnable=learnable, learnable=learnable,
reason=reason, reason=reason,
) )
def _actuator_category(entity: HaEntitySummary) -> str:
if entity.domain == "light":
return "light"
if entity.domain == "switch":
return "switch_socket"
if entity.domain == "button" or entity.domain == "input_button":
return "button"
if entity.domain == "cover":
return "cover_shutter"
if entity.domain == "climate":
return "heating"
if entity.domain == "lock":
return "lock"
if entity.domain == "fan":
return "fan"
if entity.domain in {"media_player", "remote"}:
return "media_tv"
if entity.domain in {"input_boolean", "number"}:
return "helper"
return entity.domain
def _measurement_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class == "illuminance":
return "brightness"
if device_class == "temperature":
return "temperature"
if device_class in {"humidity", "moisture"}:
return "humidity"
if device_class in {"power", "energy", "current", "voltage"}:
return "energy_power"
if device_class in {"battery", "signal_strength"}:
return "diagnostic"
return "measurement"
def _binary_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class in {"motion", "occupancy", "presence"}:
return "presence_motion"
if device_class in {"door", "garage_door", "opening", "window"}:
return "opening"
if device_class in {"smoke", "safety", "problem"}:
return "safety"
return "binary"
def _context_category(entity: HaEntitySummary) -> str:
if entity.domain.startswith("input_"):
return "helper"
if entity.domain in {"person", "device_tracker", "zone"}:
return "presence_location"
return entity.domain

View File

@@ -3,6 +3,7 @@ import json
import logging import logging
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@@ -19,6 +20,7 @@ from app.behavior.engine import BehaviorEngine
from app.config import load_settings from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.ml.registry.model_registry import ModelRegistry from app.ml.registry.model_registry import ModelRegistry
from backend.routes.ml import init_ml_routes from backend.routes.ml import init_ml_routes
@@ -41,6 +43,7 @@ class _WsStatus:
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = app.state.settings settings = app.state.settings
client: HaClient | None = None client: HaClient | None = None
startup_task: asyncio.Task[None] | None = None
reconcile_task: asyncio.Task[None] | None = None reconcile_task: asyncio.Task[None] | None = None
event_listener_task: asyncio.Task[None] | None = None event_listener_task: asyncio.Task[None] | None = None
fallback_task: asyncio.Task[None] | None = None fallback_task: asyncio.Task[None] | None = None
@@ -72,15 +75,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings=settings, settings=settings,
) )
app.state.ws_status = _WsStatus() app.state.ws_status = _WsStatus()
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup") startup_task = asyncio.create_task(_startup_reconciliation(app))
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)) reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
event_listener_task = asyncio.create_task(_ha_event_listener(app, client)) event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
fallback_task = asyncio.create_task(_fallback_prediction(app)) fallback_task = asyncio.create_task(_fallback_prediction(app))
try: try:
yield yield
finally: finally:
if startup_task is not None:
startup_task.cancel()
with suppress(asyncio.CancelledError):
await startup_task
if reconcile_task is not None: if reconcile_task is not None:
reconcile_task.cancel() reconcile_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
@@ -100,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.7", version="0.7.16",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -145,24 +150,59 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
service = getattr(app.state, "actuator_service", None) service = getattr(app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService): if not isinstance(service, ActuatorReconciliationService):
continue continue
try:
await asyncio.to_thread(service.reconcile_all, "scheduled") await asyncio.to_thread(service.reconcile_all, "scheduled")
engine = getattr(app.state, "behavior_engine", None) engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine): if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.train_all)
except Exception:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
async def _startup_reconciliation(app: FastAPI) -> None:
delay_seconds = 5
while True:
service = getattr(app.state, "actuator_service", None)
engine = getattr(app.state, "behavior_engine", None)
if not isinstance(service, ActuatorReconciliationService) or not isinstance(
engine,
BehaviorEngine,
):
return
try:
await asyncio.to_thread(service.reconcile_all, "startup")
await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
return
except Exception as exc:
logger.warning(
"Startup-Reconciliation verschoben: %s. Neuer Versuch in %ss.",
exc,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
delay_seconds = min(delay_seconds * 2, 60)
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None: async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
"""Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus.""" """Hört auf Home-Assistant-Websocket-Events und löst sofortige Vorhersagen aus."""
settings = app.state.settings settings = app.state.settings
engine = app.state.behavior_engine engine = app.state.behavior_engine
ha_reader = getattr(app.state, "ha_reader", None)
store = app.state.actuator_store store = app.state.actuator_store
if not isinstance(engine, BehaviorEngine) or not isinstance(store, ActuatorStore): if (
not isinstance(engine, BehaviorEngine)
or not isinstance(store, ActuatorStore)
or not isinstance(ha_reader, HaReader)
):
logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert") logger.error("BehaviorEngine oder ActuatorStore nicht initialisiert")
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert" ws_status.error = "BehaviorEngine oder ActuatorStore nicht initialisiert"
return return
state_cache: dict[str, HaEntitySummary] = {}
ha_url = str(settings.ha_url).rstrip("/") ha_url = str(settings.ha_url).rstrip("/")
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket" ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token) auth_token = cast(str, settings.ha_token)
@@ -171,7 +211,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
if ws_status is not None: if ws_status is not None:
ws_status.status = "connecting" ws_status.status = "connecting"
try: try:
async with websockets.connect(ws_url) as websocket: async with websockets.connect(ws_url, ping_interval=None) as websocket:
auth_required_msg = await websocket.recv() auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg) auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required": if auth_required_data.get("type") != "auth_required":
@@ -194,6 +234,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
continue continue
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt") logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
if ws_status is not None: if ws_status is not None:
ws_status.status = "connected" ws_status.status = "connected"
ws_status.error = None ws_status.error = None
@@ -220,18 +261,25 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
entity_id = event_data.get("entity_id") entity_id = event_data.get("entity_id")
if not entity_id: if not entity_id:
continue continue
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 # Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen # Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread( await asyncio.to_thread(
engine.handle_state_change, engine.handle_state_change,
entity_id, entity_id,
event_data.get("new_state"), new_state,
current_entities=list(state_cache.values()),
) )
except json.JSONDecodeError: except json.JSONDecodeError:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket") logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc: except Exception as exc:
logger.exception("Fehler bei Event-Verarbeitung: %s", exc) logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
except (websockets.exceptions.ConnectionClosed, OSError) as exc: except (
websockets.exceptions.ConnectionClosed,
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc) logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
@@ -252,7 +300,13 @@ async def _fallback_prediction(app: FastAPI) -> None:
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen. Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
""" """
while True: while True:
await asyncio.sleep(app.state.settings.prediction_interval_seconds) 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 # Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None) ws_status = getattr(app.state, "ws_status", None)
if ws_status is None or ws_status.status != "connected": if ws_status is None or ws_status.status != "connected":
@@ -262,4 +316,71 @@ async def _fallback_prediction(app: FastAPI) -> None:
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)", "Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
ws_status.status if ws_status else "unavailable", ws_status.status if ws_status else "unavailable",
) )
try:
await asyncio.to_thread(engine.evaluate_all) await asyncio.to_thread(engine.evaluate_all)
except Exception:
logger.exception("Fallback-Vorhersage fehlgeschlagen.")
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

View File

@@ -134,9 +134,18 @@
<option value="">Alle steuerbaren Typen</option> <option value="">Alle steuerbaren Typen</option>
<option value="light">Lichter</option> <option value="light">Lichter</option>
<option value="switch">Schalter / Helper</option> <option value="switch">Schalter / Helper</option>
<option value="button">Buttons</option>
<option value="input_button">Helper-Buttons</option>
<option value="input_boolean">Helper-Schalter</option>
<option value="cover">Rollläden / Cover</option> <option value="cover">Rollläden / Cover</option>
<option value="climate">Heizungen / Klima</option>
<option value="lock">Schlösser</option>
<option value="fan">Lüftung / Ventilatoren</option> <option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option> <option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</option>
<option value="number">Numerische Helper</option>
<option value="valve">Ventile</option>
</select> </select>
</div> </div>
<div> <div>
@@ -150,6 +159,7 @@
</select> </select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<div id="actuator-suggestions" class="card-list"></div>
</section> </section>
<section class="wide" id="observed"> <section class="wide" id="observed">
@@ -175,6 +185,7 @@ let currentActuatorId = null;
let actuatorChoices = []; let actuatorChoices = [];
let contextOptions = []; let contextOptions = [];
let manualContextState = {options: [], selected: new Set()}; let manualContextState = {options: [], selected: new Set()};
const ACTUATOR_RESULT_LIMIT = 50;
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
@@ -295,6 +306,7 @@ async function loadOverview() {
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
void loadActuatorSuggestions();
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
@@ -317,13 +329,48 @@ async function loadActuatorDiscovery() {
} }
} }
async function loadActuatorSuggestions() {
const box = document.getElementById("actuator-suggestions");
if (!box) return;
try {
const suggestions = await api("v1/actuators/suggestions");
box.innerHTML = suggestions.length ? `
<h3>Vorschläge aus bestehenden Zusammenhängen</h3>
${suggestions.slice(0, 8).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">Vorschlag übernehmen</button>
</article>
`).join("")}
` : "";
} catch (_) {
box.innerHTML = "";
}
}
function actuatorGroupLabel(domain) { function actuatorGroupLabel(domain) {
const labels = { const labels = {
button: "Buttons",
climate: "Heizungen / Klima",
light: "Lichter", light: "Lichter",
switch: "Schalter / Helper", input_boolean: "Helper-Schalter",
input_button: "Helper-Buttons",
lock: "Schlösser",
media_player: "TV / Medien",
number: "Numerische Helper",
remote: "Fernbedienungen",
switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover", cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren", fan: "Lüftung / Ventilatoren",
humidifier: "Befeuchter / Entfeuchter", humidifier: "Befeuchter / Entfeuchter",
valve: "Ventile",
}; };
return labels[domain] || domain; return labels[domain] || domain;
} }
@@ -335,14 +382,17 @@ function renderActuatorSelect() {
const query = normalizedSearch(document.getElementById("actuator-search")?.value || ""); const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
const filtered = actuatorChoices const filtered = actuatorChoices
.filter(entity => !domain || entity.domain === domain) .filter(entity => !domain || entity.domain === domain)
.filter(entity => matchesSearch(entity, query)) .filter(entity => matchesSearch(entity, query));
.slice(0, 120); const visible = filtered.slice(0, ACTUATOR_RESULT_LIMIT);
const domains = [...new Set(filtered.map(entity => entity.domain))].sort(); const domains = [...new Set(visible.map(entity => entity.domain))].sort();
const limitLabel = filtered.length > visible.length
? ` - ${visible.length} von ${filtered.length}; Suche oder Typ weiter eingrenzen`
: "";
select.innerHTML = [ select.innerHTML = [
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`, `<option value="">${filtered.length ? `Gerät auswählen${limitLabel}` : "Keine passenden Geräte gefunden"}</option>`,
...domains.map(group => ` ...domains.map(group => `
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}"> <optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
${filtered ${visible
.filter(entity => entity.domain === group) .filter(entity => entity.domain === group)
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`) .map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
.join("")} .join("")}
@@ -415,13 +465,28 @@ async function configureActuator() {
async function loadConfiguredActuators() { async function loadConfiguredActuators() {
const box = document.getElementById("configured-actuators"); const box = document.getElementById("configured-actuators");
try { try {
const rows = await api("v1/actuators"); const [rows, entities] = await Promise.all([
api("v1/actuators"),
api("v1/entities"),
]);
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
const groups = new Map();
for (const record of rows) {
const entity = entityMap.get(record.actuator_entity_id) || {};
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({record, entity});
}
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
${groupedRows.map(([group, items]) => `
<h3>${escapeHtml(group)}</h3>
<div class="card-list"> <div class="card-list">
${rows.map(record => ` ${items.map(({record, entity}) => `
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}"> <article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
<div class="card-title"> <div class="card-title">
<div> <div>
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div> <div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div> <div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
</div> </div>
@@ -443,7 +508,8 @@ async function loadConfiguredActuators() {
</div> </div>
</article> </article>
`).join("")} `).join("")}
</div>` : "<p>Noch keine Aktoren ausgewählt.</p>"; </div>
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
} catch (error) { } catch (error) {
box.textContent = error.message; box.textContent = error.message;
} }
@@ -468,6 +534,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.filter(candidate => contexts.includes(candidate.entity_id)) .filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
<code>${escapeHtml(entityId)}</code>
<button class="secondary compact" onclick="removeContextEntity('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(entityId)}')">Entfernen</button>
</li>
`).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction; const prediction = record.behavior.prediction;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
@@ -574,11 +648,17 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${prediction ${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>` ? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"} : "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<div class="actions">
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
</div>
<h3>Passende Home-Assistant-Automationen</h3> <h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p> <p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
${automationControls} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <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>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
@@ -613,6 +693,34 @@ async function saveManualAssignment(actuatorId) {
} }
} }
async function removeContextEntity(actuatorId, entityId) {
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericEntityId = record.assignment.selected_numeric_entity_id === entityId
? null
: record.assignment.selected_numeric_entity_id;
const contextEntityIds = (record.assignment.selected_context_entity_ids || [])
.filter(id => id !== entityId);
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: `Entity ${entityId} entfernt`,
}),
});
await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) {
alert(error.message);
}
}
async function configureSuggestedActuator(actuatorId) {
document.getElementById("actuator-input").value = actuatorId;
await configureActuator();
}
async function evaluateActuator(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
const record = await api( const record = await api(
@@ -632,6 +740,23 @@ async function evaluateActuator(actuatorId) {
} }
} }
async function sendFeedback(actuatorId, correct) {
const expectedState = correct ? null : prompt("Welcher Zustand wäre korrekt gewesen? Leer lassen, wenn nur abwerten.");
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/feedback`, {
method: "POST",
body: JSON.stringify({
correct,
expected_state: expectedState || null,
}),
});
await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) { async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active const question = active
? pauseMatchingAutomations ? pauseMatchingAutomations

View File

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

View File

@@ -277,6 +277,39 @@ def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
assert record.lifecycle.status is LifecycleStatus.ARCHIVED assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
tmp_path: Path,
) -> None:
entities = [
HaEntitySummary(
entity_id="light.treppe_unten",
domain="light",
friendly_name="Licht Treppe Unten",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.shelly_schrank_channel_1_overload",
domain="binary_sensor",
device_class="problem",
friendly_name="Shelly Schrank Channel 1 Überlast",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.terrasse_terasse_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Terrasse Terasse Überhitzung",
area_name="Strom",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.treppe_unten")
assert record.assignment.selected_context_entity_ids == []
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [

View File

@@ -9,6 +9,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.config import Settings from app.config import Settings
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import ( from app.ha.history import (
@@ -227,3 +228,37 @@ def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
assert "sensor.abstellkammer_illuminance" in entity_ids assert "sensor.abstellkammer_illuminance" in entity_ids
assert "binary_sensor.abstellkammer_motion" in entity_ids assert "binary_sensor.abstellkammer_motion" in entity_ids
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids
def test_actuator_discovery_prefers_light_over_duplicate_switch() -> None:
entities = {
"light.schreibtisch": HaEntitySummary(
entity_id="light.schreibtisch",
domain="light",
friendly_name="Schreibtisch Licht",
device_id="device-1",
),
"switch.schreibtisch": HaEntitySummary(
entity_id="switch.schreibtisch",
domain="switch",
friendly_name="Schreibtisch Schalter",
device_id="device-1",
),
"cover.rollladen": HaEntitySummary(
entity_id="cover.rollladen",
domain="cover",
friendly_name="Rollladen",
device_id="device-2",
),
}
result = _deduplicate_actuator_ids(
[
("switch.schreibtisch", "switch_socket"),
("light.schreibtisch", "light"),
("cover.rollladen", "cover_shutter"),
],
entities,
)
assert result == ["cover.rollladen", "light.schreibtisch"]

View File

@@ -27,6 +27,7 @@ class FakeHaReader(HaReader):
entity_id="sensor.temperature", entity_id="sensor.temperature",
domain="sensor", domain="sensor",
device_class="temperature", device_class="temperature",
category="temperature",
role=EntityRole.MEASUREMENT, role=EntityRole.MEASUREMENT,
learnable=True, learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.", reason="Numerischer Messsensor für Zeitreihen und Training.",
@@ -116,6 +117,7 @@ def test_discovery_filters_entities() -> None:
"device_class": "temperature", "device_class": "temperature",
"state_class": None, "state_class": None,
"unit_of_measurement": None, "unit_of_measurement": None,
"category": "temperature",
"role": "measurement", "role": "measurement",
"learnable": True, "learnable": True,
"reason": "Numerischer Messsensor für Zeitreihen und Training.", "reason": "Numerischer Messsensor für Zeitreihen und Training.",

View File

@@ -8,6 +8,7 @@ import pytest
from app.actuators.models import ( from app.actuators.models import (
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
BehaviorPrediction,
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
ExecutionEvent, ExecutionEvent,
@@ -212,6 +213,125 @@ def test_engine_counts_known_automation_actions_like_manual_actions(
assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0} assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0}
def test_feedback_marks_prediction_correct_as_learning_pattern(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
)
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback("light.office", correct=True)
assert result.behavior.patterns[-1].target_state == "on"
assert result.behavior.patterns[-1].context_states == {
"binary_sensor.office_presence": "on"
}
assert result.behavior.patterns[-1].source == "user_feedback"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als korrekt bestätigt."
def test_feedback_marks_prediction_wrong_and_adds_correction(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.office_presence": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=1),
)
],
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
),
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback(
"light.office",
correct=False,
expected_state="off",
)
assert result.behavior.patterns[0].weight == 0.1
assert result.behavior.patterns[-1].target_state == "off"
assert result.behavior.patterns[-1].source == "user_correction"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als falsch markiert."
def test_engine_learns_causal_automation_with_activation_credit( def test_engine_learns_causal_automation_with_activation_credit(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@@ -438,6 +558,7 @@ def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None:
("domain", "state", "service"), ("domain", "state", "service"),
[ [
("light", "on", "turn_on"), ("light", "on", "turn_on"),
("media_player", "off", "turn_off"),
("switch", "off", "turn_off"), ("switch", "off", "turn_off"),
("cover", "open", "open_cover"), ("cover", "open", "open_cover"),
("cover", "closed", "close_cover"), ("cover", "closed", "close_cover"),
@@ -523,3 +644,137 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
min_support=1, min_support=1,
window_minutes=30, window_minutes=30,
) is None ) 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"})
]

View File

@@ -74,3 +74,60 @@ def test_discovery_filters_domain_and_learnable() -> None:
result = discover_entities(entities, domains={" SENSOR "}, learnable=True) result = discover_entities(entities, domains={" SENSOR "}, learnable=True)
assert [item.entity_id for item in result] == ["sensor.temperature"] assert [item.entity_id for item in result] == ["sensor.temperature"]
@pytest.mark.parametrize(
("entity", "category"),
[
(
HaEntitySummary(entity_id="climate.bad", domain="climate"),
"heating",
),
(
HaEntitySummary(entity_id="lock.front_door", domain="lock"),
"lock",
),
(
HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"),
"helper",
),
(
HaEntitySummary(entity_id="media_player.tv", domain="media_player"),
"media_tv",
),
(
HaEntitySummary(
entity_id="sensor.brightness",
domain="sensor",
device_class="illuminance",
),
"brightness",
),
(
HaEntitySummary(
entity_id="binary_sensor.motion",
domain="binary_sensor",
device_class="motion",
),
"presence_motion",
),
],
)
def test_classify_entity_categories(entity: HaEntitySummary, category: str) -> None:
assert classify_entity(entity).category == category
@pytest.mark.parametrize(
"entity",
[
HaEntitySummary(entity_id="automation.lights", domain="automation"),
HaEntitySummary(entity_id="update.core", domain="update"),
],
)
def test_classify_excludes_non_actuator_management_entities(
entity: HaEntitySummary,
) -> None:
result = classify_entity(entity)
assert result.role is EntityRole.UNSUPPORTED
assert result.learnable is False

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -8,6 +9,8 @@ from fastapi.testclient import TestClient
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
from app.main import _ha_event_listener, app as fastapi_app, lifespan from app.main import _ha_event_listener, app as fastapi_app, lifespan
@@ -41,10 +44,32 @@ class _RecordingBehaviorEngine(BehaviorEngine):
store=ActuatorStore(tmp_path / "actuators"), store=ActuatorStore(tmp_path / "actuators"),
settings=MagicMock(), settings=MagicMock(),
) )
self.state_changes: list[tuple[str, dict[str, object] | None]] = [] 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) -> None: def handle_state_change(
self.state_changes.append((entity_id, new_state)) 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: def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
@@ -61,12 +86,16 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
] ]
) )
with patch("websockets.connect", return_value=fake_ws): with patch("websockets.connect", return_value=fake_ws) as connect:
try: try:
await _ha_event_listener(mock_app, mock_client) await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=None,
)
assert fake_ws.sent == [ assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"}, {"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"}, {"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
@@ -79,12 +108,19 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
mock_app.state.ws_status = MagicMock() mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path) mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store") mock_store = ActuatorStore(tmp_path / "store")
mock_app.state.actuator_store = mock_store mock_app.state.actuator_store = mock_store
mock_client = MagicMock() mock_client = MagicMock()
anyio.run(run_test) anyio.run(run_test)
assert mock_engine.state_changes == [("light.test", {"state": "on"})] 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.status == "connected"
assert mock_app.state.ws_status.error is None assert mock_app.state.ws_status.error is None