From 08e41b0198b6b07bce4fc8917ec81d82a22432d9 Mon Sep 17 00:00:00 2001 From: Otto Date: Sun, 26 Jul 2026 21:57:57 +0200 Subject: [PATCH] Improve learning discovery and dashboard i18n --- app/actuators/lifecycle.py | 157 +++++++++++++++++- app/actuators/models.py | 3 + app/behavior/engine.py | 139 ++++++++++++++-- app/ha/client.py | 1 - app/ha/history.py | 32 +++- app/static/index.html | 253 +++++++++++++++++++++++++++--- tests/actuators/test_lifecycle.py | 96 +++++++++++- tests/behavior/test_engine.py | 75 +++++++++ tests/ha/test_history.py | 22 +++ 9 files changed, 739 insertions(+), 39 deletions(-) diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py index 548a88d..0ecc947 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -46,6 +46,8 @@ _STOPWORDS = frozenset( "entity", "humidity", "illuminance", + "led", + "lidl", "light", "licht", "lichtschalter", @@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({ "presence", "window", }) +_PRESENCE_TOKENS = frozenset({ + "besetzt", + "occupied", + "occupancy", + "presence", + "prasenz", + "praesenz", + "motion", + "bewegung", + "bewegungsmelder", +}) +_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"}) +_CABINET_TOKENS = frozenset({"schrank", "cabinet"}) +_PV_TOKENS = frozenset({ + "pv", + "solar", + "photovoltaik", + "akku", + "batterie", + "battery", + "einspeisung", + "wechselrichter", + "inverter", + "netzbezug", + "grid", + "verbrauch", +}) class ActuatorReconciliationService: @@ -864,6 +893,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary return True if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)): return True + actuator_tokens = _metadata_tokens(actuator, include_stopwords=True) + entity_tokens = _metadata_tokens(entity, include_stopwords=True) + if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity): + return True + if actuator.domain in {"fan", "humidifier"} and ( + _is_presence_context(entity) or entity.device_class in {"humidity", "moisture"} + ): + return True + if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and ( + entity_tokens.intersection(_PV_TOKENS) + ): + return True entity_tokens = _metadata_tokens(entity, include_stopwords=True) return bool( entity_tokens.intersection(_OUTDOOR_TOKENS) @@ -878,6 +919,18 @@ def _eligible_for_auto_context( device_class = candidate.device_class or "" if device_class in _AUTO_CONTEXT_CLASSES: return True + if actuator.domain in {"fan", "humidifier"} and device_class in { + "humidity", + "moisture", + "temperature", + }: + return True + if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate): + return True + actuator_tokens = _metadata_tokens(actuator, include_stopwords=True) + candidate_tokens = _candidate_tokens(candidate, include_stopwords=True) + if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate): + return True if ( actuator.device_name and candidate.device_name @@ -899,6 +952,7 @@ def _score_candidate( score = 0.0 actuator_tokens = _metadata_tokens(actuator) entity_tokens = _metadata_tokens(entity) + full_entity_tokens = _metadata_tokens(entity, include_stopwords=True) overlap = sorted(actuator_tokens.intersection(entity_tokens)) if overlap: score += min(0.4, 0.1 * len(overlap)) @@ -924,6 +978,31 @@ def _score_candidate( if entity.device_class in preferred_device_classes: score += 0.2 evidence.append(f"Passende device_class: {entity.device_class}") + if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in { + "humidity", + "moisture", + }: + score += 0.3 + evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.") + if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in { + "humidity", + "moisture", + }: + score += 0.3 + evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.") + if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity): + score += 0.3 + evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.") + if context and _is_mailbox_reset_candidate( + _metadata_tokens(actuator, include_stopwords=True), + _metadata_tokens(entity, include_stopwords=True), + entity, + ): + score += 0.45 + evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.") + if full_entity_tokens.intersection(_PV_TOKENS): + score += 0.12 if context else 0.18 + evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.") if not context and actuator.domain == "light" and entity.device_class == "illuminance": score += 0.2 evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.") @@ -1068,11 +1147,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False for value in raw_values: if value is None: continue - for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")): - if len(token) < 3 or (not include_stopwords and token in _STOPWORDS): + for token in _TOKEN_PATTERN.findall(_normalize_text(value)): + if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS): continue tokens.add(token) - return tokens + return _expand_room_tokens(tokens) + + +def _candidate_tokens( + candidate: AssignmentCandidate, + *, + include_stopwords: bool = False, +) -> set[str]: + raw_values = [ + candidate.entity_id, + candidate.friendly_name, + candidate.area_name, + candidate.device_name, + ] + tokens: set[str] = set() + for value in raw_values: + if value is None: + continue + for token in _TOKEN_PATTERN.findall(_normalize_text(value)): + if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS): + continue + tokens.add(token) + return _expand_room_tokens(tokens) + + +def _expand_room_tokens(tokens: set[str]) -> set[str]: + expanded = set(tokens) + if "gaste" in expanded: + expanded.add("gaeste") + if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded): + expanded.add("gaestewc") + if {"gaeste", "zimmer"}.issubset(expanded): + expanded.add("gaestezimmer") + return expanded + + +def _normalize_text(value: str) -> str: + return ( + value.lower() + .replace("_", " ") + .replace("ä", "ae") + .replace("ö", "oe") + .replace("ü", "ue") + .replace("ß", "ss") + ) + + +def _is_presence_context(entity: HaEntitySummary) -> bool: + if entity.device_class in {"motion", "occupancy", "presence"}: + return True + return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS)) + + +def _is_presence_candidate(candidate: AssignmentCandidate) -> bool: + if candidate.device_class in {"motion", "occupancy", "presence"}: + return True + return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS)) + + +def _is_mailbox_reset_candidate( + actuator_tokens: set[str], + context_tokens: set[str], + entity: HaEntitySummary | AssignmentCandidate, +) -> bool: + if not actuator_tokens.intersection(_MAILBOX_TOKENS): + return False + if not context_tokens.intersection(_CABINET_TOKENS): + return False + return entity.domain == "binary_sensor" and entity.device_class in { + "door", + "garage_door", + "opening", + } def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str: diff --git a/app/actuators/models.py b/app/actuators/models.py index 3bad3cd..3d27264 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -123,12 +123,14 @@ class ModelLifecycleState(BaseModel): class BehaviorPattern(BaseModel): target_state: str = Field(min_length=1, max_length=100) + target_attributes: dict[str, object] = Field(default_factory=dict) minute_of_day: int = Field(ge=0, le=1439) weekday: int = Field(ge=0, le=6) context_states: dict[str, str] = Field(default_factory=dict) trigger_entity_id: str | None = None trigger_from_state: str | None = None trigger_to_state: str | None = None + trigger_delay_seconds: int | None = Field(default=None, ge=0) source: str = Field(default="observed", max_length=40) weight: float = Field(default=1.0, ge=0.1, le=1.0) observed_at: datetime @@ -136,6 +138,7 @@ class BehaviorPattern(BaseModel): class BehaviorPrediction(BaseModel): target_state: str + target_attributes: dict[str, object] = Field(default_factory=dict) confidence: float = Field(ge=0.0, le=1.0) generated_at: datetime reason: str diff --git a/app/behavior/engine.py b/app/behavior/engine.py index cd7ebd4..5cd7a50 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -48,10 +48,27 @@ _MAX_DECISION_TRACES = 30 _MAX_LATENCY_MEASUREMENTS = 50 _MAX_FEEDBACK_LOG = 50 _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) -_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3) +_CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4) _OWN_ACTION_TOLERANCE = timedelta(seconds=20) -_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"}) +_SAFE_ACTIVE_DOMAINS = frozenset({ + "button", + "cover", + "fan", + "humidifier", + "input_button", + "light", + "switch", +}) _AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"}) +_LIGHT_TARGET_ATTRIBUTES = frozenset({ + "brightness", + "color_temp", + "color_temp_kelvin", + "effect", + "hs_color", + "rgb_color", + "xy_color", +}) logger = logging.getLogger(__name__) @@ -339,7 +356,7 @@ class BehaviorEngine: now=now, min_support=self._settings.min_behavior_actions, window_minutes=self._settings.prediction_window_minutes, - causal_window_seconds=self._settings.prediction_interval_seconds * 2, + causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240), timezone_name=self._settings.timezone, ) if prediction is not None: @@ -439,7 +456,11 @@ class BehaviorEngine: self._ha_reader.call_service( domain, service, - {"entity_id": actuator_entity_id}, + _service_data_for_prediction( + actuator_entity_id, + domain, + prediction, + ), ) decision_to_service_ms = _elapsed_ms(service_started_perf) except (HaClientError, ValueError) as exc: @@ -1107,7 +1128,7 @@ class BehaviorEngine: blockers.append( f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}." ) - if current_state == prediction.target_state: + if _target_reached(record.actuator_entity_id, current_state, prediction): blockers.append("Zielzustand ist bereits erreicht.") if not self._cooldown_elapsed( record.behavior, @@ -1150,12 +1171,16 @@ class BehaviorEngine: patterns.append( BehaviorPattern( target_state=point.state, + target_attributes=_target_attributes_for(point), minute_of_day=local.hour * 60 + local.minute, weekday=local.weekday(), context_states=contexts, - trigger_entity_id=trigger[0] if trigger else None, - trigger_from_state=trigger[1] if trigger else None, - trigger_to_state=trigger[2] if trigger else None, + trigger_entity_id=trigger[1] if trigger else None, + trigger_from_state=trigger[2] if trigger else None, + trigger_to_state=trigger[3] if trigger else None, + trigger_delay_seconds=( + int(trigger[0].total_seconds()) if trigger else None + ), source=source, weight=weight, observed_at=point.timestamp, @@ -1941,6 +1966,7 @@ def predict_behavior( minute_of_day = local.hour * 60 + local.minute changed_at = current_context_changed_at or {} by_state: dict[str, list[float]] = {} + attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {} causal_support_by_state: dict[str, int] = {} for pattern in patterns: if pattern.trigger_entity_id and pattern.trigger_to_state: @@ -1954,7 +1980,11 @@ def predict_behavior( current_context.get(pattern.trigger_entity_id) == pattern.trigger_to_state and trigger_age is not None - and 0 <= trigger_age <= causal_window_seconds + and _trigger_age_matches( + trigger_age, + pattern.trigger_delay_seconds, + causal_window_seconds, + ) ): continue comparable = [ @@ -1969,6 +1999,9 @@ def predict_behavior( ) score = pattern.weight * (0.85 + 0.15 * context_score) by_state.setdefault(pattern.target_state, []).append(score) + attributes_by_state.setdefault(pattern.target_state, []).append( + (score, pattern.target_attributes) + ) causal_support_by_state[pattern.target_state] = ( causal_support_by_state.get(pattern.target_state, 0) + 1 ) @@ -1998,6 +2031,9 @@ def predict_behavior( 0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score ) by_state.setdefault(pattern.target_state, []).append(score) + attributes_by_state.setdefault(pattern.target_state, []).append( + (score, pattern.target_attributes) + ) if not by_state: return None target_state, scores = max( @@ -2011,6 +2047,9 @@ def predict_behavior( return None return BehaviorPrediction( target_state=target_state, + target_attributes=_aggregate_target_attributes( + attributes_by_state.get(target_state, []) + ), confidence=round(confidence, 4), generated_at=now, matching_patterns=support, @@ -2044,9 +2083,87 @@ def _weighted_context_score( return matched_weight / total_weight +def _trigger_age_matches( + trigger_age_seconds: float, + expected_delay_seconds: int | None, + causal_window_seconds: int, +) -> bool: + if trigger_age_seconds < 0: + return False + if expected_delay_seconds is None or expected_delay_seconds <= 10: + return trigger_age_seconds <= causal_window_seconds + tolerance = max(30, min(90, causal_window_seconds // 2)) + return abs(trigger_age_seconds - expected_delay_seconds) <= tolerance + + +def _aggregate_target_attributes( + weighted_attributes: list[tuple[float, dict[str, object]]], +) -> dict[str, object]: + if not weighted_attributes: + return {} + result: dict[str, object] = {} + numeric_values: dict[str, list[tuple[float, float]]] = {} + categorical_values: dict[str, dict[str, float]] = {} + for score, attributes in weighted_attributes: + for key, value in attributes.items(): + if key not in _LIGHT_TARGET_ATTRIBUTES: + continue + if isinstance(value, bool) or value is None: + continue + if isinstance(value, (int, float)): + numeric_values.setdefault(key, []).append((score, float(value))) + else: + categorical_values.setdefault(key, {}).setdefault(str(value), 0.0) + categorical_values[key][str(value)] += score + for key, values in numeric_values.items(): + total_weight = sum(score for score, _ in values) + if total_weight <= 0: + continue + result[key] = round(sum(score * value for score, value in values) / total_weight) + for key, values in categorical_values.items(): + if key in result: + continue + result[key] = max(values.items(), key=lambda item: (item[1], item[0]))[0] + return result + + +def _target_attributes_for(point: StateHistoryPoint) -> dict[str, object]: + if point.state != "on": + return {} + return { + key: value + for key, value in point.attributes.items() + if key in _LIGHT_TARGET_ATTRIBUTES and value is not None + } + + +def _service_data_for_prediction( + actuator_entity_id: str, + domain: str, + prediction: BehaviorPrediction, +) -> dict[str, object]: + data: dict[str, object] = {"entity_id": actuator_entity_id} + if domain == "light" and prediction.target_state == "on": + data.update(prediction.target_attributes) + return data + + +def _target_reached( + actuator_entity_id: str, + current_state: str, + prediction: BehaviorPrediction, +) -> bool: + domain = actuator_entity_id.split(".", 1)[0] + if domain == "light" and prediction.target_state == "on" and prediction.target_attributes: + return False + return current_state == prediction.target_state + + 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 in {"button", "input_button"}: + return "press" if domain == "scene": return "turn_on" if target_state == "on" else None if domain == "cover": @@ -2112,7 +2229,7 @@ def _recent_context_transition( history: dict[str, StateHistorySeries], context_ids: list[str], timestamp: datetime, -) -> tuple[str, str, str] | None: +) -> tuple[timedelta, str, str, str] | None: nearest: tuple[timedelta, str, str, str] | None = None for entity_id in context_ids: series = history.get(entity_id) @@ -2131,7 +2248,7 @@ def _recent_context_transition( previous_state = point.state if nearest is None: return None - return nearest[1], nearest[2], nearest[3] + return nearest def _circular_minute_distance(left: int, right: int) -> int: diff --git a/app/ha/client.py b/app/ha/client.py index d5a5447..9861dfd 100644 --- a/app/ha/client.py +++ b/app/ha/client.py @@ -78,7 +78,6 @@ class HaClient: "filter_entity_id": ",".join(entity_ids), "end_time": end_time.isoformat(), "minimal_response": "1", - "no_attributes": "1", }, ) if not isinstance(payload, list): diff --git a/app/ha/history.py b/app/ha/history.py index a8fd9f4..7056191 100644 --- a/app/ha/history.py +++ b/app/ha/history.py @@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel): class StateHistoryPoint(BaseModel): timestamp: datetime state: str + attributes: dict[str, object] = {} class StateHistorySeries(BaseModel): @@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries] timestamp = _parse_timestamp( raw_entry.get("last_changed") or raw_entry.get("last_updated") ) - if not points or points[-1].state != raw_state: - points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state)) + attributes = raw_entry.get("attributes") + if not isinstance(attributes, dict): + attributes = {} + if ( + not points + or points[-1].state != raw_state + or _relevant_state_attributes(points[-1].attributes) + != _relevant_state_attributes(attributes) + ): + points.append( + StateHistoryPoint( + timestamp=timestamp, + state=raw_state, + attributes=_relevant_state_attributes(attributes), + ) + ) if entity_id is not None and points: points.sort(key=lambda point: point.timestamp) normalized.append(StateHistorySeries(entity_id=entity_id, points=points)) @@ -176,3 +191,16 @@ def _optional_string(value: object) -> str | None: if value is None or value == "": return None return str(value) + + +def _relevant_state_attributes(attributes: dict[str, object]) -> dict[str, object]: + keys = { + "brightness", + "color_temp", + "color_temp_kelvin", + "effect", + "hs_color", + "rgb_color", + "xy_color", + } + return {key: attributes[key] for key in keys if key in attributes} diff --git a/app/static/index.html b/app/static/index.html index a740aa2..b7b65db 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -288,6 +288,69 @@ const STATUS_TIMEOUT_MS = 2000; const DASHBOARD_TIMEOUT_MS = 3000; const I18N = { de: { + ui: { + tagline: "Geräte, Lernen, Freigaben und Systemzustand.", + menu: "Menü", + nav_status: "Startseite / System", + nav_learning: "Lernen", + nav_discovery: "Discovery & Einrichtung", + nav_settings: "Einstellungen", + page_ready: "Seite bereit, Status folgt ...", + discovery_title: "Discovery & Einrichtung", + entity_id: "Entity-ID", + actuator_placeholder: "z. B. light.licht_abstellraum", + type: "Typ", + all_actuators: "Alle steuerbaren Typen", + lights: "Lichter", + switches: "Schalter / Helper", + buttons: "Buttons", + helper_buttons: "Helper-Buttons", + helper_switches: "Helper-Schalter", + covers: "Rollläden / Cover", + climate: "Heizungen / Klima", + locks: "Schlösser", + fans: "Lüftung / Ventilatoren", + humidifiers: "Befeuchter / Entfeuchter", + media: "TV / Medien", + remotes: "Fernbedienungen", + scenes: "Szenen", + numbers: "Numerische Helper", + valves: "Ventile", + search_list: "Liste durchsuchen", + search_placeholder: "Raum, Gerät oder Entity", + device_list: "Geräteliste", + device_list_lazy: "Geräteliste bei Bedarf laden", + add_device: "Gerät hinzufügen und Beobachtung starten", + load_device_list: "Geräteliste laden", + ready: "Bereit.", + show_suggestions: "Vorschläge anzeigen", + load_suggestions: "Vorschläge laden", + observed_devices: "Beobachtete Geräte", + refresh: "Aktualisieren", + details: "Details", + back: "Zurück", + detail_empty: "Öffne bei einem beobachteten Gerät die Details.", + system_cache: "System & Cache", + check_status: "Status prüfen", + checking: "Prüfung läuft ...", + settings: "Einstellungen", + settings_hint: "Sprache und Standardwerte für die Bedienoberfläche.", + language: "Sprache", + no_prediction: "Keine fällige Aktion", + open: "offen", + no_area: "Ohne Bereich", + loading_start: "Startdaten laden ...", + loading_devices: "Beobachtete Geräte werden geladen ...", + delayed_start: "Startdaten verzögert", + unavailable_start: "Startdaten sind gerade nicht verfügbar.", + system_loading: "Systemübersicht lädt ...", + system_delayed: "Systemübersicht verzögert", + context_detected: "Kontext erkannt", + active_approved: "aktiv freigegeben", + shadow_prediction: "Prüfmodus mit Vorhersage", + learning_blocked: "Lernen blockiert", + collecting_actions: "sammelt Handlungen", + }, safety_stage: { observe: "Nur beobachten", suggest: "Vorschläge anzeigen", @@ -358,6 +421,69 @@ const I18N = { }, }, en: { + ui: { + tagline: "Devices, learning, approvals, and system health.", + menu: "Menu", + nav_status: "Home / System", + nav_learning: "Learning", + nav_discovery: "Discovery & setup", + nav_settings: "Settings", + page_ready: "Page ready, status pending ...", + discovery_title: "Discovery & setup", + entity_id: "Entity ID", + actuator_placeholder: "e.g. light.storage_room", + type: "Type", + all_actuators: "All controllable types", + lights: "Lights", + switches: "Switches / helpers", + buttons: "Buttons", + helper_buttons: "Helper buttons", + helper_switches: "Helper switches", + covers: "Shutters / covers", + climate: "Heating / climate", + locks: "Locks", + fans: "Ventilation / fans", + humidifiers: "Humidifiers / dehumidifiers", + media: "TV / media", + remotes: "Remotes", + scenes: "Scenes", + numbers: "Numeric helpers", + valves: "Valves", + search_list: "Search list", + search_placeholder: "Room, device, or entity", + device_list: "Device list", + device_list_lazy: "Load device list when needed", + add_device: "Add device and start observing", + load_device_list: "Load device list", + ready: "Ready.", + show_suggestions: "Show suggestions", + load_suggestions: "Load suggestions", + observed_devices: "Observed devices", + refresh: "Refresh", + details: "Details", + back: "Back", + detail_empty: "Open details from an observed device.", + system_cache: "System & cache", + check_status: "Check status", + checking: "Checking ...", + settings: "Settings", + settings_hint: "Language and UI defaults.", + language: "Language", + no_prediction: "No due action", + open: "open", + no_area: "No area", + loading_start: "Loading start data ...", + loading_devices: "Loading observed devices ...", + delayed_start: "Start data delayed", + unavailable_start: "Start data is currently unavailable.", + system_loading: "Loading system overview ...", + system_delayed: "System overview delayed", + context_detected: "Context detected", + active_approved: "actively approved", + shadow_prediction: "Review mode with prediction", + learning_blocked: "Learning blocked", + collecting_actions: "collecting actions", + }, safety_stage: { observe: "Observe only", suggest: "Show suggestions", @@ -468,11 +594,15 @@ function showView(viewId) { function setLanguage(language) { uiLang = I18N[language] ? language : "de"; localStorage.setItem("sillyhome.ui.language", uiLang); + document.documentElement.lang = uiLang; + applyStaticTranslations(); syncSettingsView(); + renderActuatorSelect(); if (cachedActuators) renderConfiguredActuators(); if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview); - if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) { - document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId); + if (currentActuatorId) { + cachedDetailHtml.delete(currentActuatorId); + void showActuator(currentActuatorId); } } @@ -482,16 +612,95 @@ function syncSettingsView() { } function translate(group, value, fallback = "") { - if (value == null || value === "") return fallback || "offen"; + if (value == null || value === "") return fallback || ui("open"); return I18N[uiLang]?.[group]?.[value] || fallback || String(value); } +function ui(key) { + return I18N[uiLang]?.ui?.[key] || I18N.de.ui[key] || key; +} + +function setText(selector, key) { + const element = document.querySelector(selector); + if (element) element.textContent = ui(key); +} + +function setPlaceholder(selector, key) { + const element = document.querySelector(selector); + if (element) element.placeholder = ui(key); +} + +function applyStaticTranslations() { + setText("header .brand p", "tagline"); + setText("label[for='section-jump']", "menu"); + const navOptions = document.querySelectorAll("#section-jump option"); + [ + "nav_status", + "nav_learning", + "nav_discovery", + "nav_settings", + ].forEach((key, index) => { + if (navOptions[index]) navOptions[index].textContent = ui(key); + }); + setText("#load-budget", "page_ready"); + setText("#choose h2", "discovery_title"); + setText("label[for='actuator-input']", "entity_id"); + setPlaceholder("#actuator-input", "actuator_placeholder"); + setText("label[for='actuator-domain-filter']", "type"); + const domainOptions = document.querySelectorAll("#actuator-domain-filter option"); + [ + "all_actuators", + "lights", + "switches", + "buttons", + "helper_buttons", + "helper_switches", + "covers", + "climate", + "locks", + "fans", + "humidifiers", + "media", + "remotes", + "scenes", + "numbers", + "valves", + ].forEach((key, index) => { + if (domainOptions[index]) domainOptions[index].textContent = ui(key); + }); + setText("label[for='actuator-search']", "search_list"); + setPlaceholder("#actuator-search", "search_placeholder"); + setText("label[for='actuator-select']", "device_list"); + const lazyOption = document.querySelector("#actuator-select option[value='']"); + if (lazyOption) lazyOption.textContent = ui("device_list_lazy"); + const chooseButtons = document.querySelectorAll("#choose > button"); + if (chooseButtons[0]) chooseButtons[0].textContent = ui("add_device"); + if (chooseButtons[1]) chooseButtons[1].textContent = ui("load_device_list"); + setText("#actuator-config-result", "ready"); + setText("#choose .manual-context summary", "show_suggestions"); + const suggestionButton = document.querySelector("#choose .manual-context button"); + if (suggestionButton) suggestionButton.textContent = ui("load_suggestions"); + setText("#observed h2", "observed_devices"); + const refreshButton = document.querySelector("#observed .panel-title button"); + if (refreshButton) refreshButton.textContent = ui("refresh"); + setText("#detail h2", "details"); + const backButton = document.querySelector("#detail .panel-title button"); + if (backButton) backButton.textContent = ui("back"); + setText("#actuator-detail", "detail_empty"); + setText("#status-section h2", "system_cache"); + const statusButton = document.querySelector("#status-section .panel-title button"); + if (statusButton) statusButton.textContent = ui("check_status"); + setText("#settings h2", "settings"); + setText("#settings .muted", "settings_hint"); + setText("label[for='language-select']", "language"); +} + function formatDateTime(value) { - if (!value) return "noch offen"; + if (!value) return ui("open"); const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? String(value) - : parsed.toLocaleString("de-DE"); + : parsed.toLocaleString(uiLang === "en" ? "en-US" : "de-DE"); } function uniqueValues(values) { @@ -532,7 +741,7 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) { function lifecycleLabel(record) { const behaviorStatus = record.behavior_status || record.behavior?.status; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status; - if (behaviorStatus === "trained") return "Kontext erkannt"; + if (behaviorStatus === "trained") return ui("context_detected"); const labels = { trained: translate("lifecycle_status", "trained"), pending_history: translate("lifecycle_status", "pending_history"), @@ -556,10 +765,10 @@ function statusClass(record) { function behaviorLabel(record) { const mode = record.behavior_mode || record.behavior?.mode; const status = record.behavior_status || record.behavior?.status; - if (mode === "active") return "aktiv freigegeben"; - if (status === "trained") return "Prüfmodus mit Vorhersage"; - if (status === "blocked") return "Lernen blockiert"; - return "sammelt Handlungen"; + if (mode === "active") return ui("active_approved"); + if (status === "trained") return ui("shadow_prediction"); + if (status === "blocked") return ui("learning_blocked"); + return ui("collecting_actions"); } function predictionLabel(record) { @@ -567,11 +776,11 @@ function predictionLabel(record) { const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence; return target ? `${target} (${Math.round(confidence * 100)} %)` - : "Keine fällige Aktion"; + : ui("no_prediction"); } function entityLabel(entity) { - const area = entity.area_name || "Ohne Bereich"; + const area = entity.area_name || ui("no_area"); const name = entity.friendly_name || entity.entity_id; return `${area} - ${name} (${entity.entity_id})`; } @@ -655,9 +864,9 @@ async function loadOverview() { async function doLoadOverview() { const startedAt = performance.now(); const budget = document.getElementById("load-budget"); - if (budget) budget.textContent = "Startdaten laden ..."; + if (budget) budget.textContent = ui("loading_start"); if (!cachedActuators) { - document.getElementById("configured-actuators").innerHTML = "

Beobachtete Geräte werden geladen ...

"; + document.getElementById("configured-actuators").innerHTML = `

${escapeHtml(ui("loading_devices"))}

`; } try { const dashboard = await api("v1/actuators/dashboard/start"); @@ -675,12 +884,12 @@ async function doLoadOverview() { } } catch (error) { document.getElementById("configured-actuators").innerHTML = `

${escapeHtml(error.message)}

`; - if (budget) budget.textContent = "Startdaten verzögert"; + if (budget) budget.textContent = ui("delayed_start"); try { await loadSummaryData(); renderConfiguredActuators(); } catch (_) { - document.getElementById("configured-actuators").innerHTML = "
Startdaten sind gerade nicht verfügbar.
"; + document.getElementById("configured-actuators").innerHTML = `
${escapeHtml(ui("unavailable_start"))}
`; } } scheduleDashboardExtras(); @@ -697,7 +906,7 @@ async function loadSystemOverview() { async function doLoadSystemOverview() { const startedAt = performance.now(); const budget = document.getElementById("load-budget"); - if (budget) budget.textContent = "Systemübersicht lädt ..."; + if (budget) budget.textContent = ui("system_loading"); try { const dashboard = await api("v1/actuators/dashboard/system"); dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt); @@ -713,7 +922,7 @@ async function doLoadSystemOverview() { scheduleDashboardExtras(); } catch (error) { document.getElementById("status").innerHTML = `

Systemübersicht verzögert: ${escapeHtml(error.message)}

`; - if (budget) budget.textContent = "Systemübersicht verzögert"; + if (budget) budget.textContent = ui("system_delayed"); } } @@ -1950,9 +2159,11 @@ async function removeActuator(actuatorId) { } async function startDashboard() { - document.getElementById("status").innerHTML = "

Status lädt nach ...

"; - document.getElementById("configured-actuators").innerHTML = "
Öffne „Lernen“, um Geräte zu laden.
"; - document.getElementById("actuator-detail").innerHTML = "
Wähle später ein Gerät aus der Übersicht.
"; + document.documentElement.lang = uiLang; + applyStaticTranslations(); + document.getElementById("status").innerHTML = `

${escapeHtml(uiLang === "en" ? "Status loading ..." : "Status lädt nach ...")}

`; + document.getElementById("configured-actuators").innerHTML = `
${escapeHtml(uiLang === "en" ? "Open Learning to load devices." : "Öffne „Lernen“, um Geräte zu laden.")}
`; + document.getElementById("actuator-detail").innerHTML = `
${escapeHtml(uiLang === "en" ? "Select a device from the overview later." : "Wähle später ein Gerät aus der Übersicht.")}
`; syncSettingsView(); const initialView = localStorage.getItem("sillyhome.ui.view") === "detail" ? "observed" diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py index 78fc5d4..da7af5f 100644 --- a/tests/actuators/test_lifecycle.py +++ b/tests/actuators/test_lifecycle.py @@ -87,7 +87,7 @@ def _service( def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None: - start = datetime(2026, 6, 1, tzinfo=timezone.utc) + start = datetime.now(timezone.utc) - timedelta(days=1) entities = [ HaEntitySummary( entity_id="light.abstellkammer", @@ -352,6 +352,100 @@ def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None: assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit" +def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None: + entities = [ + HaEntitySummary( + entity_id="light.lidl_kuche", + domain="light", + friendly_name="Lidl Küche", + ), + HaEntitySummary( + entity_id="light.lidl_wohnzimmer", + domain="light", + friendly_name="Lidl Wohnzimmer", + ), + HaEntitySummary( + entity_id="binary_sensor.pir_kuche_motion_detection", + domain="binary_sensor", + device_class="motion", + friendly_name="Bewegungsmelder", + device_name="PIR_Küche", + ), + HaEntitySummary( + entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any", + domain="binary_sensor", + device_class="motion", + friendly_name="Bewegungsmelder", + device_name="PIR_Wohnzimmer", + ), + ] + service = _service(tmp_path, entities, {}) + + record = service.configure_actuator("light.lidl_kuche") + + assert record.assignment.selected_context_entity_ids == [ + "binary_sensor.pir_kuche_motion_detection" + ] + + +def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None: + entities = [ + HaEntitySummary( + entity_id="button.smart_mailbox_als_geleert_markieren", + domain="button", + friendly_name="Smart Mailbox Als geleert markieren", + ), + HaEntitySummary( + entity_id="binary_sensor.schrank_strasse_open", + domain="binary_sensor", + device_class="door", + friendly_name="Schrank Straße", + ), + ] + service = _service(tmp_path, entities, {}) + + record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren") + + assert record.assignment.selected_context_entity_ids == [ + "binary_sensor.schrank_strasse_open" + ] + assert record.assignment.review_required is False + + +def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None: + start = datetime(2026, 6, 1, tzinfo=timezone.utc) + entities = [ + HaEntitySummary( + entity_id="humidifier.gastewc_luftung", + domain="humidifier", + friendly_name="GästeWC Lüftung", + ), + HaEntitySummary( + entity_id="sensor.pir_gastewc_humidity", + domain="sensor", + device_class="humidity", + state_class="measurement", + unit_of_measurement="%", + friendly_name="Gäste WC Luftfeuchtigkeit", + ), + HaEntitySummary( + entity_id="input_boolean.gaste_wc_occupied", + domain="input_boolean", + friendly_name="gaste_wc_occupied", + ), + ] + service = _service( + tmp_path, + entities, + {"sensor.pir_gastewc_humidity": _points(8, start, 55.0)}, + ) + + record = service.configure_actuator("humidifier.gastewc_luftung") + + assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity" + assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids + + 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 58bb7cd..63cae08 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None: ) is None +def test_prediction_respects_learned_context_delay() -> None: + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + patterns = [ + BehaviorPattern( + target_state="on", + minute_of_day=60, + weekday=0, + context_states={"input_boolean.gaste_wc_occupied": "on"}, + trigger_entity_id="input_boolean.gaste_wc_occupied", + trigger_from_state="off", + trigger_to_state="on", + trigger_delay_seconds=180, + source="automation", + weight=1.0, + observed_at=now - timedelta(days=days_ago), + ) + for days_ago in (3, 2, 1) + ] + + early = predict_behavior( + patterns, + current_context={"input_boolean.gaste_wc_occupied": "on"}, + current_context_changed_at={ + "input_boolean.gaste_wc_occupied": now - timedelta(seconds=30) + }, + now=now, + min_support=3, + window_minutes=30, + causal_window_seconds=240, + ) + due = predict_behavior( + patterns, + current_context={"input_boolean.gaste_wc_occupied": "on"}, + current_context_changed_at={ + "input_boolean.gaste_wc_occupied": now - timedelta(seconds=185) + }, + now=now, + min_support=3, + window_minutes=30, + causal_window_seconds=240, + ) + + assert early is None + assert due is not None + assert due.target_state == "on" + + +def test_light_prediction_carries_brightness_attributes() -> None: + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + patterns = [ + BehaviorPattern( + target_state="on", + target_attributes={"brightness": brightness}, + minute_of_day=now.astimezone().hour * 60 + now.astimezone().minute, + weekday=now.astimezone().weekday(), + context_states={"binary_sensor.pir_kuche_motion_detection": "on"}, + source="automation", + weight=1.0, + observed_at=now - timedelta(days=days_ago), + ) + for days_ago, brightness in zip((3, 2, 1), (80, 90, 100), strict=True) + ] + + prediction = predict_behavior( + patterns, + current_context={"binary_sensor.pir_kuche_motion_detection": "on"}, + now=now, + min_support=3, + window_minutes=30, + ) + + assert prediction is not None + assert prediction.target_attributes["brightness"] == 90 + + def test_state_change_uses_websocket_context_state_for_immediate_action( tmp_path: Path, ) -> None: diff --git a/tests/ha/test_history.py b/tests/ha/test_history.py index 9dbb26f..97ce769 100644 --- a/tests/ha/test_history.py +++ b/tests/ha/test_history.py @@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None: assert [point.state for point in result[0].points] == ["off", "on"] +def test_normalize_state_history_keeps_light_attribute_changes() -> None: + result = normalize_state_history_payload( + [ + [ + { + "entity_id": "light.office", + "state": "on", + "attributes": {"brightness": 80, "friendly_name": "Office"}, + "last_changed": "2026-06-01T08:00:00+00:00", + }, + { + "state": "on", + "attributes": {"brightness": 120, "friendly_name": "Office"}, + "last_changed": "2026-06-01T08:05:00+00:00", + }, + ] + ] + ) + + assert [point.attributes["brightness"] for point in result[0].points] == [80, 120] + + def test_normalize_logbook_preserves_action_origin() -> None: result = normalize_logbook_payload( [