From 1b2b76455a2999a918f8827c4c50786efdb8cbbf Mon Sep 17 00:00:00 2001 From: Otto Date: Tue, 16 Jun 2026 12:06:03 +0200 Subject: [PATCH] Tighten context onboarding and actuator suggestions --- CHANGELOG.md | 11 +++ addon/config.yaml | 2 +- app/actuators/lifecycle.py | 44 +++++++++- app/api/v1/actuators.py | 138 +++++++++++++++++++++++++++++- app/behavior/engine.py | 2 +- app/ha/discovery.py | 4 + app/main.py | 2 +- app/static/index.html | 75 +++++++++++++++- pyproject.toml | 2 +- tests/actuators/test_lifecycle.py | 33 +++++++ tests/behavior/test_engine.py | 1 + tests/ha/test_discovery.py | 4 + 12 files changed, 310 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f89a0..f470482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 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. diff --git a/addon/config.yaml b/addon/config.yaml index 992ba10..7f35013 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.12" +version: "0.7.13" 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/actuators/lifecycle.py b/app/actuators/lifecycle.py index 9d5fcf0..e967767 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -57,7 +57,7 @@ _STOPWORDS = frozenset( "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_MIN_SCORE = 0.5 _NUMERIC_MIN_MARGIN = 0.18 @@ -88,6 +88,7 @@ _DIAGNOSTIC_TOKENS = frozenset({ "diagnostic", "firmware", "gesehen", + "heat", "last", "linkquality", "knoten", @@ -100,10 +101,28 @@ _DIAGNOSTIC_TOKENS = frozenset({ "signal", "ssid", "status", + "overheat", + "overheating", + "overload", "uptime", + "uberhitzung", + "ueberhitzung", + "ueberlast", + "überhitzung", + "überlast", "wifi", "zuletzt", }) +_AUTO_CONTEXT_CLASSES = frozenset({ + "door", + "garage_door", + "illuminance", + "motion", + "occupancy", + "opening", + "presence", + "window", +}) class ActuatorReconciliationService: @@ -628,8 +647,12 @@ class ActuatorReconciliationService: if context else _NUMERIC_AUTO_ACCEPT_MIN_SCORE ) + can_auto_accept_context = ( + not context or _eligible_for_auto_context(actuator, candidate) + ) auto_accepted = ( - candidate.score >= minimum_score + can_auto_accept_context + and candidate.score >= minimum_score and confidence >= auto_score 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( actuator: HaEntitySummary, entity: HaEntitySummary, diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 0d34004..2ee76e7 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -8,7 +8,7 @@ from app.actuators.models import ActuatorRecord, ReconciliationState from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine 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.models import HaEntitySummary from app.ha.reader import HaReader @@ -43,6 +43,18 @@ class FeedbackRequest(BaseModel): 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]) def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} @@ -58,6 +70,66 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE 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]) def context_options( request: Request, @@ -294,3 +366,67 @@ def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str: 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 diff --git a/app/behavior/engine.py b/app/behavior/engine.py index 944a1f0..5feebbf 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -898,7 +898,7 @@ def predict_behavior( 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) if domain == "cover": return {"open": "open_cover", "closed": "close_cover"}.get(target_state) diff --git a/app/ha/discovery.py b/app/ha/discovery.py index 28dc00a..fe8dd64 100644 --- a/app/ha/discovery.py +++ b/app/ha/discovery.py @@ -92,7 +92,9 @@ _ACTUATOR_DOMAINS = frozenset({ "input_button", "lock", "light", + "media_player", "number", + "remote", "siren", "switch", "valve", @@ -225,6 +227,8 @@ def _actuator_category(entity: HaEntitySummary) -> str: 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 diff --git a/app/main.py b/app/main.py index c05f747..5790b53 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.12", + version="0.7.13", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 7777e4a..7b3a2a4 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -142,6 +142,8 @@ + + @@ -157,6 +159,7 @@

Noch kein Aktor ausgewählt.

+
@@ -302,7 +305,11 @@ async function loadOverview() { status.innerHTML = `

${escapeHtml(error.message)}

`; chips.innerHTML = ""; } - await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); + await Promise.all([ + loadActuatorDiscovery(), + loadActuatorSuggestions(), + loadConfiguredActuators(), + ]); } async function loadActuatorDiscovery() { @@ -325,6 +332,32 @@ 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 ? ` +

Vorschläge aus bestehenden Zusammenhängen

+ ${suggestions.slice(0, 8).map(item => ` +
+
+
+
${escapeHtml(item.entity_id)}
+
${escapeHtml(item.area_name || item.device_name || item.domain)}
+
+ ${Math.round(item.confidence * 100)} % +
+

${escapeHtml(item.reason)}

+ +
+ `).join("")} + ` : ""; + } catch (_) { + box.innerHTML = ""; + } +} + function actuatorGroupLabel(domain) { const labels = { button: "Buttons", @@ -333,7 +366,9 @@ function actuatorGroupLabel(domain) { 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", fan: "Lüftung / Ventilatoren", @@ -486,6 +521,14 @@ async function showActuator(actuatorId, evaluationMessage = "") { .filter(candidate => contexts.includes(candidate.entity_id)) .map(candidate => `
  • ${escapeHtml(candidate.friendly_name || candidate.entity_id)}: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}
  • `) .join(""); + const currentContextControls = contexts.length + ? `` + : "

    Noch keine Kontext-Entity ausgewählt.

    "; const prediction = record.behavior.prediction; const learnedAutomationActions = record.behavior.patterns.filter( pattern => pattern.source === "automation", @@ -601,6 +644,8 @@ async function showActuator(actuatorId, evaluationMessage = "") { ${automationControls}

    Welche Zusammenhänge automatisch verwendet werden

    ${evidence ? `` : "

    Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.

    "} +

    Verwendete Sensoren/Zustände ändern

    + ${currentContextControls} ${manualAssignment} `; document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); @@ -635,6 +680,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) { try { const record = await api( diff --git a/pyproject.toml b/pyproject.toml index db1a476..d3ddf5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.12" +version = "0.7.13" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py index 14743c9..1ea98dd 100644 --- a/tests/actuators/test_lifecycle.py +++ b/tests/actuators/test_lifecycle.py @@ -277,6 +277,39 @@ def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context( 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: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index f2cea2b..7c41677 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -558,6 +558,7 @@ def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None: ("domain", "state", "service"), [ ("light", "on", "turn_on"), + ("media_player", "off", "turn_off"), ("switch", "off", "turn_off"), ("cover", "open", "open_cover"), ("cover", "closed", "close_cover"), diff --git a/tests/ha/test_discovery.py b/tests/ha/test_discovery.py index 2571da8..acbc790 100644 --- a/tests/ha/test_discovery.py +++ b/tests/ha/test_discovery.py @@ -91,6 +91,10 @@ def test_discovery_filters_domain_and_learnable() -> None: 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",