Compare commits
11 Commits
feature/ac
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ae051238 | |||
| fb76d89204 | |||
| 1370d02c15 | |||
| 100f5af578 | |||
| ede6b87dbd | |||
| 47e8c7e549 | |||
| ef7e0c5600 | |||
| 8d070fc9ca | |||
| ba15cc4d83 | |||
| da51ac2063 | |||
| ce568056fc |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.6.0 - 2026-06-14
|
||||||
|
- Kausales Shadow-Lernen erkennt frische Kontextwechsel unmittelbar vor einer
|
||||||
|
Aktorhandlung, etwa `Tür geschlossen → offen` vor `Licht aus → an`
|
||||||
|
- Historische Home-Assistant-Automationen dürfen Vorhersagen begründen, zählen
|
||||||
|
aber weiterhin niemals als eindeutige Benutzerhandlung oder Ausführungsfreigabe
|
||||||
|
- Aktuelle `last_changed`-Zeitpunkte verhindern Vorhersagen aus längst
|
||||||
|
unveränderten Sensorzuständen
|
||||||
|
- Oberfläche trennt gelernte Benutzerhandlungen und erkannte HA-Automationen
|
||||||
|
|
||||||
|
## 0.5.4 - 2026-06-14
|
||||||
|
- Tür-, Bewegungs- und andere belastbare Kontextsensoren werden auch ohne
|
||||||
|
numerischen Sensor als vollständige automatische Kontextzuordnung angezeigt
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.5.0"
|
version: "0.6.0"
|
||||||
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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -198,12 +199,18 @@ class BehaviorEngine:
|
|||||||
)
|
)
|
||||||
if entity_id and entity_id in entities and entities[entity_id].state is not None
|
if entity_id and entity_id in entities and entities[entity_id].state is not None
|
||||||
}
|
}
|
||||||
|
current_context_changed_at = {
|
||||||
|
entity_id: entities[entity_id].last_changed
|
||||||
|
for entity_id in current_context
|
||||||
|
}
|
||||||
prediction = predict_behavior(
|
prediction = predict_behavior(
|
||||||
record.behavior.patterns,
|
record.behavior.patterns,
|
||||||
current_context=current_context,
|
current_context=current_context,
|
||||||
|
current_context_changed_at=current_context_changed_at,
|
||||||
now=now,
|
now=now,
|
||||||
min_support=self._settings.min_behavior_actions,
|
min_support=self._settings.min_behavior_actions,
|
||||||
window_minutes=self._settings.prediction_window_minutes,
|
window_minutes=self._settings.prediction_window_minutes,
|
||||||
|
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
|
||||||
timezone_name=self._settings.timezone,
|
timezone_name=self._settings.timezone,
|
||||||
)
|
)
|
||||||
behavior = record.behavior.model_copy(
|
behavior = record.behavior.model_copy(
|
||||||
@@ -326,7 +333,12 @@ class BehaviorEngine:
|
|||||||
if _matches_own_execution(point, own_executions):
|
if _matches_own_execution(point, own_executions):
|
||||||
continue
|
continue
|
||||||
source, weight = _action_source(point, logbook)
|
source, weight = _action_source(point, logbook)
|
||||||
if source == "automation":
|
trigger = _recent_context_transition(
|
||||||
|
context_history,
|
||||||
|
context_ids,
|
||||||
|
point.timestamp,
|
||||||
|
)
|
||||||
|
if source == "automation" and trigger is None:
|
||||||
continue
|
continue
|
||||||
contexts = {
|
contexts = {
|
||||||
entity_id: state
|
entity_id: state
|
||||||
@@ -340,6 +352,9 @@ class BehaviorEngine:
|
|||||||
minute_of_day=local.hour * 60 + local.minute,
|
minute_of_day=local.hour * 60 + local.minute,
|
||||||
weekday=local.weekday(),
|
weekday=local.weekday(),
|
||||||
context_states=contexts,
|
context_states=contexts,
|
||||||
|
trigger_entity_id=trigger[0] if trigger else None,
|
||||||
|
trigger_from_state=trigger[1] if trigger else None,
|
||||||
|
trigger_to_state=trigger[2] if trigger else None,
|
||||||
source=source,
|
source=source,
|
||||||
weight=weight,
|
weight=weight,
|
||||||
observed_at=point.timestamp,
|
observed_at=point.timestamp,
|
||||||
@@ -373,14 +388,52 @@ def predict_behavior(
|
|||||||
now: datetime,
|
now: datetime,
|
||||||
min_support: int,
|
min_support: int,
|
||||||
window_minutes: int,
|
window_minutes: int,
|
||||||
|
current_context_changed_at: dict[str, datetime | None] | None = None,
|
||||||
|
causal_window_seconds: int = 120,
|
||||||
timezone_name: str = "Europe/Berlin",
|
timezone_name: str = "Europe/Berlin",
|
||||||
) -> BehaviorPrediction | None:
|
) -> BehaviorPrediction | None:
|
||||||
if not patterns:
|
if not patterns:
|
||||||
return None
|
return None
|
||||||
local = now.astimezone(ZoneInfo(timezone_name))
|
local = now.astimezone(ZoneInfo(timezone_name))
|
||||||
minute_of_day = local.hour * 60 + local.minute
|
minute_of_day = local.hour * 60 + local.minute
|
||||||
|
changed_at = current_context_changed_at or {}
|
||||||
by_state: dict[str, list[float]] = {}
|
by_state: dict[str, list[float]] = {}
|
||||||
|
causal_support_by_state: dict[str, int] = {}
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
|
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
||||||
|
trigger_changed_at = changed_at.get(pattern.trigger_entity_id)
|
||||||
|
trigger_age = (
|
||||||
|
(now - trigger_changed_at).total_seconds()
|
||||||
|
if trigger_changed_at is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
current_context.get(pattern.trigger_entity_id)
|
||||||
|
== pattern.trigger_to_state
|
||||||
|
and trigger_age is not None
|
||||||
|
and 0 <= trigger_age <= causal_window_seconds
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
comparable = [
|
||||||
|
(entity_id, expected)
|
||||||
|
for entity_id, expected in pattern.context_states.items()
|
||||||
|
if entity_id in current_context
|
||||||
|
]
|
||||||
|
context_score = (
|
||||||
|
sum(
|
||||||
|
current_context[entity_id] == expected
|
||||||
|
for entity_id, expected in comparable
|
||||||
|
)
|
||||||
|
/ len(comparable)
|
||||||
|
if comparable
|
||||||
|
else 0.5
|
||||||
|
)
|
||||||
|
score = pattern.weight * (0.85 + 0.15 * context_score)
|
||||||
|
by_state.setdefault(pattern.target_state, []).append(score)
|
||||||
|
causal_support_by_state[pattern.target_state] = (
|
||||||
|
causal_support_by_state.get(pattern.target_state, 0) + 1
|
||||||
|
)
|
||||||
|
continue
|
||||||
distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
|
distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
|
||||||
if distance > window_minutes:
|
if distance > window_minutes:
|
||||||
continue
|
continue
|
||||||
@@ -414,6 +467,7 @@ def predict_behavior(
|
|||||||
key=lambda item: (sum(item[1]), len(item[1]), item[0]),
|
key=lambda item: (sum(item[1]), len(item[1]), item[0]),
|
||||||
)
|
)
|
||||||
support = len(scores)
|
support = len(scores)
|
||||||
|
causal_support = causal_support_by_state.get(target_state, 0)
|
||||||
confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
|
confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
|
||||||
if confidence <= 0:
|
if confidence <= 0:
|
||||||
return None
|
return None
|
||||||
@@ -423,7 +477,12 @@ def predict_behavior(
|
|||||||
generated_at=now,
|
generated_at=now,
|
||||||
matching_patterns=support,
|
matching_patterns=support,
|
||||||
reason=(
|
reason=(
|
||||||
f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
|
(
|
||||||
|
f"{causal_support} historische Handlungen folgten demselben "
|
||||||
|
"frischen Sensorwechsel."
|
||||||
|
)
|
||||||
|
if causal_support
|
||||||
|
else f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -476,6 +535,32 @@ def _matches_own_execution(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_context_transition(
|
||||||
|
history: dict[str, StateHistorySeries],
|
||||||
|
context_ids: list[str],
|
||||||
|
timestamp: datetime,
|
||||||
|
) -> tuple[str, str, str] | None:
|
||||||
|
nearest: tuple[timedelta, str, str, str] | None = None
|
||||||
|
for entity_id in context_ids:
|
||||||
|
series = history.get(entity_id)
|
||||||
|
if series is None:
|
||||||
|
continue
|
||||||
|
previous_state: str | None = None
|
||||||
|
for point in series.points:
|
||||||
|
if point.timestamp > timestamp:
|
||||||
|
break
|
||||||
|
if previous_state is not None and point.state != previous_state:
|
||||||
|
age = timestamp - point.timestamp
|
||||||
|
if age <= _CONTEXT_TRIGGER_TOLERANCE and (
|
||||||
|
nearest is None or age < nearest[0]
|
||||||
|
):
|
||||||
|
nearest = (age, entity_id, previous_state, point.state)
|
||||||
|
previous_state = point.state
|
||||||
|
if nearest is None:
|
||||||
|
return None
|
||||||
|
return nearest[1], nearest[2], nearest[3]
|
||||||
|
|
||||||
|
|
||||||
def _circular_minute_distance(left: int, right: int) -> int:
|
def _circular_minute_distance(left: int, right: int) -> int:
|
||||||
direct = abs(left - right)
|
direct = abs(left - right)
|
||||||
return min(direct, 1440 - direct)
|
return min(direct, 1440 - direct)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -206,36 +239,48 @@ async function showActuator(actuatorId) {
|
|||||||
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
||||||
.join("");
|
.join("");
|
||||||
const prediction = record.behavior.prediction;
|
const prediction = record.behavior.prediction;
|
||||||
|
const requiredUserActions = 3;
|
||||||
|
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||||
|
pattern => pattern.source === "automation",
|
||||||
|
).length;
|
||||||
|
const missingUserActions = Math.max(
|
||||||
|
0,
|
||||||
|
requiredUserActions - record.behavior.high_confidence_sample_count,
|
||||||
|
);
|
||||||
const activationButton = record.behavior.mode === "active"
|
const activationButton = record.behavior.mode === "active"
|
||||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false)">Autonomes Schalten stoppen</button>`
|
||||||
: record.behavior.status === "trained"
|
: record.behavior.status === "trained" && missingUserActions === 0
|
||||||
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
|
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true)">Lernen und Schalten freigeben</button>`
|
||||||
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
|
: record.behavior.status === "trained"
|
||||||
|
? `<p class='muted'>Freigabe noch gesperrt: ${missingUserActions} eindeutig manuelle Bedienung${missingUserActions === 1 ? "" : "en"} fehlen. Bediene das Licht dafür direkt über Home Assistant.</p>`
|
||||||
|
: "<p class='muted'>Freigabe wird möglich, sobald genügend Handlungen gelernt wurden.</p>";
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
<div class="grid-two">
|
<div class="grid-two">
|
||||||
<div>
|
<div>
|
||||||
<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 Benutzer:</strong> ${record.behavior.high_confidence_sample_count}</p>
|
||||||
|
<p><strong>Davon erkannte HA-Automationen:</strong> ${learnedAutomationActions}</p>
|
||||||
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
|
<p><strong>Letztes Training:</strong> ${escapeHtml(record.behavior.last_trained_at || "noch nicht")}</p>
|
||||||
<p><strong>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)}')">Vorhersage jetzt prüfen</button>
|
||||||
</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) {
|
||||||
@@ -276,7 +321,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) {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -189,6 +189,98 @@ def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
|
|||||||
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
|
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_learns_causal_automation_for_shadow_without_user_credit(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
actuator_points: list[StateHistoryPoint] = []
|
||||||
|
door_points: list[StateHistoryPoint] = []
|
||||||
|
logbook: list[LogbookEntry] = []
|
||||||
|
for days_ago in (3, 2, 1):
|
||||||
|
action_at = now - timedelta(days=days_ago)
|
||||||
|
actuator_points.extend(
|
||||||
|
[
|
||||||
|
StateHistoryPoint(
|
||||||
|
timestamp=action_at - timedelta(minutes=1),
|
||||||
|
state="off",
|
||||||
|
),
|
||||||
|
StateHistoryPoint(timestamp=action_at, state="on"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
door_points.extend(
|
||||||
|
[
|
||||||
|
StateHistoryPoint(
|
||||||
|
timestamp=action_at - timedelta(minutes=1),
|
||||||
|
state="off",
|
||||||
|
),
|
||||||
|
StateHistoryPoint(
|
||||||
|
timestamp=action_at - timedelta(seconds=1),
|
||||||
|
state="on",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logbook.append(
|
||||||
|
LogbookEntry(
|
||||||
|
entity_id="light.storage",
|
||||||
|
timestamp=action_at,
|
||||||
|
message="turned on",
|
||||||
|
context_domain="automation",
|
||||||
|
context_service="trigger",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
actuator_points.sort(key=lambda point: point.timestamp)
|
||||||
|
door_points.sort(key=lambda point: point.timestamp)
|
||||||
|
settings = _settings(tmp_path)
|
||||||
|
store = ActuatorStore(settings.actuator_store)
|
||||||
|
record = store.configure("light.storage")
|
||||||
|
store.upsert(
|
||||||
|
record.model_copy(
|
||||||
|
update={
|
||||||
|
"assignment": record.assignment.model_copy(
|
||||||
|
update={
|
||||||
|
"selected_context_entity_ids": [
|
||||||
|
"binary_sensor.storage_door"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reader = FakeBehaviorReader(
|
||||||
|
entities=[],
|
||||||
|
history=[
|
||||||
|
StateHistorySeries(
|
||||||
|
entity_id="light.storage",
|
||||||
|
points=actuator_points,
|
||||||
|
),
|
||||||
|
StateHistorySeries(
|
||||||
|
entity_id="binary_sensor.storage_door",
|
||||||
|
points=door_points,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
logbook=logbook,
|
||||||
|
)
|
||||||
|
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||||
|
|
||||||
|
trained = engine.train("light.storage")
|
||||||
|
automation_patterns = [
|
||||||
|
pattern
|
||||||
|
for pattern in trained.behavior.patterns
|
||||||
|
if pattern.source == "automation"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(automation_patterns) == 3
|
||||||
|
assert trained.behavior.high_confidence_sample_count == 0
|
||||||
|
assert {
|
||||||
|
(
|
||||||
|
pattern.trigger_entity_id,
|
||||||
|
pattern.trigger_from_state,
|
||||||
|
pattern.trigger_to_state,
|
||||||
|
)
|
||||||
|
for pattern in automation_patterns
|
||||||
|
} == {("binary_sensor.storage_door", "off", "on")}
|
||||||
|
|
||||||
|
|
||||||
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
||||||
settings = _settings(tmp_path)
|
settings = _settings(tmp_path)
|
||||||
store = ActuatorStore(settings.actuator_store)
|
store = ActuatorStore(settings.actuator_store)
|
||||||
@@ -259,3 +351,66 @@ def test_prediction_requires_temporal_support() -> None:
|
|||||||
min_support=3,
|
min_support=3,
|
||||||
window_minutes=30,
|
window_minutes=30,
|
||||||
) is None
|
) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_prediction_uses_fresh_causal_context_transition_outside_time_window() -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
patterns = [
|
||||||
|
BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"binary_sensor.storage_door": "on"},
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
source="automation",
|
||||||
|
weight=0.7,
|
||||||
|
observed_at=now - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
for days_ago in (3, 2, 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
prediction = predict_behavior(
|
||||||
|
patterns,
|
||||||
|
current_context={"binary_sensor.storage_door": "on"},
|
||||||
|
current_context_changed_at={
|
||||||
|
"binary_sensor.storage_door": now - timedelta(seconds=10)
|
||||||
|
},
|
||||||
|
now=now,
|
||||||
|
min_support=3,
|
||||||
|
window_minutes=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prediction is not None
|
||||||
|
assert prediction.target_state == "on"
|
||||||
|
assert prediction.matching_patterns == 3
|
||||||
|
assert prediction.confidence == 0.7
|
||||||
|
assert "frischen Sensorwechsel" in prediction.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_prediction_ignores_stale_causal_context_state() -> None:
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
pattern = BehaviorPattern(
|
||||||
|
target_state="on",
|
||||||
|
minute_of_day=60,
|
||||||
|
weekday=0,
|
||||||
|
context_states={"binary_sensor.storage_door": "on"},
|
||||||
|
trigger_entity_id="binary_sensor.storage_door",
|
||||||
|
trigger_from_state="off",
|
||||||
|
trigger_to_state="on",
|
||||||
|
source="automation",
|
||||||
|
weight=0.7,
|
||||||
|
observed_at=now - timedelta(days=1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert predict_behavior(
|
||||||
|
[pattern],
|
||||||
|
current_context={"binary_sensor.storage_door": "on"},
|
||||||
|
current_context_changed_at={
|
||||||
|
"binary_sensor.storage_door": now - timedelta(minutes=5)
|
||||||
|
},
|
||||||
|
now=now,
|
||||||
|
min_support=1,
|
||||||
|
window_minutes=30,
|
||||||
|
) is None
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
18
tests/test_addon_config.py
Normal file
18
tests/test_addon_config.py
Normal 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
|
||||||
@@ -9,7 +9,14 @@ 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 "Bediene das Licht dafür direkt über Home Assistant" in response.text
|
||||||
|
assert "Davon erkannte HA-Automationen" in response.text
|
||||||
|
assert 'record.behavior.status === "trained" && missingUserActions === 0' in response.text
|
||||||
assert "Automation-Entwurf" not in response.text
|
assert "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