Compare commits
7 Commits
fix/contex
...
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 77f328c4a8 | |||
| 7ad97320a2 | |||
| 58d3126a35 | |||
| 1c5eab14b6 | |||
| 87ae051238 | |||
| fb76d89204 | |||
| 1370d02c15 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.6.2 - 2026-06-14
|
||||||
|
- Eindeutig im Home-Assistant-Logbuch erkannte Automationen und Scripts zählen für
|
||||||
|
Lernen und Freigabe gleichwertig wie manuelle Bedienungen
|
||||||
|
- Automationsmuster erhalten dieselbe Modellgewichtung wie manuelle Handlungen
|
||||||
|
- Oberfläche zeigt die gemeinsame Zahl als `eindeutig geregelt`; eine
|
||||||
|
ausdrückliche Aktivierung pro Aktor bleibt weiterhin erforderlich
|
||||||
|
|
||||||
|
## 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
|
## 0.5.4 - 2026-06-14
|
||||||
- Tür-, Bewegungs- und andere belastbare Kontextsensoren werden auch ohne
|
- Tür-, Bewegungs- und andere belastbare Kontextsensoren werden auch ohne
|
||||||
numerischen Sensor als vollständige automatische Kontextzuordnung angezeigt
|
numerischen Sensor als vollständige automatische Kontextzuordnung angezeigt
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ Lernentscheidungen erfolgen automatisch.
|
|||||||
System das lokale Modell automatisch.
|
System das lokale Modell automatisch.
|
||||||
5. Vorhersagen laufen zunächst ausschließlich im Shadow-Modus.
|
5. Vorhersagen laufen zunächst ausschließlich im Shadow-Modus.
|
||||||
6. Erst nach ausdrücklicher Freigabe pro Aktor werden hochkonfidente,
|
6. Erst nach ausdrücklicher Freigabe pro Aktor werden hochkonfidente,
|
||||||
erlaubte Zustände geschaltet. Eigene Schaltungen und erkannte
|
erlaubte Zustände geschaltet. Eindeutig im HA-Logbuch erkannte Automationen
|
||||||
HA-Automationen werden nicht als Nutzerhandlungen zurückgelernt.
|
und Scripts zählen dabei gleichwertig wie manuelle Bedienungen. Eigene
|
||||||
|
Schaltungen von SillyHome werden nicht zurückgelernt.
|
||||||
|
|
||||||
Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
|
Vor einem Update sollte in Home Assistant unter **Einstellungen → System → Backups**
|
||||||
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte
|
eine Teil-Sicherung des Add-ons erstellt werden. Zur Wiederherstellung das gewünschte
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.5.4"
|
version: "0.6.2"
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -123,7 +124,9 @@ class BehaviorEngine:
|
|||||||
logbook=logbook,
|
logbook=logbook,
|
||||||
own_executions=record.behavior.execution_events,
|
own_executions=record.behavior.execution_events,
|
||||||
)
|
)
|
||||||
high_confidence = sum(1 for pattern in patterns if pattern.source == "user")
|
trusted_actions = sum(
|
||||||
|
1 for pattern in patterns if pattern.source in {"user", "automation"}
|
||||||
|
)
|
||||||
status = (
|
status = (
|
||||||
BehaviorStatus.TRAINED
|
BehaviorStatus.TRAINED
|
||||||
if len(patterns) >= self._settings.min_behavior_actions
|
if len(patterns) >= self._settings.min_behavior_actions
|
||||||
@@ -141,7 +144,7 @@ class BehaviorEngine:
|
|||||||
update={
|
update={
|
||||||
"status": status,
|
"status": status,
|
||||||
"sample_count": len(patterns),
|
"sample_count": len(patterns),
|
||||||
"high_confidence_sample_count": high_confidence,
|
"high_confidence_sample_count": trusted_actions,
|
||||||
"patterns": patterns[-_MAX_PATTERNS:],
|
"patterns": patterns[-_MAX_PATTERNS:],
|
||||||
"last_trained_at": now,
|
"last_trained_at": now,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
@@ -198,12 +201,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(
|
||||||
@@ -289,8 +298,8 @@ class BehaviorEngine:
|
|||||||
< self._settings.min_behavior_actions
|
< self._settings.min_behavior_actions
|
||||||
):
|
):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Für die Freigabe fehlen noch eindeutig dir zugeordnete Handlungen. "
|
"Für die Freigabe fehlen noch eindeutig zugeordnete manuelle "
|
||||||
"Bediene den Aktor einige Male über Home Assistant."
|
"oder automatisierte Handlungen."
|
||||||
)
|
)
|
||||||
mode = BehaviorMode.ACTIVE
|
mode = BehaviorMode.ACTIVE
|
||||||
approved_at = now
|
approved_at = now
|
||||||
@@ -326,8 +335,11 @@ 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(
|
||||||
continue
|
context_history,
|
||||||
|
context_ids,
|
||||||
|
point.timestamp,
|
||||||
|
)
|
||||||
contexts = {
|
contexts = {
|
||||||
entity_id: state
|
entity_id: state
|
||||||
for entity_id in context_ids
|
for entity_id in context_ids
|
||||||
@@ -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."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -461,7 +520,7 @@ def _action_source(
|
|||||||
if nearest.context_user_id:
|
if nearest.context_user_id:
|
||||||
return "user", 1.0
|
return "user", 1.0
|
||||||
if nearest.context_domain in _AUTOMATION_CONTEXT_DOMAINS:
|
if nearest.context_domain in _AUTOMATION_CONTEXT_DOMAINS:
|
||||||
return "automation", 0.1
|
return "automation", 1.0
|
||||||
return "physical_or_unknown", 0.7
|
return "physical_or_unknown", 0.7
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -225,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 {
|
||||||
@@ -239,17 +239,20 @@ 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 requiredTrustedActions = 3;
|
||||||
const missingUserActions = Math.max(
|
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||||
|
pattern => pattern.source === "automation",
|
||||||
|
).length;
|
||||||
|
const missingTrustedActions = Math.max(
|
||||||
0,
|
0,
|
||||||
requiredUserActions - record.behavior.high_confidence_sample_count,
|
requiredTrustedActions - 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" && missingUserActions === 0
|
: record.behavior.status === "trained" && missingTrustedActions === 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>`
|
||||||
: record.behavior.status === "trained"
|
: 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 noch gesperrt: ${missingTrustedActions} eindeutig zugeordnete manuelle oder automatisierte Handlung${missingTrustedActions === 1 ? "" : "en"} fehlen.</p>`
|
||||||
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</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">
|
||||||
@@ -265,11 +268,14 @@ async function showActuator(actuatorId) {
|
|||||||
<h3>Lernfortschritt</h3>
|
<h3>Lernfortschritt</h3>
|
||||||
<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 geregelt:</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>
|
||||||
@@ -286,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ Für jeden Aktor lädt SillyHome Next:
|
|||||||
- automatisch zugeordnete Mess- und Kontext-Entities
|
- automatisch zugeordnete Mess- und Kontext-Entities
|
||||||
- deren Zustand zum Zeitpunkt der Handlung
|
- deren Zustand zum Zeitpunkt der Handlung
|
||||||
|
|
||||||
Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen erhalten das
|
Eindeutig einem Home-Assistant-Benutzer zugeordnete Handlungen und im Logbuch
|
||||||
höchste Gewicht. Erkannte Automations- und Script-Aktionen werden verworfen.
|
erkannte Automations- oder Script-Aktionen erhalten das höchste Gewicht.
|
||||||
Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das
|
Physische oder nicht eindeutig zuordenbare Bedienungen dürfen das Shadow-Modell
|
||||||
Shadow-Modell ergänzen, reichen allein aber nicht zur Aktivierung.
|
ergänzen, reichen allein aber nicht zur Aktivierung.
|
||||||
|
|
||||||
## Modell
|
## Modell
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ Kontext. Mehrere passende historische Handlungen erhöhen die Confidence.
|
|||||||
2. `shadow`: Modell ist trainiert; Vorhersagen werden angezeigt, aber nicht ausgeführt.
|
2. `shadow`: Modell ist trainiert; Vorhersagen werden angezeigt, aber nicht ausgeführt.
|
||||||
3. `active`: Nutzer hat den Aktor ausdrücklich freigegeben.
|
3. `active`: Nutzer hat den Aktor ausdrücklich freigegeben.
|
||||||
|
|
||||||
Die Aktivierung verlangt genügend eindeutig einem Benutzer zugeordnete
|
Die Aktivierung verlangt genügend eindeutig zugeordnete manuelle oder
|
||||||
Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
|
automatisierte Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
|
||||||
`light`, `switch`, `fan`, `humidifier` und `cover`.
|
`light`, `switch`, `fan`, `humidifier` und `cover`.
|
||||||
|
|
||||||
## Schutzmechanismen
|
## Schutzmechanismen
|
||||||
@@ -48,4 +48,4 @@ Handlungen. Ausgeführt werden nur erlaubte Zustände reversibler Domains:
|
|||||||
- keine Ausführung bei bereits erreichtem Zielzustand
|
- keine Ausführung bei bereits erreichtem Zielzustand
|
||||||
- keine Ausführung unbekannter Zustände oder riskanter Domains
|
- keine Ausführung unbekannter Zustände oder riskanter Domains
|
||||||
- eigene Schaltungen werden beim nächsten Training herausgefiltert
|
- eigene Schaltungen werden beim nächsten Training herausgefiltert
|
||||||
- bekannte Automation-/Script-Aktionen werden nicht als Nutzerverhalten gelernt
|
- Automation-/Script-Aktionen zählen nur bei eindeutiger Herkunft im HA-Logbuch
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -161,8 +161,8 @@ def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval(
|
|||||||
shadow = engine.evaluate("light.office")
|
shadow = engine.evaluate("light.office")
|
||||||
|
|
||||||
assert trained.behavior.status is BehaviorStatus.TRAINED
|
assert trained.behavior.status is BehaviorStatus.TRAINED
|
||||||
assert trained.behavior.sample_count == 3
|
assert trained.behavior.sample_count == 6
|
||||||
assert trained.behavior.high_confidence_sample_count == 3
|
assert trained.behavior.high_confidence_sample_count == 6
|
||||||
assert shadow.behavior.mode is BehaviorMode.SHADOW
|
assert shadow.behavior.mode is BehaviorMode.SHADOW
|
||||||
assert shadow.behavior.prediction is not None
|
assert shadow.behavior.prediction is not None
|
||||||
assert shadow.behavior.prediction.target_state == "on"
|
assert shadow.behavior.prediction.target_state == "on"
|
||||||
@@ -179,14 +179,121 @@ def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
|
def test_engine_counts_known_automation_actions_like_manual_actions(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
engine, _ = _engine(tmp_path, now)
|
engine, _ = _engine(tmp_path, now)
|
||||||
|
|
||||||
trained = engine.train("light.office")
|
trained = engine.train("light.office")
|
||||||
|
|
||||||
assert {pattern.target_state for pattern in trained.behavior.patterns} == {"on"}
|
assert {pattern.target_state for pattern in trained.behavior.patterns} == {
|
||||||
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
|
"on",
|
||||||
|
"off",
|
||||||
|
}
|
||||||
|
assert {pattern.source for pattern in trained.behavior.patterns} == {
|
||||||
|
"user",
|
||||||
|
"automation",
|
||||||
|
}
|
||||||
|
assert trained.behavior.high_confidence_sample_count == 6
|
||||||
|
assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_learns_causal_automation_with_activation_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 == 3
|
||||||
|
assert {pattern.weight for pattern in automation_patterns} == {1.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")}
|
||||||
|
|
||||||
|
active = engine.set_active("light.storage", active=True)
|
||||||
|
|
||||||
|
assert active.behavior.mode is BehaviorMode.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
||||||
@@ -209,7 +316,7 @@ def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
|||||||
engine.set_active("lock.front_door", active=True)
|
engine.set_active("lock.front_door", active=True)
|
||||||
|
|
||||||
|
|
||||||
def test_active_mode_requires_user_attributed_actions(tmp_path: Path) -> None:
|
def test_active_mode_requires_trusted_manual_or_automation_actions(tmp_path: Path) -> None:
|
||||||
settings = _settings(tmp_path)
|
settings = _settings(tmp_path)
|
||||||
store = ActuatorStore(settings.actuator_store)
|
store = ActuatorStore(settings.actuator_store)
|
||||||
record = store.configure("light.office")
|
record = store.configure("light.office")
|
||||||
@@ -229,7 +336,7 @@ def test_active_mode_requires_user_attributed_actions(tmp_path: Path) -> None:
|
|||||||
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
|
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
|
||||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="eindeutig dir zugeordnete"):
|
with pytest.raises(ValueError, match="manuelle oder automatisierte"):
|
||||||
engine.set_active("light.office", active=True)
|
engine.set_active("light.office", active=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -259,3 +366,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,15 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
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 "Freigabe noch gesperrt" in response.text
|
||||||
assert "Bediene das Licht dafür direkt über Home Assistant" in response.text
|
assert "manuelle oder automatisierte Handlung" in response.text
|
||||||
assert 'record.behavior.status === "trained" && missingUserActions === 0' 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" && missingTrustedActions === 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
|
||||||
|
|||||||
Reference in New Issue
Block a user