Compare commits

..

3 Commits

Author SHA1 Message Date
3140f65527 Fix HA websocket state change handling 2026-06-15 18:15:14 +02:00
5727053951 fix: hide diagnostic context suggestions 2026-06-14 23:47:14 +02:00
658516cd96 fix: narrow manual context suggestions 2026-06-14 23:42:50 +02:00
7 changed files with 89 additions and 11 deletions

View File

@@ -1,5 +1,20 @@
# Changelog
## 0.7.7 - 2026-06-15
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
## 0.7.6 - 2026-06-14
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
## 0.7.5 - 2026-06-14
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
gemeinsame Geräte/Tokens oder echte globale Außenwerte
- Diagnosewerte wie MQTT-, WiFi-, Restart- und Connect-Zähler werden nicht mehr
als fachliche Kontextvorschläge angeboten
## 0.7.4 - 2026-06-14
- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
Roh-Liste aller Sensoren und Zustände

View File

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

View File

@@ -79,6 +79,31 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 120
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"connect",
"count",
"diagnostic",
"firmware",
"gesehen",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"reason",
"restart",
"rssi",
"signal",
"ssid",
"status",
"uptime",
"wifi",
"zuletzt",
})
class ActuatorReconciliationService:
@@ -131,6 +156,11 @@ class ActuatorReconciliationService:
selected = entity.entity_id in selected_ids
if selected:
score = max(score, 1.0)
if not selected and (
_is_diagnostic_context(entity)
or not _has_context_relationship(actuator, entity)
):
continue
if not selected and score < 0.1:
continue
ranked.append((score, _context_sort_group(entity), entity))
@@ -687,6 +717,32 @@ def _context_sort_group(entity: HaEntitySummary) -> str:
return f"20_{entity.domain}_{device_class}"
def _is_diagnostic_context(entity: HaEntitySummary) -> bool:
tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(tokens.intersection(_DIAGNOSTIC_TOKENS))
def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary) -> bool:
if (
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
):
return True
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
return True
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS)
and entity.device_class in {"illuminance", "humidity", "temperature"}
)
def _score_candidate(
actuator: HaEntitySummary,
entity: HaEntitySummary,
@@ -732,8 +788,7 @@ def _score_candidate(
if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
outdoor_tokens = {"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"}
if entity_tokens.intersection(outdoor_tokens) and entity.device_class in {
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
"illuminance",
"humidity",
"temperature",
@@ -801,7 +856,7 @@ def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False) -> set[str]:
raw_values = [
entity.entity_id,
entity.friendly_name,
@@ -813,7 +868,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
if len(token) < 3 or token in _STOPWORDS:
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return tokens

View File

@@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.4",
version="0.7.7",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -213,12 +213,20 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
event = data.get("event", {})
if event.get("event_type") != "state_changed":
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:
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread(engine.handle_state_change, entity_id, event.get("new_state"))
await asyncio.to_thread(
engine.handle_state_change,
entity_id,
event_data.get("new_state"),
)
except json.JSONDecodeError:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc:

View File

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

View File

@@ -78,7 +78,7 @@ def _service(
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"),
history_days=14,
history_days=31,
min_training_points=5,
retrain_stale_hours=24,
reconcile_interval_seconds=900,

View File

@@ -55,7 +55,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
'{"type":"auth_ok"}',
(
'{"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(),
]