Compare commits

..

15 Commits

Author SHA1 Message Date
77f328c4a8 Merge pull request 'v0.6.2: HA-Automationen gleichwertig lernen' (#39) from feature/automation-equality-v0.6.2 into main 2026-06-14 15:58:44 +02:00
7ad97320a2 BEHAVIOR-004: trust HA automation actions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:58:05 +02:00
58d3126a35 Merge pull request 'v0.6.1: sichtbare Rückmeldung bei Situationsprüfung' (#38) from fix/evaluation-feedback-v0.6.1 into main 2026-06-14 15:38:26 +02:00
1c5eab14b6 UI-003: show prediction evaluation feedback 2026-06-14 15:38:11 +02:00
87ae051238 Merge pull request 'v0.6.0: kausales Shadow-Lernen aus Sensorwechseln' (#37) from feature/causal-shadow-v0.6.0 into main 2026-06-14 15:35:22 +02:00
fb76d89204 BEHAVIOR-003: learn causal shadow triggers
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-14 15:35:07 +02:00
1370d02c15 Merge pull request 'v0.5.4: korrekter Kontext- und Freigabestatus' (#36) from fix/context-status-v0.5.4 into main 2026-06-14 15:29:07 +02:00
100f5af578 UI-002: align context and activation status 2026-06-14 15:28:52 +02:00
ede6b87dbd Merge pull request 'v0.5.3: sichere Sensorzuordnung für Aktoren' (#35) from fix/sensor-assignment-v0.5.3 into main 2026-06-14 15:19:55 +02:00
47e8c7e549 ASSIGN-001: reject unrelated actuator sensors 2026-06-14 15:19:30 +02:00
ef7e0c5600 Merge pull request 'v0.5.2: Add-on-Build liefert zuverlässig aktuellen Code' (#34) from fix/addon-cache-v0.5.2 into main 2026-06-14 11:38:51 +02:00
8d070fc9ca BUILD-001: invalidate addon application cache per release 2026-06-14 11:38:36 +02:00
ba15cc4d83 Merge pull request 'v0.5.1: verständliche Ingress-Führung und vereinfachte Add-on-Konfiguration' (#33) from fix/ingress-guidance-v0.5.1 into main 2026-06-14 11:03:20 +02:00
da51ac2063 UI-001: simplify addon setup and explain ingress workflow 2026-06-14 11:02:38 +02:00
ce568056fc Merge pull request 'v0.5.0: behavior learning, shadow prediction and safe activation' (#32) from feature/actuator-sensor-lifecycle into main
Merge pull request v0.5.0 behavior learning and safe activation (#32)
2026-06-14 10:41:16 +02:00
19 changed files with 570 additions and 88 deletions

View File

@@ -1,5 +1,55 @@
# 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
- 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
- Verhindert fachlich falsche Sensorzuordnungen nur aufgrund generischer Namen wie
`Licht` oder `Lichtschalter`
- Übernimmt numerische Sensoren nur noch bei einem belastbaren absoluten Score und
einer eindeutigen Abgrenzung zum zweitbesten Kandidaten
- Begrenzt Zusatzkontext auf relevante Sensoren und bevorzugt bei Lichtaktoren
echte Beleuchtungsstärke gegenüber fremden Leistungs- oder Energiezählern
## 0.5.2 - 2026-06-14
- Add-on-Build invalidiert den Docker-Cache bei jeder Versionsänderung, damit
Versionsmetadaten und tatsächlich ausgelieferter Anwendungscode übereinstimmen
- Korrigierte Ingress-Oberfläche aus 0.5.1 dadurch erstmals zuverlässig ausgeliefert
## 0.5.1 - 2026-06-14
- Technische Modell-, Intervall- und Sicherheitsparameter aus der normalen
Home-Assistant-Add-on-Konfiguration entfernt; sichere Standardwerte bleiben aktiv
- Ingress um einen klaren Ablauf mit Aktorauswahl, Beobachtungsphase und späterer
Ausführungsfreigabe ergänzt
- Bedienelemente und Diagnosen in verständlicher Alltagssprache erklärt
## 0.5.0 - 2026-06-14 ## 0.5.0 - 2026-06-14
- Ingress auf reine Aktorauswahl, automatischen Lernstatus und Vorhersagen reduziert - Ingress auf reine Aktorauswahl, automatischen Lernstatus und Vorhersagen reduziert
- Automatische Kontextzuordnung ohne Sensor-Overrides oder Review-Blockade - Automatische Kontextzuordnung ohne Sensor-Overrides oder Review-Blockade

View File

@@ -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

View File

@@ -4,13 +4,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
# The add-on version changes for every release. Copying its config before the
# clone makes Docker invalidate the application layer instead of reusing old code.
COPY config.yaml /tmp/addon-config.yaml
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git \ && apt-get install -y --no-install-recommends git \
&& git clone --depth 1 --branch main \ && git clone --depth 1 --branch main \
http://192.168.6.31:3000/pino/sillyhome-next.git /app \ http://192.168.6.31:3000/pino/sillyhome-next.git /app \
&& python -m pip install --upgrade pip \ && python -m pip install --upgrade pip \
&& python -m pip install /app \ && python -m pip install /app \
&& rm -rf /var/lib/apt/lists/* /app/.git && rm -rf /var/lib/apt/lists/* /app/.git /tmp/addon-config.yaml
COPY run.sh /run.sh COPY run.sh /run.sh
RUN chmod 0755 /run.sh RUN chmod 0755 /run.sh

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.5.0" 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
@@ -16,28 +16,6 @@ panel_admin: true
homeassistant_api: true homeassistant_api: true
hassio_api: false hassio_api: false
auth_api: false auth_api: false
options:
history_days: 14
min_training_points: 24
retrain_stale_hours: 24
reconcile_interval_seconds: 900
min_behavior_actions: 3
prediction_confidence: 0.82
prediction_window_minutes: 30
prediction_interval_seconds: 60
execution_cooldown_seconds: 900
timezone: Europe/Berlin
schema:
history_days: "int(1,31)"
min_training_points: "int(2,10000)"
retrain_stale_hours: "int(1,720)"
reconcile_interval_seconds: "int(60,86400)"
min_behavior_actions: "int(2,100)"
prediction_confidence: "float(0.5,0.99)"
prediction_window_minutes: "int(5,120)"
prediction_interval_seconds: "int(30,3600)"
execution_cooldown_seconds: "int(60,86400)"
timezone: "str"
map: map:
- type: addon_config - type: addon_config
read_only: false read_only: false

View File

@@ -45,6 +45,8 @@ _STOPWORDS = frozenset(
"humidity", "humidity",
"illuminance", "illuminance",
"light", "light",
"licht",
"lichtschalter",
"power", "power",
"sensor", "sensor",
"state", "state",
@@ -54,8 +56,10 @@ _STOPWORDS = frozenset(
} }
) )
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78 _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5 _MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20 _AUDIT_LIMIT = 20
@@ -231,12 +235,29 @@ class ActuatorReconciliationService:
numeric_candidates: list[AssignmentCandidate], numeric_candidates: list[AssignmentCandidate],
context_candidates: list[AssignmentCandidate], context_candidates: list[AssignmentCandidate],
) -> AssignmentSelection: ) -> AssignmentSelection:
top_numeric = numeric_candidates[0] if numeric_candidates else None top_numeric = next(
top_contexts = [ (candidate for candidate in numeric_candidates if candidate.auto_accepted),
candidate.entity_id None,
)
accepted_contexts = [
candidate
for candidate in context_candidates for candidate in context_candidates
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,
@@ -433,8 +454,15 @@ class ActuatorReconciliationService:
confidence = candidate.score / highest if highest else 0.0 confidence = candidate.score / highest if highest else 0.0
margin = candidate.score - second_score if index == 0 else 0.0 margin = candidate.score - second_score if index == 0 else 0.0
auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE auto_score = _CONTEXT_AUTO_ACCEPT_SCORE if context else _NUMERIC_AUTO_ACCEPT_SCORE
auto_accepted = confidence >= auto_score and ( minimum_score = (
context or margin >= _NUMERIC_MIN_MARGIN _CONTEXT_AUTO_ACCEPT_MIN_SCORE
if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
)
auto_accepted = (
candidate.score >= minimum_score
and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN)
) )
sorted_candidates[index] = candidate.model_copy( sorted_candidates[index] = candidate.model_copy(
update={ update={
@@ -510,6 +538,9 @@ def _score_candidate(
if entity.device_class in preferred_device_classes: if entity.device_class in preferred_device_classes:
score += 0.2 score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}") evidence.append(f"Passende device_class: {entity.device_class}")
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
if not context and entity.unit_of_measurement is not None: if not context and entity.unit_of_measurement is not None:
score += 0.05 score += 0.05
evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}") evidence.append(f"Numerische Einheit vorhanden: {entity.unit_of_measurement}")

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"})
@@ -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)

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

@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.5.0", version="0.5.2",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -13,6 +13,10 @@
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; } main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; } section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
.wide { grid-column: 1 / -1; } .wide { grid-column: 1 / -1; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
.step p { margin:5px 0; }
.ok { color: #66dfa9; } .ok { color: #66dfa9; }
.warn { color: #f3c969; } .warn { color: #f3c969; }
.bad { color: #ff8f8f; } .bad { color: #ff8f8f; }
@@ -34,34 +38,61 @@
<body> <body>
<header> <header>
<h1>SillyHome Next</h1> <h1>SillyHome Next</h1>
<p>Du wählst nur die Aktoren. SillyHome findet Kontext, lernt Gewohnheiten und trifft Vorhersagen im Shadow-Modus.</p> <p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
<p class="notice">Geschaltet wird erst nach deiner ausdrücklichen Freigabe pro Aktor.</p> <p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</header> </header>
<main> <main>
<section class="wide">
<h2>So gehst du vor</h2>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Unten im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
</div>
</section>
<section> <section>
<h2>Systemstatus</h2> <h2>Systemstatus</h2>
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
<div id="status">Prüfung läuft ...</div> <div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div> <div class="chips" id="status-chips"></div>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button> <button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
</section> </section>
<section> <section>
<h2>Aktor freigeben</h2> <h2>1. Gerät zum Lernen auswählen</h2>
<p class="muted">Nach der Auswahl analysiert SillyHome automatisch passende Sensoren, Zustände und Historie.</p> <p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
<label for="actuator-select">Home-Assistant-Aktor</label> <label for="actuator-select">Gerät aus Home Assistant</label>
<select id="actuator-select"></select> <select id="actuator-select"></select>
<button onclick="configureActuator()">Auswählen und Lernen starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
</section> </section>
<section class="wide"> <section class="wide">
<h2>Ausgewählte Aktoren</h2> <h2>2. Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
<div id="configured-actuators">Noch nicht geladen.</div> <div id="configured-actuators">Noch nicht geladen.</div>
</section> </section>
<section class="wide"> <section class="wide">
<h2>Automatisch erkannter Lernkontext</h2> <h2>3. Lernfortschritt und Freigabe</h2>
<div id="actuator-detail" class="muted">Wähle einen Aktor aus der Liste.</div> <p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section> </section>
</main> </main>
<script> <script>
@@ -81,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",
@@ -93,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";
@@ -120,7 +153,7 @@ async function loadOverview() {
`<span class="chip">API: ${escapeHtml(health.status)}</span>`, `<span class="chip">API: ${escapeHtml(health.status)}</span>`,
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`, `<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
`<span class="chip">Aktoren: ${actuators.length}</span>`, `<span class="chip">Aktoren: ${actuators.length}</span>`,
`<span class="chip">Aktive Modelle: ${reconciliation.trained_models}</span>`, `<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
].join(""); ].join("");
} catch (error) { } catch (error) {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
@@ -171,7 +204,7 @@ async function loadConfiguredActuators() {
const rows = await api("v1/actuators"); const rows = await api("v1/actuators");
box.innerHTML = rows.length ? ` box.innerHTML = rows.length ? `
<table> <table>
<tr><th>Aktor</th><th>Verhaltensmodell</th><th>Handlungen</th><th>Vorhersage</th><th></th></tr> <tr><th>Gerät</th><th>Lernstatus</th><th>Gelernte Handlungen</th><th>Letzte Vorhersage</th><th>Aktionen</th></tr>
${rows.map(record => ` ${rows.map(record => `
<tr> <tr>
<td>${escapeHtml(record.actuator_entity_id)}</td> <td>${escapeHtml(record.actuator_entity_id)}</td>
@@ -192,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 {
@@ -206,36 +239,50 @@ 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 requiredTrustedActions = 3;
const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation",
).length;
const missingTrustedActions = Math.max(
0,
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" : 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>`
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>"; : record.behavior.status === "trained"
? `<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>";
box.innerHTML = ` box.innerHTML = `
<div class="grid-two"> <div class="grid-two">
<div> <div>
<h3>${escapeHtml(record.actuator_entity_id)}</h3> <h3>${escapeHtml(record.actuator_entity_id)}</h3>
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p> <p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
<p><strong>Zuordnung:</strong> automatisch</p> <p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
<p><strong>Sicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p> <p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
<p><strong>Bewertung:</strong> ${escapeHtml(record.assignment.reason)}</p> <p class="muted">Dieser Wert beschreibt, wie sicher Raum, Sensoren und Zustände zu diesem Gerät passen.</p>
<p><strong>Ergebnis:</strong> ${escapeHtml(record.assignment.reason)}</p>
</div> </div>
<div> <div>
<h3>Verhaltensmodell</h3> <h3>Lernfortschritt</h3>
<p><strong>Modus:</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>Status:</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>Aktuelle Vorhersage</h3> <h3>Was SillyHome aktuell vorhersagt</h3>
${prediction ${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${prediction.executed ? "<span class='ok'>Ausgeführt.</span>" : "<span class='muted'>Nicht ausgeführt.</span>"}</p>` ? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} ${prediction.executed ? "<span class='ok'>Ausgeführt.</span>" : "<span class='muted'>Nicht ausgeführt.</span>"}</p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"} : "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<h3>Automatisch verwendeter Kontext</h3> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
`; `;
} catch (error) { } catch (error) {
@@ -245,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);
} }
@@ -276,7 +332,7 @@ async function removeActuator(actuatorId) {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"}); await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
if (currentActuatorId === actuatorId) { if (currentActuatorId === actuatorId) {
currentActuatorId = null; currentActuatorId = null;
document.getElementById("actuator-detail").textContent = "Wähle einen Aktor aus der Liste."; document.getElementById("actuator-detail").textContent = "Öffne bei einem beobachteten Gerät die Details.";
} }
await loadOverview(); await loadOverview();
} catch (error) { } catch (error) {

View File

@@ -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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.5.0" version = "0.5.2"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

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,
@@ -141,7 +142,7 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Path) -> None: def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
HaEntitySummary( HaEntitySummary(
@@ -181,8 +182,62 @@ def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Pat
record = service.configure_actuator("switch.garage_pump") record = service.configure_actuator("switch.garage_pump")
assert record.assignment.review_required is True assert record.assignment.review_required is True
assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy" assert record.assignment.selected_numeric_entity_id is None
assert record.lifecycle.status is LifecycleStatus.TRAINED assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_cross_assign_other_room_light_energy(
tmp_path: Path,
) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id=(
"light.lichtschalter_abstellraum_"
"lichtschalter_abstellraum_s1"
),
domain="light",
friendly_name="Licht Abstellraum",
),
HaEntitySummary(
entity_id="sensor.licht_badezimmer_energy",
domain="sensor",
device_class="energy",
state_class="total_increasing",
unit_of_measurement="kWh",
friendly_name="Lichtschalter_Badezimmer Licht Badezimmer energy",
),
HaEntitySummary(
entity_id="binary_sensor.abstellraum_ture",
domain="binary_sensor",
device_class="door",
friendly_name="Abstellraum Türe",
),
HaEntitySummary(
entity_id="binary_sensor.briefkasten_open",
domain="binary_sensor",
device_class="opening",
friendly_name="Briefkasten open",
),
]
service = _service(
tmp_path,
entities,
{"sensor.licht_badezimmer_energy": _points(8, start, 1.0)},
)
record = service.configure_actuator(
"light.lichtschalter_abstellraum_lichtschalter_abstellraum_s1"
)
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == [
"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
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None:

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
@@ -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

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

@@ -0,0 +1,18 @@
from pathlib import Path
def test_addon_does_not_expose_internal_learning_parameters() -> None:
config = Path("addon/config.yaml").read_text(encoding="utf-8")
assert "\noptions:" not in config
assert "\nschema:" not in config
assert "prediction_confidence" not in config
assert "execution_cooldown_seconds" not in config
def test_addon_version_invalidates_application_build_layer() -> None:
dockerfile = Path("addon/Dockerfile").read_text(encoding="utf-8")
config_copy = dockerfile.index("COPY config.yaml /tmp/addon-config.yaml")
repository_clone = dockerfile.index("git clone --depth 1 --branch main")
assert config_copy < repository_clone

View File

@@ -9,7 +9,21 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "SillyHome Next" in response.text assert "SillyHome Next" in response.text
assert "Aktor freigeben" in response.text assert "So gehst du vor" in response.text
assert "ausdrücklichen Freigabe pro Aktor" in response.text assert "Gerät zum Lernen auswählen" in response.text
assert "Wie gewohnt bedienen" 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 "Freigabe noch gesperrt" in response.text
assert "manuelle oder automatisierte Handlung" 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