diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c5942..3920d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.7.18 - 2026-06-16 +- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und + rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege. +- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie + "Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen. +- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände, + Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer + kategorisiert und per Suche/Kategorie erreichbar. +- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur, + Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter. + ## 0.7.17 - 2026-06-16 - WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor dem teuren State-Cache-Listenbau verworfen. diff --git a/addon/config.yaml b/addon/config.yaml index dda4e85..855ef64 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.17" +version: "0.7.18" 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 e967767..ed81b2f 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -72,29 +72,42 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({ "device_tracker", "fan", "humidifier", + "input_boolean", + "input_number", + "input_select", "light", + "media_player", "person", + "remote", + "scene", "sensor", + "sun", "switch", "weather", }) -_CONTEXT_SUGGESTION_LIMIT = 120 +_CONTEXT_SUGGESTION_LIMIT = 500 _OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"}) _DIAGNOSTIC_TOKENS = frozenset({ "basic", "battery", + "bytes", "connect", "count", + "data", "diagnostic", "firmware", "gesehen", "heat", + "inbytes", + "interface", "last", "linkquality", "knoten", "knotens", "mqtt", "node", + "outbytes", + "pfsense", "reason", "restart", "rssi", @@ -105,6 +118,7 @@ _DIAGNOSTIC_TOKENS = frozenset({ "overheating", "overload", "uptime", + "vpn", "uberhitzung", "ueberhitzung", "ueberlast", @@ -175,13 +189,10 @@ 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: + if not selected and _is_diagnostic_context(entity): continue + if not selected and not _has_context_relationship(actuator, entity): + score = max(score, 0.01) ranked.append((score, _context_sort_group(entity), entity)) ranked.sort( key=lambda item: ( @@ -849,12 +860,17 @@ def _merge_manual_candidates( for entity_id in selected_entity_ids: existing = by_id.get(entity_id) if existing is not None: + evidence = [ + item + for item in existing.evidence + if item != "Manuell vom Nutzer als relevant festgelegt." + ] by_id[entity_id] = existing.model_copy( update={ "auto_accepted": True, "confidence": 1.0, "evidence": [ - *existing.evidence, + *evidence, "Manuell vom Nutzer als relevant festgelegt.", ], } @@ -883,13 +899,28 @@ def _merge_manual_candidates( def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: if context: - return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) + mapping = { + "climate": {"occupancy", "presence", "window"}, + "cover": {"illuminance", "wind_speed"}, + "fan": {"humidity", "moisture", "occupancy", "presence", "temperature"}, + "humidifier": {"humidity", "moisture", "temperature"}, + "light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, + "switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, + } + return frozenset( + mapping.get( + domain, + {"door", "garage_door", "motion", "occupancy", "opening", "presence"}, + ) + ) mapping = { - "climate": {"temperature", "humidity", "power"}, + "climate": {"temperature", "humidity"}, "cover": {"illuminance", "temperature", "wind_speed"}, - "fan": {"temperature", "humidity", "power"}, - "humidifier": {"humidity", "temperature", "power"}, - "light": {"illuminance", "power", "energy"}, + "fan": {"temperature", "humidity", "moisture"}, + "humidifier": {"humidity", "moisture", "temperature"}, + "light": {"illuminance"}, + "media_player": {"power", "energy"}, + "remote": {"battery"}, "switch": {"power", "energy", "current"}, "valve": {"temperature", "pressure", "humidity"}, } diff --git a/app/behavior/engine.py b/app/behavior/engine.py index 5feebbf..534ccb5 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -900,6 +900,8 @@ def predict_behavior( def service_for_state(domain: str, target_state: str) -> str | None: if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}: return {"on": "turn_on", "off": "turn_off"}.get(target_state) + if domain == "scene": + return "turn_on" if target_state == "on" else None if domain == "cover": return {"open": "open_cover", "closed": "close_cover"}.get(target_state) return None diff --git a/app/ha/discovery.py b/app/ha/discovery.py index fe8dd64..f0a9414 100644 --- a/app/ha/discovery.py +++ b/app/ha/discovery.py @@ -95,6 +95,7 @@ _ACTUATOR_DOMAINS = frozenset({ "media_player", "number", "remote", + "scene", "siren", "switch", "valve", @@ -229,6 +230,8 @@ def _actuator_category(entity: HaEntitySummary) -> str: return "fan" if entity.domain in {"media_player", "remote"}: return "media_tv" + if entity.domain == "scene": + return "scene" if entity.domain in {"input_boolean", "number"}: return "helper" return entity.domain diff --git a/app/main.py b/app/main.py index 34b287b..a46df4b 100644 --- a/app/main.py +++ b/app/main.py @@ -105,7 +105,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.17", + version="0.7.18", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index 374fbff..586f9c0 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -144,6 +144,7 @@ + @@ -185,8 +186,15 @@ let currentActuatorId = null; let actuatorChoices = []; let contextOptions = []; let manualContextState = {options: [], selected: new Set()}; +let cachedActuators = null; +let cachedEntities = null; +let cachedDiscovery = null; const ACTUATOR_RESULT_LIMIT = 50; +function uniqueValues(values) { + return [...new Set(values.filter(Boolean))]; +} + async function api(path, options = {}) { const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const body = response.status === 204 ? null : await response.json().catch(() => ({})); @@ -251,14 +259,33 @@ function matchesSearch(entity, query) { function categoryForEntity(entity) { const cls = entity.device_class || ""; + const text = normalizedSearch([ + entity.entity_id, + entity.friendly_name, + entity.area_name, + entity.device_name, + ].filter(Boolean).join(" ")); + if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) { + return "PV / Akku / Einspeisung"; + } + if (entity.domain === "fan") return "Lüftung / Ventilatoren"; + if (entity.domain === "climate") return "Heizung / Klima"; + if (entity.domain === "weather") return "Wetter"; + if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen"; + if (entity.domain === "cover") return "Rollläden / Cover"; if (entity.domain === "light") return "Lichtzustände"; - if (entity.domain === "switch") return "Schalter / Helper"; + if (entity.domain === "switch") return "Schalter / Steckdosen"; + if (entity.domain.startsWith("input_")) return "Helper"; + if (entity.domain === "scene") return "Szenen"; + if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien"; if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz"; if (["illuminance"].includes(cls)) return "Helligkeit"; if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster"; + if (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose"; if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit"; if (["temperature"].includes(cls)) return "Temperatur"; if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie"; + if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal"; if (entity.domain === "binary_sensor") return "Binäre Sensoren"; if (entity.domain === "sensor") return "Weitere Messsensoren"; return "Weitere Zustände"; @@ -305,18 +332,36 @@ async function loadOverview() { status.innerHTML = `
${escapeHtml(error.message)}
`; chips.innerHTML = ""; } - await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); + await loadDashboardData(); + renderActuatorDiscovery(); + renderConfiguredActuators(); void loadActuatorSuggestions(); } +async function loadDashboardData() { + const [actuators, entities, discovery] = await Promise.all([ + api("v1/actuators"), + api("v1/entities"), + api("v1/actuators/discovery"), + ]); + cachedActuators = actuators; + cachedEntities = entities; + cachedDiscovery = discovery; +} + async function loadActuatorDiscovery() { + if (!cachedActuators || !cachedDiscovery) { + await loadDashboardData(); + } + renderActuatorDiscovery(); +} + +function renderActuatorDiscovery() { const options = document.getElementById("actuator-options"); const select = document.getElementById("actuator-select"); try { - const [available, configured] = await Promise.all([ - api("v1/actuators/discovery"), - api("v1/actuators"), - ]); + const available = cachedDiscovery || []; + const configured = cachedActuators || []; const configuredIds = new Set(configured.map(record => record.actuator_entity_id)); actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id)); options.innerHTML = actuatorChoices.slice(0, 120).map(entity => @@ -366,6 +411,7 @@ function actuatorGroupLabel(domain) { media_player: "TV / Medien", number: "Numerische Helper", remote: "Fernbedienungen", + scene: "Szenen", switch: "Schalter / Steckdosen", cover: "Rollläden / Cover", fan: "Lüftung / Ventilatoren", @@ -463,12 +509,17 @@ async function configureActuator() { } async function loadConfiguredActuators() { + if (!cachedActuators || !cachedEntities) { + await loadDashboardData(); + } + renderConfiguredActuators(); +} + +function renderConfiguredActuators() { const box = document.getElementById("configured-actuators"); try { - const [rows, entities] = await Promise.all([ - api("v1/actuators"), - api("v1/entities"), - ]); + const rows = cachedActuators || []; + const entities = cachedEntities || []; const entityMap = new Map(entities.map(entity => [entity.entity_id, entity])); const groups = new Map(); for (const record of rows) { @@ -532,7 +583,7 @@ async function showActuator(actuatorId, evaluationMessage = "") { ].filter(Boolean); const evidence = [...record.numeric_candidates, ...record.context_candidates] .filter(candidate => contexts.includes(candidate.entity_id)) - .map(candidate => `