From e2826e92ec51f28a23d9d3e27cedfae98eadb3c6 Mon Sep 17 00:00:00 2001 From: Otto Date: Tue, 16 Jun 2026 11:38:32 +0200 Subject: [PATCH] Improve SillyHome discovery and feedback learning --- CHANGELOG.md | 9 +++ addon/config.yaml | 2 +- app/api/v1/actuators.py | 65 +++++++++++++++++- app/behavior/engine.py | 103 +++++++++++++++++++++++++++++ app/ha/discovery.py | 91 +++++++++++++++++++++++++- app/main.py | 2 +- app/static/index.html | 37 ++++++++++- pyproject.toml | 2 +- tests/api/test_actuators.py | 35 ++++++++++ tests/api/test_entities.py | 2 + tests/behavior/test_engine.py | 120 ++++++++++++++++++++++++++++++++++ tests/ha/test_discovery.py | 53 +++++++++++++++ 12 files changed, 513 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8955c9..b35a1d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 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. diff --git a/addon/config.yaml b/addon/config.yaml index c680163..bfecb34 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.10" +version: "0.7.11" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 44457af..0d34004 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -38,12 +38,22 @@ class ManualAssignmentRequest(BaseModel): note: str | None = Field(default=None, max_length=500) +class FeedbackRequest(BaseModel): + correct: bool + expected_state: str | None = Field(default=None, max_length=100) + + @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} discovered = ha_reader.discover() - actuator_ids = sorted( - entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR + actuator_ids = _deduplicate_actuator_ids( + [ + (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] @@ -116,6 +126,22 @@ def evaluate_actuator( 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) def set_activation( actuator_entity_id: str, @@ -233,3 +259,38 @@ def _behavior(request: Request) -> BehaviorEngine: detail="Verhaltenslernen ist nicht initialisiert.", ) 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}" diff --git a/app/behavior/engine.py b/app/behavior/engine.py index f4f15ce..944a1f0 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -356,6 +356,95 @@ class BehaviorEngine: ) 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: record = self._store.get(actuator_entity_id) related = [ @@ -856,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( history: dict[str, StateHistorySeries], context_ids: list[str], diff --git a/app/ha/discovery.py b/app/ha/discovery.py index e56343c..28dc00a 100644 --- a/app/ha/discovery.py +++ b/app/ha/discovery.py @@ -21,6 +21,7 @@ class DiscoveredEntity(BaseModel): device_class: str | None = None state_class: str | None = None unit_of_measurement: str | None = None + category: str role: EntityRole learnable: bool reason: str @@ -82,14 +83,39 @@ _BINARY_CONTEXT_CLASSES = frozenset({ "window", }) _ACTUATOR_DOMAINS = frozenset({ + "button", + "climate", "cover", "fan", "humidifier", + "input_boolean", + "input_button", + "lock", "light", + "number", + "siren", "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"}) @@ -102,6 +128,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity: return _result( entity, EntityRole.MEASUREMENT, + category=_measurement_category(entity), learnable=True, reason="Numerischer Messsensor für Zeitreihen und Training.", ) @@ -110,6 +137,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity: return _result( entity, EntityRole.BINARY_CONTEXT, + category=_binary_category(entity), learnable=True, reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.", ) @@ -119,6 +147,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity: return _result( entity, EntityRole.CONTEXT, + category=_context_category(entity), learnable=learnable, reason=( "Kontextquelle für Training und Erklärungen." @@ -131,6 +160,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity: return _result( entity, EntityRole.ACTUATOR, + category=_actuator_category(entity), learnable=False, reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.", ) @@ -138,6 +168,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity: return _result( entity, EntityRole.UNSUPPORTED, + category="unsupported", learnable=False, reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.", ) @@ -162,6 +193,7 @@ def _result( entity: HaEntitySummary, role: EntityRole, *, + category: str, learnable: bool, reason: str, ) -> DiscoveredEntity: @@ -171,7 +203,62 @@ def _result( device_class=entity.device_class, state_class=entity.state_class, unit_of_measurement=entity.unit_of_measurement, + category=category, role=role, learnable=learnable, 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 {"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 diff --git a/app/main.py b/app/main.py index 0169d60..49158cc 100644 --- a/app/main.py +++ b/app/main.py @@ -102,7 +102,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.7.10", + version="0.7.11", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 6247dc2..fdb1128 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -134,9 +134,16 @@ + + + + + + +
@@ -319,11 +326,18 @@ async function loadActuatorDiscovery() { function actuatorGroupLabel(domain) { const labels = { + button: "Buttons", + climate: "Heizungen / Klima", light: "Lichter", - switch: "Schalter / Helper", + input_boolean: "Helper-Schalter", + input_button: "Helper-Buttons", + lock: "Schlösser", + number: "Numerische Helper", + switch: "Schalter / Steckdosen", cover: "Rollläden / Cover", fan: "Lüftung / Ventilatoren", humidifier: "Befeuchter / Entfeuchter", + valve: "Ventile", }; return labels[domain] || domain; } @@ -574,6 +588,10 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${prediction ? `

${escapeHtml(prediction.target_state)} mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${escapeHtml(prediction.execution_reason)}

` : "

Aktuell ist kein gelerntes Handlungsmuster fällig.

"} +
+ + +

Passende Home-Assistant-Automationen

Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.

${automationControls} @@ -632,6 +650,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) { const question = active ? pauseMatchingAutomations diff --git a/pyproject.toml b/pyproject.toml index cba0e28..c085f5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.10" +version = "0.7.11" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 4b25515..d46b8cc 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -9,6 +9,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine 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 discover_entities 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 "binary_sensor.abstellkammer_motion" 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"] diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index 6333c91..f35713c 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -27,6 +27,7 @@ class FakeHaReader(HaReader): entity_id="sensor.temperature", domain="sensor", device_class="temperature", + category="temperature", role=EntityRole.MEASUREMENT, learnable=True, reason="Numerischer Messsensor für Zeitreihen und Training.", @@ -116,6 +117,7 @@ def test_discovery_filters_entities() -> None: "device_class": "temperature", "state_class": None, "unit_of_measurement": None, + "category": "temperature", "role": "measurement", "learnable": True, "reason": "Numerischer Messsensor für Zeitreihen und Training.", diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index 7d809ef..f2cea2b 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -8,6 +8,7 @@ import pytest from app.actuators.models import ( BehaviorMode, BehaviorPattern, + BehaviorPrediction, BehaviorState, BehaviorStatus, 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} +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( tmp_path: Path, ) -> None: diff --git a/tests/ha/test_discovery.py b/tests/ha/test_discovery.py index 92ac0fc..2571da8 100644 --- a/tests/ha/test_discovery.py +++ b/tests/ha/test_discovery.py @@ -74,3 +74,56 @@ def test_discovery_filters_domain_and_learnable() -> None: result = discover_entities(entities, domains={" SENSOR "}, learnable=True) 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="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