Compare commits

...

7 Commits

Author SHA1 Message Date
faf4099756 Load actuator suggestions asynchronously
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:14:51 +02:00
1b2b76455a Tighten context onboarding and actuator suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:06:03 +02:00
18999ff68a Limit actuator picker results
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:43:00 +02:00
e2826e92ec Improve SillyHome discovery and feedback learning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:38:32 +02:00
c5f42a39a9 Fix realtime HA state-change execution
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 10:50:28 +02:00
309b33b812 Use fresh HA event state for behavior triggers
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 19:37:45 +02:00
9db7cde179 Fix HA websocket keepalive fallback
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-15 19:30:02 +02:00
15 changed files with 1196 additions and 44 deletions

View File

@@ -1,5 +1,53 @@
# Changelog # Changelog
## 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.14"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -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,23 +189,32 @@ 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)
try: if current_entities is None:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} try:
except HaClientError as exc: current_entities = self._ha_reader.read_entities()
logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc) except HaClientError as exc:
return self._save_behavior( logger.warning("Current HA state unavailable for %s: %s", actuator_entity_id, exc)
record, return self._save_behavior(
record.behavior.model_copy( record,
update={ record.behavior.model_copy(
"last_evaluated_at": now, update={
"prediction": None, "last_evaluated_at": now,
"reason": f"Aktueller Home-Assistant-Zustand ist nicht verfügbar: {exc}", "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) 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
@@ -100,7 +102,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.14",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -155,14 +157,20 @@ 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 +179,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 +202,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,12 +229,15 @@ 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")
@@ -252,7 +264,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":
@@ -263,3 +281,67 @@ async def _fallback_prediction(app: FastAPI) -> None:
ws_status.status if ws_status else "unavailable", ws_status.status if ws_status else "unavailable",
) )
await asyncio.to_thread(engine.evaluate_all) 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

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("")}
@@ -468,6 +518,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 +632,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 +677,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 +724,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.14"
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