Compare commits

...

8 Commits

13 changed files with 344 additions and 12 deletions

View File

@@ -1,5 +1,28 @@
# Changelog # Changelog
## 0.6.1 - 2026-06-14
- Manuelle Prüfung als `Aktuelle Situation auswerten` eindeutig von Simulation
oder Aktorschaltung abgegrenzt
- Sichtbare Rückmeldung mit Prüfzeitpunkt, vorhergesagtem Zustand und Sicherheit
oder klarem Hinweis auf einen fehlenden frischen Sensorwechsel
## 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
- Status und Zuordnungssicherheit bilden das aktive Verhaltenslernen ab statt
eines optionalen numerischen Modells
- Ausführungsfreigabe erscheint erst, wenn genügend eindeutig manuelle
Bedienungen vorliegen; bis dahin nennt die Oberfläche die noch fehlende Anzahl
## 0.5.3 - 2026-06-14 ## 0.5.3 - 2026-06-14
- Verhindert fachlich falsche Sensorzuordnungen nur aufgrund generischer Namen wie - Verhindert fachlich falsche Sensorzuordnungen nur aufgrund generischer Namen wie
`Licht` oder `Lichtschalter` `Licht` oder `Lichtschalter`

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.5.3" version: "0.6.1"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -239,12 +239,25 @@ class ActuatorReconciliationService:
(candidate for candidate in numeric_candidates if candidate.auto_accepted), (candidate for candidate in numeric_candidates if candidate.auto_accepted),
None, None,
) )
top_contexts = [ accepted_contexts = [
candidate.entity_id candidate
for candidate in context_candidates for candidate in context_candidates
if candidate.auto_accepted if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS] ][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if top_numeric is None: if top_numeric is None:
if accepted_contexts:
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Passender Schaltkontext automatisch erkannt. Für diese "
"Verhaltensvorhersage ist kein numerischer Sensor erforderlich."
),
)
return AssignmentSelection( return AssignmentSelection(
selected_numeric_entity_id=None, selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts, selected_context_entity_ids=top_contexts,

View File

@@ -92,6 +92,9 @@ class BehaviorPattern(BaseModel):
minute_of_day: int = Field(ge=0, le=1439) minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6) weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict) 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) source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0) weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime observed_at: datetime

View File

@@ -22,6 +22,7 @@ from app.ha.reader import HaReader
_MAX_PATTERNS = 500 _MAX_PATTERNS = 500
_MAX_EXECUTION_EVENTS = 100 _MAX_EXECUTION_EVENTS = 100
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10) _ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20) _OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"}) _SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"}) _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 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( prediction = predict_behavior(
record.behavior.patterns, record.behavior.patterns,
current_context=current_context, current_context=current_context,
current_context_changed_at=current_context_changed_at,
now=now, now=now,
min_support=self._settings.min_behavior_actions, min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes, window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
timezone_name=self._settings.timezone, timezone_name=self._settings.timezone,
) )
behavior = record.behavior.model_copy( behavior = record.behavior.model_copy(
@@ -326,7 +333,12 @@ class BehaviorEngine:
if _matches_own_execution(point, own_executions): if _matches_own_execution(point, own_executions):
continue continue
source, weight = _action_source(point, logbook) 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 continue
contexts = { contexts = {
entity_id: state entity_id: state
@@ -340,6 +352,9 @@ class BehaviorEngine:
minute_of_day=local.hour * 60 + local.minute, minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(), weekday=local.weekday(),
context_states=contexts, 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, source=source,
weight=weight, weight=weight,
observed_at=point.timestamp, observed_at=point.timestamp,
@@ -373,14 +388,52 @@ def predict_behavior(
now: datetime, now: datetime,
min_support: int, min_support: int,
window_minutes: int, window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None,
causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin", timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None: ) -> BehaviorPrediction | None:
if not patterns: if not patterns:
return None return None
local = now.astimezone(ZoneInfo(timezone_name)) local = now.astimezone(ZoneInfo(timezone_name))
minute_of_day = local.hour * 60 + local.minute minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {} by_state: dict[str, list[float]] = {}
causal_support_by_state: dict[str, int] = {}
for pattern in patterns: 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) distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
if distance > window_minutes: if distance > window_minutes:
continue continue
@@ -414,6 +467,7 @@ def predict_behavior(
key=lambda item: (sum(item[1]), len(item[1]), item[0]), key=lambda item: (sum(item[1]), len(item[1]), item[0]),
) )
support = len(scores) 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)) confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
if confidence <= 0: if confidence <= 0:
return None return None
@@ -423,7 +477,12 @@ def predict_behavior(
generated_at=now, generated_at=now,
matching_patterns=support, matching_patterns=support,
reason=( 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: def _circular_minute_distance(left: int, right: int) -> int:
direct = abs(left - right) direct = abs(left - right)
return min(direct, 1440 - direct) return min(direct, 1440 - direct)

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
@@ -15,6 +17,7 @@ class HaEntitySummary(BaseModel):
entity_id: str entity_id: str
domain: str domain: str
state: str | None = None state: str | None = None
last_changed: datetime | None = None
state_class: str | None = None state_class: str | None = None
device_class: str | None = None device_class: str | None = None
unit_of_measurement: str | None = None unit_of_measurement: str | None = None

View File

@@ -53,6 +53,7 @@ class HaReader:
entity_id=entity_id, entity_id=entity_id,
domain=domain, domain=domain,
state=_optional_str(item.get("state")), state=_optional_str(item.get("state")),
last_changed=_optional_datetime(item.get("last_changed")),
state_class=_optional_str(attributes.get("state_class")), state_class=_optional_str(attributes.get("state_class")),
device_class=_optional_str(attributes.get("device_class")), device_class=_optional_str(attributes.get("device_class")),
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")), 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 == "": if value is None or value == "":
return None return None
return str(value) 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

View File

@@ -112,6 +112,7 @@ async function api(path, options = {}) {
} }
function lifecycleLabel(record) { function lifecycleLabel(record) {
if (record.behavior.status === "trained") return "Kontext erkannt";
const labels = { const labels = {
trained: "lernt", trained: "lernt",
pending_history: "sammelt Historie", pending_history: "sammelt Historie",
@@ -124,6 +125,7 @@ function lifecycleLabel(record) {
} }
function statusClass(record) { function statusClass(record) {
if (record.behavior.status === "trained") return "ok";
if (record.lifecycle.status === "trained") return "ok"; if (record.lifecycle.status === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
return "bad"; return "bad";
@@ -223,7 +225,7 @@ async function loadConfiguredActuators() {
} }
} }
async function showActuator(actuatorId) { async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId; currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail"); const box = document.getElementById("actuator-detail");
try { try {
@@ -237,11 +239,21 @@ async function showActuator(actuatorId) {
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const prediction = record.behavior.prediction; 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,
);
const activationButton = record.behavior.mode === "active" const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
: record.behavior.status === "trained" : record.behavior.status === "trained" && missingUserActions === 0
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>` ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>"; : record.behavior.status === "trained"
? `<p class='muted'>Freigabe noch gesperrt: ${missingUserActions} eindeutig manuelle Bedienung${missingUserActions === 1 ? "" : "en"} fehlen. Bediene das Licht dafür direkt über Home Assistant.</p>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
box.innerHTML = ` box.innerHTML = `
<div class="grid-two"> <div class="grid-two">
<div> <div>
@@ -257,10 +269,13 @@ async function showActuator(actuatorId) {
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p> <p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</p>
<p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p> <p><strong>Gelernte Handlungen:</strong> ${record.behavior.sample_count}</p>
<p><strong>Davon eindeutig Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p> <p><strong>Davon eindeutig Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p>
<p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p>
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p> <p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
<p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p> <p><strong>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
${activationButton} ${activationButton}
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Vorhersage jetzt prüfen</button> <button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Aktuelle Situation auswerten</button>
<p class="muted">Die Prüfung simuliert keinen Sensorwechsel und schaltet keinen Aktor.</p>
${evaluationMessage ? `<p class="ok">${escapeHtml(evaluationMessage)}</p>` : ""}
</div> </div>
</div> </div>
<h3>Was SillyHome aktuell vorhersagt</h3> <h3>Was SillyHome aktuell vorhersagt</h3>
@@ -277,9 +292,18 @@ async function showActuator(actuatorId) {
async function evaluateActuator(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`, {method: "POST"}); const record = await api(
`v1/actuators/${encodeURIComponent(actuatorId)}/evaluate`,
{method: "POST"},
);
const checkedAt = new Date(
record.behavior.last_evaluated_at || Date.now(),
).toLocaleString("de-DE");
const message = record.behavior.prediction
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId, message);
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from app.actuators.lifecycle import ActuatorReconciliationService from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ( from app.actuators.models import (
AssignmentSource,
LifecycleStatus, LifecycleStatus,
ManualOverride, ManualOverride,
model_id_for_actuator, model_id_for_actuator,
@@ -233,6 +234,9 @@ def test_reconciliation_does_not_cross_assign_other_room_light_energy(
assert record.assignment.selected_context_entity_ids == [ assert record.assignment.selected_context_entity_ids == [
"binary_sensor.abstellraum_ture" "binary_sensor.abstellraum_ture"
] ]
assert record.assignment.source is AssignmentSource.AUTOMATIC
assert record.assignment.confidence == 1.0
assert record.assignment.review_required is False
assert record.lifecycle.status is LifecycleStatus.ARCHIVED assert record.lifecycle.status is LifecycleStatus.ARCHIVED

View File

@@ -76,6 +76,7 @@ def test_entities_returns_reader_data() -> None:
"entity_id": "sensor.temperature", "entity_id": "sensor.temperature",
"domain": "sensor", "domain": "sensor",
"state": None, "state": None,
"last_changed": None,
"state_class": None, "state_class": None,
"device_class": None, "device_class": None,
"unit_of_measurement": None, "unit_of_measurement": None,

View File

@@ -5,7 +5,7 @@ from pathlib import Path
import pytest 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.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
from app.config import Settings 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"} 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: def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
settings = _settings(tmp_path) settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store) store = ActuatorStore(settings.actuator_store)
@@ -259,3 +351,66 @@ def test_prediction_requires_temporal_support() -> None:
min_support=3, min_support=3,
window_minutes=30, window_minutes=30,
) is None ) 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

View File

@@ -15,6 +15,7 @@ class FakeHaClient(HaClient):
{ {
"entity_id": "sensor.temperature", "entity_id": "sensor.temperature",
"state": "21.5", "state": "21.5",
"last_changed": "2026-06-14T12:00:00+00:00",
"attributes": { "attributes": {
"state_class": "measurement", "state_class": "measurement",
"device_class": "temperature", "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") sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
assert sensor.unit_of_measurement == "°C" assert sensor.unit_of_measurement == "°C"
assert sensor.state == "21.5" 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.area_name == "Kueche"
assert sensor.device_name == "Thermometer" assert sensor.device_name == "Thermometer"

View File

@@ -14,5 +14,13 @@ def test_dashboard_is_served_at_root() -> None:
assert "Wie gewohnt bedienen" in response.text assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" 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 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 "Aktuelle Situation auswerten" in response.text
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text
assert 'record.behavior.status === "trained" && missingUserActions === 0' in response.text
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text