diff --git a/CHANGELOG.md b/CHANGELOG.md index 786fba1..ebcbb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.6.0 - 2026-06-14 +- Kausales Shadow-Lernen erkennt frische Kontextwechsel unmittelbar vor einer + Aktorhandlung, etwa `Tür geschlossen → offen` vor `Licht aus → an` +- Historische Home-Assistant-Automationen dürfen Vorhersagen begründen, zählen + aber weiterhin niemals als eindeutige Benutzerhandlung oder Ausführungsfreigabe +- Aktuelle `last_changed`-Zeitpunkte verhindern Vorhersagen aus längst + unveränderten Sensorzuständen +- Oberfläche trennt gelernte Benutzerhandlungen und erkannte HA-Automationen + ## 0.5.4 - 2026-06-14 - Tür-, Bewegungs- und andere belastbare Kontextsensoren werden auch ohne numerischen Sensor als vollständige automatische Kontextzuordnung angezeigt diff --git a/addon/config.yaml b/addon/config.yaml index 5d1f8a0..87f9a6d 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.5.4" +version: "0.6.0" 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/models.py b/app/actuators/models.py index a926825..383dfb5 100644 --- a/app/actuators/models.py +++ b/app/actuators/models.py @@ -92,6 +92,9 @@ class BehaviorPattern(BaseModel): 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 source: str = Field(default="observed", max_length=40) weight: float = Field(default=1.0, ge=0.1, le=1.0) observed_at: datetime diff --git a/app/behavior/engine.py b/app/behavior/engine.py index 40145f1..793a200 100644 --- a/app/behavior/engine.py +++ b/app/behavior/engine.py @@ -22,6 +22,7 @@ from app.ha.reader import HaReader _MAX_PATTERNS = 500 _MAX_EXECUTION_EVENTS = 100 _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) +_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3) _OWN_ACTION_TOLERANCE = timedelta(seconds=20) _SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"}) _AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"}) @@ -198,12 +199,18 @@ class BehaviorEngine: ) if entity_id and entity_id in entities and entities[entity_id].state is not None } + current_context_changed_at = { + entity_id: entities[entity_id].last_changed + for entity_id in current_context + } prediction = predict_behavior( record.behavior.patterns, current_context=current_context, + current_context_changed_at=current_context_changed_at, 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, timezone_name=self._settings.timezone, ) behavior = record.behavior.model_copy( @@ -326,7 +333,12 @@ class BehaviorEngine: if _matches_own_execution(point, own_executions): continue source, weight = _action_source(point, logbook) - if source == "automation": + trigger = _recent_context_transition( + context_history, + context_ids, + point.timestamp, + ) + if source == "automation" and trigger is None: continue contexts = { entity_id: state @@ -340,6 +352,9 @@ class BehaviorEngine: 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, source=source, weight=weight, observed_at=point.timestamp, @@ -373,14 +388,52 @@ def predict_behavior( now: datetime, min_support: int, window_minutes: int, + current_context_changed_at: dict[str, datetime | None] | None = None, + causal_window_seconds: int = 120, timezone_name: str = "Europe/Berlin", ) -> BehaviorPrediction | None: if not patterns: return None local = now.astimezone(ZoneInfo(timezone_name)) minute_of_day = local.hour * 60 + local.minute + changed_at = current_context_changed_at or {} by_state: dict[str, list[float]] = {} + causal_support_by_state: dict[str, int] = {} for pattern in patterns: + if pattern.trigger_entity_id and pattern.trigger_to_state: + trigger_changed_at = changed_at.get(pattern.trigger_entity_id) + trigger_age = ( + (now - trigger_changed_at).total_seconds() + if trigger_changed_at is not None + else None + ) + if not ( + current_context.get(pattern.trigger_entity_id) + == pattern.trigger_to_state + and trigger_age is not None + and 0 <= trigger_age <= causal_window_seconds + ): + continue + comparable = [ + (entity_id, expected) + for entity_id, expected in pattern.context_states.items() + if entity_id in current_context + ] + context_score = ( + sum( + current_context[entity_id] == expected + for entity_id, expected in comparable + ) + / len(comparable) + if comparable + else 0.5 + ) + score = pattern.weight * (0.85 + 0.15 * context_score) + by_state.setdefault(pattern.target_state, []).append(score) + causal_support_by_state[pattern.target_state] = ( + causal_support_by_state.get(pattern.target_state, 0) + 1 + ) + continue distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day) if distance > window_minutes: continue @@ -414,6 +467,7 @@ def predict_behavior( key=lambda item: (sum(item[1]), len(item[1]), item[0]), ) support = len(scores) + causal_support = causal_support_by_state.get(target_state, 0) confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support)) if confidence <= 0: return None @@ -423,7 +477,12 @@ def predict_behavior( generated_at=now, matching_patterns=support, reason=( - f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext." + ( + f"{causal_support} historische Handlungen folgten demselben " + "frischen Sensorwechsel." + ) + if causal_support + else f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext." ), ) @@ -476,6 +535,32 @@ def _matches_own_execution( ) +def _recent_context_transition( + history: dict[str, StateHistorySeries], + context_ids: list[str], + timestamp: datetime, +) -> tuple[str, str, str] | None: + nearest: tuple[timedelta, str, str, str] | None = None + for entity_id in context_ids: + series = history.get(entity_id) + if series is None: + continue + previous_state: str | None = None + for point in series.points: + if point.timestamp > timestamp: + break + if previous_state is not None and point.state != previous_state: + age = timestamp - point.timestamp + if age <= _CONTEXT_TRIGGER_TOLERANCE and ( + nearest is None or age < nearest[0] + ): + nearest = (age, entity_id, previous_state, point.state) + previous_state = point.state + if nearest is None: + return None + return nearest[1], nearest[2], nearest[3] + + def _circular_minute_distance(left: int, right: int) -> int: direct = abs(left - right) return min(direct, 1440 - direct) diff --git a/app/ha/models.py b/app/ha/models.py index 183082a..671aa19 100644 --- a/app/ha/models.py +++ b/app/ha/models.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from pydantic import BaseModel @@ -15,6 +17,7 @@ class HaEntitySummary(BaseModel): entity_id: str domain: str state: str | None = None + last_changed: datetime | None = None state_class: str | None = None device_class: str | None = None unit_of_measurement: str | None = None diff --git a/app/ha/reader.py b/app/ha/reader.py index 0d85334..3559eb0 100644 --- a/app/ha/reader.py +++ b/app/ha/reader.py @@ -53,6 +53,7 @@ class HaReader: entity_id=entity_id, domain=domain, state=_optional_str(item.get("state")), + last_changed=_optional_datetime(item.get("last_changed")), state_class=_optional_str(attributes.get("state_class")), device_class=_optional_str(attributes.get("device_class")), unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")), @@ -116,3 +117,13 @@ def _optional_str(value: object) -> str | None: if value is None or value == "": return None return str(value) + + +def _optional_datetime(value: object) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else None diff --git a/app/static/index.html b/app/static/index.html index 790039c..c396181 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -240,6 +240,9 @@ async function showActuator(actuatorId) { .join(""); const prediction = record.behavior.prediction; const requiredUserActions = 3; + const learnedAutomationActions = record.behavior.patterns.filter( + pattern => pattern.source === "automation", + ).length; const missingUserActions = Math.max( 0, requiredUserActions - record.behavior.high_confidence_sample_count, @@ -266,6 +269,7 @@ async function showActuator(actuatorId) {

Betriebsart: ${escapeHtml(behaviorLabel(record))}

Gelernte Handlungen: ${record.behavior.sample_count}

Davon eindeutig Benutzer: ${record.behavior.high_confidence_sample_count}

+

Davon erkannte HA-Automationen: ${learnedAutomationActions}

Letztes Training: ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}

Was noch passiert: ${escapeHtml(record.behavior.reason)}

${activationButton} diff --git a/tests/api/test_entities.py b/tests/api/test_entities.py index 8040d1f..6333c91 100644 --- a/tests/api/test_entities.py +++ b/tests/api/test_entities.py @@ -76,6 +76,7 @@ def test_entities_returns_reader_data() -> None: "entity_id": "sensor.temperature", "domain": "sensor", "state": None, + "last_changed": None, "state_class": None, "device_class": None, "unit_of_measurement": None, diff --git a/tests/behavior/test_engine.py b/tests/behavior/test_engine.py index 8f7b5de..5ae7b32 100644 --- a/tests/behavior/test_engine.py +++ b/tests/behavior/test_engine.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from app.actuators.models import BehaviorMode, BehaviorStatus +from app.actuators.models import BehaviorMode, BehaviorPattern, BehaviorStatus from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state from app.config import Settings @@ -189,6 +189,98 @@ def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None: assert {pattern.source for pattern in trained.behavior.patterns} == {"user"} +def test_engine_learns_causal_automation_for_shadow_without_user_credit( + tmp_path: Path, +) -> None: + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + actuator_points: list[StateHistoryPoint] = [] + door_points: list[StateHistoryPoint] = [] + logbook: list[LogbookEntry] = [] + for days_ago in (3, 2, 1): + action_at = now - timedelta(days=days_ago) + actuator_points.extend( + [ + StateHistoryPoint( + timestamp=action_at - timedelta(minutes=1), + state="off", + ), + StateHistoryPoint(timestamp=action_at, state="on"), + ] + ) + door_points.extend( + [ + StateHistoryPoint( + timestamp=action_at - timedelta(minutes=1), + state="off", + ), + StateHistoryPoint( + timestamp=action_at - timedelta(seconds=1), + state="on", + ), + ] + ) + logbook.append( + LogbookEntry( + entity_id="light.storage", + timestamp=action_at, + message="turned on", + context_domain="automation", + context_service="trigger", + ) + ) + actuator_points.sort(key=lambda point: point.timestamp) + door_points.sort(key=lambda point: point.timestamp) + settings = _settings(tmp_path) + store = ActuatorStore(settings.actuator_store) + record = store.configure("light.storage") + store.upsert( + record.model_copy( + update={ + "assignment": record.assignment.model_copy( + update={ + "selected_context_entity_ids": [ + "binary_sensor.storage_door" + ], + } + ) + } + ) + ) + reader = FakeBehaviorReader( + entities=[], + history=[ + StateHistorySeries( + entity_id="light.storage", + points=actuator_points, + ), + StateHistorySeries( + entity_id="binary_sensor.storage_door", + points=door_points, + ), + ], + logbook=logbook, + ) + engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings) + + trained = engine.train("light.storage") + automation_patterns = [ + pattern + for pattern in trained.behavior.patterns + if pattern.source == "automation" + ] + + assert len(automation_patterns) == 3 + assert trained.behavior.high_confidence_sample_count == 0 + assert { + ( + pattern.trigger_entity_id, + pattern.trigger_from_state, + pattern.trigger_to_state, + ) + for pattern in automation_patterns + } == {("binary_sensor.storage_door", "off", "on")} + + def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None: settings = _settings(tmp_path) store = ActuatorStore(settings.actuator_store) @@ -259,3 +351,66 @@ def test_prediction_requires_temporal_support() -> None: min_support=3, window_minutes=30, ) is None + + +def test_prediction_uses_fresh_causal_context_transition_outside_time_window() -> None: + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + patterns = [ + BehaviorPattern( + target_state="on", + minute_of_day=60, + weekday=0, + context_states={"binary_sensor.storage_door": "on"}, + trigger_entity_id="binary_sensor.storage_door", + trigger_from_state="off", + trigger_to_state="on", + source="automation", + weight=0.7, + observed_at=now - timedelta(days=days_ago), + ) + for days_ago in (3, 2, 1) + ] + + prediction = predict_behavior( + patterns, + current_context={"binary_sensor.storage_door": "on"}, + current_context_changed_at={ + "binary_sensor.storage_door": now - timedelta(seconds=10) + }, + now=now, + min_support=3, + window_minutes=30, + ) + + assert prediction is not None + assert prediction.target_state == "on" + assert prediction.matching_patterns == 3 + assert prediction.confidence == 0.7 + assert "frischen Sensorwechsel" in prediction.reason + + +def test_prediction_ignores_stale_causal_context_state() -> None: + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + pattern = BehaviorPattern( + target_state="on", + minute_of_day=60, + weekday=0, + context_states={"binary_sensor.storage_door": "on"}, + trigger_entity_id="binary_sensor.storage_door", + trigger_from_state="off", + trigger_to_state="on", + source="automation", + weight=0.7, + observed_at=now - timedelta(days=1), + ) + + assert predict_behavior( + [pattern], + current_context={"binary_sensor.storage_door": "on"}, + current_context_changed_at={ + "binary_sensor.storage_door": now - timedelta(minutes=5) + }, + now=now, + min_support=1, + window_minutes=30, + ) is None diff --git a/tests/ha/test_ha_reader.py b/tests/ha/test_ha_reader.py index 3a42b8f..752ac12 100644 --- a/tests/ha/test_ha_reader.py +++ b/tests/ha/test_ha_reader.py @@ -15,6 +15,7 @@ class FakeHaClient(HaClient): { "entity_id": "sensor.temperature", "state": "21.5", + "last_changed": "2026-06-14T12:00:00+00:00", "attributes": { "state_class": "measurement", "device_class": "temperature", @@ -87,6 +88,7 @@ def test_ha_reader_returns_summaries() -> None: sensor = next(item for item in summaries if item.entity_id == "sensor.temperature") assert sensor.unit_of_measurement == "°C" assert sensor.state == "21.5" + assert sensor.last_changed == datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc) assert sensor.area_name == "Kueche" assert sensor.device_name == "Thermometer" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2c93b94..6f8fad5 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -16,6 +16,7 @@ def test_dashboard_is_served_at_root() -> None: assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text assert "Freigabe noch gesperrt" in response.text assert "Bediene das Licht dafür direkt über Home Assistant" in response.text + assert "Davon erkannte HA-Automationen" in response.text assert 'record.behavior.status === "trained" && missingUserActions === 0' in response.text assert "Automation-Entwurf" not in response.text assert "Manuelle Overrides" not in response.text