Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 309b33b812 | |||
| 9db7cde179 | |||
| 3140f65527 | |||
| 5727053951 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.9 - 2026-06-15
|
||||||
|
- Event-basierte Vorhersagen verwenden den frischen Sensorzustand direkt aus
|
||||||
|
dem Home-Assistant-WebSocket-Event, damit Kontextwechsel ohne REST-Race sofort
|
||||||
|
bewertet und geschaltet werden können
|
||||||
|
- Regressionstest stellt sicher, dass ein Türsensor-Event trotz veraltetem
|
||||||
|
HA-Snapshot direkt `light.turn_on` auslöst
|
||||||
|
|
||||||
|
## 0.7.8 - 2026-06-15
|
||||||
|
- Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive-
|
||||||
|
Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu
|
||||||
|
aufgebaut werden
|
||||||
|
- Fallback-Auswertung läuft bei getrenntem WebSocket kurzfristig alle 5 Sekunden,
|
||||||
|
damit übernommene Aktoren nicht ohne Steuerung bleiben
|
||||||
|
|
||||||
|
## 0.7.7 - 2026-06-15
|
||||||
|
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
|
||||||
|
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
|
||||||
|
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
|
||||||
|
|
||||||
|
## 0.7.6 - 2026-06-14
|
||||||
|
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
|
||||||
|
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
|
||||||
|
|
||||||
## 0.7.5 - 2026-06-14
|
## 0.7.5 - 2026-06-14
|
||||||
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
|
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
|
||||||
gemeinsame Geräte/Tokens oder echte globale Außenwerte
|
gemeinsame Geräte/Tokens oder echte globale Außenwerte
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.7.5"
|
version: "0.7.9"
|
||||||
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
|
||||||
|
|||||||
@@ -81,20 +81,28 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
|
|||||||
_CONTEXT_SUGGESTION_LIMIT = 120
|
_CONTEXT_SUGGESTION_LIMIT = 120
|
||||||
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
|
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
|
||||||
_DIAGNOSTIC_TOKENS = frozenset({
|
_DIAGNOSTIC_TOKENS = frozenset({
|
||||||
|
"basic",
|
||||||
|
"battery",
|
||||||
"connect",
|
"connect",
|
||||||
"count",
|
"count",
|
||||||
"diagnostic",
|
"diagnostic",
|
||||||
"firmware",
|
"firmware",
|
||||||
|
"gesehen",
|
||||||
"last",
|
"last",
|
||||||
"linkquality",
|
"linkquality",
|
||||||
|
"knoten",
|
||||||
|
"knotens",
|
||||||
"mqtt",
|
"mqtt",
|
||||||
|
"node",
|
||||||
"reason",
|
"reason",
|
||||||
"restart",
|
"restart",
|
||||||
"rssi",
|
"rssi",
|
||||||
"signal",
|
"signal",
|
||||||
"ssid",
|
"ssid",
|
||||||
|
"status",
|
||||||
"uptime",
|
"uptime",
|
||||||
"wifi",
|
"wifi",
|
||||||
|
"zuletzt",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,13 @@ 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,
|
||||||
|
) -> 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:
|
try:
|
||||||
@@ -230,6 +236,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,
|
||||||
@@ -622,6 +644,8 @@ class BehaviorEngine:
|
|||||||
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 +657,37 @@ 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},
|
||||||
|
)
|
||||||
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],
|
||||||
*,
|
*,
|
||||||
|
|||||||
24
app/main.py
24
app/main.py
@@ -100,7 +100,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.5",
|
version="0.7.9",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -171,7 +171,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":
|
||||||
@@ -213,12 +213,20 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
event = data.get("event", {})
|
event = data.get("event", {})
|
||||||
if event.get("event_type") != "state_changed":
|
if event.get("event_type") != "state_changed":
|
||||||
continue
|
continue
|
||||||
entity_id = event.get("entity_id")
|
event_data = event.get("data", {})
|
||||||
|
if not isinstance(event_data, dict):
|
||||||
|
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
|
||||||
|
continue
|
||||||
|
entity_id = event_data.get("entity_id")
|
||||||
if not entity_id:
|
if not entity_id:
|
||||||
continue
|
continue
|
||||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||||
await asyncio.to_thread(engine.handle_state_change, entity_id, event.get("new_state"))
|
await asyncio.to_thread(
|
||||||
|
engine.handle_state_change,
|
||||||
|
entity_id,
|
||||||
|
event_data.get("new_state"),
|
||||||
|
)
|
||||||
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:
|
||||||
@@ -244,7 +252,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":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.7.5"
|
version = "0.7.9"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def _service(
|
|||||||
model_store=str(tmp_path / "models"),
|
model_store=str(tmp_path / "models"),
|
||||||
automation_store=str(tmp_path / "automations"),
|
automation_store=str(tmp_path / "automations"),
|
||||||
actuator_store=str(tmp_path / "actuators"),
|
actuator_store=str(tmp_path / "actuators"),
|
||||||
history_days=14,
|
history_days=31,
|
||||||
min_training_points=5,
|
min_training_points=5,
|
||||||
retrain_stale_hours=24,
|
retrain_stale_hours=24,
|
||||||
reconcile_interval_seconds=900,
|
reconcile_interval_seconds=900,
|
||||||
|
|||||||
@@ -523,3 +523,67 @@ 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"})
|
||||||
|
]
|
||||||
|
|||||||
@@ -55,18 +55,22 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
'{"type":"auth_ok"}',
|
'{"type":"auth_ok"}',
|
||||||
(
|
(
|
||||||
'{"type":"event","event":{"event_type":"state_changed",'
|
'{"type":"event","event":{"event_type":"state_changed",'
|
||||||
'"entity_id":"light.test","new_state":{"state":"on"}}}'
|
'"data":{"entity_id":"light.test","new_state":{"state":"on"}}}}'
|
||||||
),
|
),
|
||||||
asyncio.CancelledError(),
|
asyncio.CancelledError(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
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"},
|
||||||
|
|||||||
Reference in New Issue
Block a user