From 8cd8f3e3b7c61912d1af06bd9a5f2ff9832af054 Mon Sep 17 00:00:00 2001 From: Otto Date: Sun, 14 Jun 2026 23:35:38 +0200 Subject: [PATCH] feat: improve actor-specific context selection --- CHANGELOG.md | 10 ++ addon/config.yaml | 2 +- app/actuators/lifecycle.py | 103 ++++++++++++++++++ app/api/v1/actuators.py | 36 ++----- app/main.py | 2 +- app/static/index.html | 209 +++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- tests/api/test_actuators.py | 16 ++- tests/test_dashboard.py | 3 + 9 files changed, 325 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b518d..95d79f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.7.4 - 2026-06-14 +- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen + Roh-Liste aller Sensoren und Zustände +- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und + durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden +- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne + Dropdown-Treffer gespeichert werden können +- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne + fachlichen Bezug nicht mehr als Standardvorschläge auf + ## 0.7.3 - 2026-06-14 - Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`, damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären diff --git a/addon/config.yaml b/addon/config.yaml index 0a2de0d..7f2ae29 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.3" +version: "0.7.4" 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 a216af5..41681ce 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -65,6 +65,20 @@ _CONTEXT_AUTO_ACCEPT_SCORE = 0.78 _CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3 _MAX_CONTEXT_SELECTIONS = 5 _AUDIT_LIMIT = 20 +_MANUAL_CONTEXT_DOMAINS = frozenset({ + "binary_sensor", + "climate", + "cover", + "device_tracker", + "fan", + "humidifier", + "light", + "person", + "sensor", + "switch", + "weather", +}) +_CONTEXT_SUGGESTION_LIMIT = 120 class ActuatorReconciliationService: @@ -91,6 +105,46 @@ class ActuatorReconciliationService: def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord: return self._store.get(actuator_entity_id) + def suggest_context_options( + self, + actuator_entity_id: str, + *, + limit: int = _CONTEXT_SUGGESTION_LIMIT, + ) -> list[HaEntitySummary]: + entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} + discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()} + actuator = entities.get(actuator_entity_id) + if actuator is None: + raise KeyError("Aktuator-Konfiguration nicht gefunden.") + selected_ids = _selected_context_ids(self._store.get(actuator_entity_id)) + ranked: list[tuple[float, str, HaEntitySummary]] = [] + for entity in entities.values(): + if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS: + continue + role = _manual_context_role(entity, discovered.get(entity.entity_id)) + score, _ = _score_candidate( + actuator, + entity, + role, + context=role is not EntityRole.MEASUREMENT, + ) + selected = entity.entity_id in selected_ids + if selected: + score = max(score, 1.0) + if not selected and score < 0.1: + continue + ranked.append((score, _context_sort_group(entity), entity)) + ranked.sort( + key=lambda item: ( + -item[0], + item[1], + item[2].area_name or "", + item[2].friendly_name or item[2].entity_id, + item[2].entity_id, + ) + ) + return [entity for _, _, entity in ranked[:limit]] + def delete_actuator(self, actuator_entity_id: str) -> None: model_id = model_id_for_actuator(actuator_entity_id) self._registry.archive(model_id) @@ -592,6 +646,47 @@ def _filter_candidates( return result +def _selected_context_ids(record: ActuatorRecord) -> set[str]: + result = set(record.assignment.selected_context_entity_ids) + if record.assignment.selected_numeric_entity_id: + result.add(record.assignment.selected_numeric_entity_id) + if record.manual_override is not None: + result.update(record.manual_override.context_entity_ids) + if record.manual_override.numeric_entity_id: + result.add(record.manual_override.numeric_entity_id) + return result + + +def _manual_context_role( + entity: HaEntitySummary, + discovered: DiscoveredEntity | None, +) -> EntityRole: + if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED: + return discovered.role + if entity.domain == "sensor": + return EntityRole.MEASUREMENT + if entity.domain == "binary_sensor": + return EntityRole.BINARY_CONTEXT + return EntityRole.CONTEXT + + +def _context_sort_group(entity: HaEntitySummary) -> str: + device_class = entity.device_class or "" + if device_class in {"motion", "occupancy", "presence"}: + return "01_presence" + if device_class in {"illuminance"}: + return "02_brightness" + if device_class in {"door", "garage_door", "opening", "window"}: + return "03_opening" + if device_class in {"humidity", "moisture"}: + return "04_humidity" + if device_class in {"power", "energy", "current", "voltage"}: + return "05_power" + if entity.domain in {"light", "switch"}: + return "06_states" + return f"20_{entity.domain}_{device_class}" + + def _score_candidate( actuator: HaEntitySummary, entity: HaEntitySummary, @@ -637,6 +732,14 @@ 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 { + "illuminance", + "humidity", + "temperature", + }: + score += 0.1 + evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.") return round(min(score, 1.0), 4), evidence diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 374e0c4..44457af 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -14,19 +14,6 @@ from app.ha.models import HaEntitySummary from app.ha.reader import HaReader router = APIRouter(prefix="/v1/actuators", tags=["actuators"]) -_MANUAL_CONTEXT_DOMAINS = frozenset({ - "binary_sensor", - "climate", - "cover", - "device_tracker", - "fan", - "humidifier", - "light", - "person", - "sensor", - "switch", - "weather", -}) class ConfigureActuatorRequest(BaseModel): @@ -62,19 +49,16 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE @router.get("/context-options", response_model=list[HaEntitySummary]) -def context_options(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: - return sorted( - [ - entity - for entity in ha_reader.read_entities() - if entity.domain in _MANUAL_CONTEXT_DOMAINS - ], - key=lambda entity: ( - entity.area_name or "", - entity.friendly_name or entity.entity_id, - entity.entity_id, - ), - ) +def context_options( + request: Request, + actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"), +) -> list[HaEntitySummary]: + if actuator_entity_id is None: + return [] + try: + return _service(request).suggest_context_options(actuator_entity_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("", response_model=list[ActuatorRecord]) diff --git a/app/main.py b/app/main.py index 90fd207..43d1387 100644 --- a/app/main.py +++ b/app/main.py @@ -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.3", + version="0.7.4", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index d6c4219..6247dc2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -51,6 +51,10 @@ .actions button { flex:1 1 180px; margin-top:0; } .detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; } .manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; } + .inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; } + .manual-entry { min-height:80px; resize:vertical; } + textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; } + optgroup { color:#cfe0ec; background:#101820; } code { color:#cfe0ec; overflow-wrap:anywhere; } @media (max-width: 760px) { header { padding:18px 14px; } @@ -123,6 +127,23 @@ +
+
+ + +
+
+ + +
+
- ${numericOptions.map(entity => ` - - `).join("")} + ${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))} - +
+
+ + +
+
+ + +
+
+ + +
- +
`; @@ -439,9 +589,14 @@ async function showActuator(actuatorId, evaluationMessage = "") { async function saveManualAssignment(actuatorId) { const numericEntityId = document.getElementById("manual-numeric-select").value || null; - const contextEntityIds = Array.from( + const selectedContextIds = Array.from( document.getElementById("manual-context-select").selectedOptions, - ).map(option => option.value); + ).map(option => option.value).filter(value => value.includes(".")); + const freeformContextIds = parseEntityIds( + document.getElementById("manual-context-freeform").value, + ); + const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])] + .filter(entityId => entityId !== numericEntityId); try { await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, { method: "POST", diff --git a/pyproject.toml b/pyproject.toml index e87488c..6f0abb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "0.7.3" +version = "0.7.4" 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 1af9ab5..4b25515 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -115,6 +115,14 @@ def _install_service(tmp_path: Path) -> None: friendly_name="Abstellkammer Bewegung", area_name="Abstellkammer", ), + HaEntitySummary( + entity_id="sensor.pfsense_interface_vpn_inbytes", + domain="sensor", + device_class="data_size", + state_class="measurement", + unit_of_measurement="KiB", + friendly_name="pfSense Interface VPN inbytes", + ), ] settings = Settings( ha_url="http://ha.local", @@ -208,10 +216,14 @@ def test_context_options_returns_learnable_entities(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) - response = client.get("/v1/actuators/context-options") + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + response = client.get( + "/v1/actuators/context-options", + params={"actuator_entity_id": "light.abstellkammer"}, + ) assert response.status_code == 200 entity_ids = {item["entity_id"] for item in response.json()} assert "sensor.abstellkammer_illuminance" in entity_ids assert "binary_sensor.abstellkammer_motion" in entity_ids - assert "light.abstellkammer" in entity_ids + assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index c278aed..8ef80cd 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -13,6 +13,7 @@ def test_dashboard_is_served_at_root() -> None: assert "Gerät zum Lernen auswählen" in response.text assert "Entitätsname oder Gerät aus Home Assistant" in response.text assert "Oder aus Liste wählen" in response.text + assert "Liste durchsuchen" in response.text assert "Wie gewohnt bedienen" in response.text assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text @@ -23,6 +24,8 @@ def test_dashboard_is_served_at_root() -> None: assert "Davon erkannte HA-Automationen" in response.text assert "Aktuelle Situation auswerten" in response.text assert "Kontext selbst festlegen" in response.text + assert "Entity-IDs manuell ergänzen" in response.text + assert "manual-context-freeform" in response.text assert "Diese Kontext-Auswahl speichern" in response.text assert "manual-context-select" in response.text assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text