Compare commits
9 Commits
v0.5.0
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| fb76d89204 | |||
| 1370d02c15 | |||
| 100f5af578 | |||
| ede6b87dbd | |||
| 47e8c7e549 | |||
| ef7e0c5600 | |||
| 8d070fc9ca | |||
| ba15cc4d83 | |||
| da51ac2063 |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,5 +1,42 @@
|
||||
# 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
|
||||
- Ingress auf reine Aktorauswahl, automatischen Lernstatus und Vorhersagen reduziert
|
||||
- Automatische Kontextzuordnung ohne Sensor-Overrides oder Review-Blockade
|
||||
|
||||
@@ -4,13 +4,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=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 \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
&& git clone --depth 1 --branch main \
|
||||
http://192.168.6.31:3000/pino/sillyhome-next.git /app \
|
||||
&& python -m pip install --upgrade pip \
|
||||
&& 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
|
||||
RUN chmod 0755 /run.sh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "0.5.0"
|
||||
version: "0.6.0"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
@@ -16,28 +16,6 @@ panel_admin: true
|
||||
homeassistant_api: true
|
||||
hassio_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:
|
||||
- type: addon_config
|
||||
read_only: false
|
||||
|
||||
@@ -45,6 +45,8 @@ _STOPWORDS = frozenset(
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"light",
|
||||
"licht",
|
||||
"lichtschalter",
|
||||
"power",
|
||||
"sensor",
|
||||
"state",
|
||||
@@ -54,8 +56,10 @@ _STOPWORDS = frozenset(
|
||||
}
|
||||
)
|
||||
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
|
||||
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
|
||||
_NUMERIC_MIN_MARGIN = 0.18
|
||||
_CONTEXT_AUTO_ACCEPT_SCORE = 0.78
|
||||
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
|
||||
_MAX_CONTEXT_SELECTIONS = 5
|
||||
_AUDIT_LIMIT = 20
|
||||
|
||||
@@ -231,12 +235,29 @@ class ActuatorReconciliationService:
|
||||
numeric_candidates: list[AssignmentCandidate],
|
||||
context_candidates: list[AssignmentCandidate],
|
||||
) -> AssignmentSelection:
|
||||
top_numeric = numeric_candidates[0] if numeric_candidates else None
|
||||
top_contexts = [
|
||||
candidate.entity_id
|
||||
top_numeric = next(
|
||||
(candidate for candidate in numeric_candidates if candidate.auto_accepted),
|
||||
None,
|
||||
)
|
||||
accepted_contexts = [
|
||||
candidate
|
||||
for candidate in context_candidates
|
||||
if candidate.auto_accepted
|
||||
][: _MAX_CONTEXT_SELECTIONS]
|
||||
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
|
||||
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(
|
||||
selected_numeric_entity_id=None,
|
||||
selected_context_entity_ids=top_contexts,
|
||||
@@ -433,8 +454,15 @@ class ActuatorReconciliationService:
|
||||
confidence = candidate.score / highest if highest 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_accepted = confidence >= auto_score and (
|
||||
context or margin >= _NUMERIC_MIN_MARGIN
|
||||
minimum_score = (
|
||||
_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(
|
||||
update={
|
||||
@@ -510,6 +538,9 @@ def _score_candidate(
|
||||
if entity.device_class in preferred_device_classes:
|
||||
score += 0.2
|
||||
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:
|
||||
score += 0.05
|
||||
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)
|
||||
weekday: int = Field(ge=0, le=6)
|
||||
context_states: dict[str, str] = Field(default_factory=dict)
|
||||
trigger_entity_id: str | None = None
|
||||
trigger_from_state: str | None = None
|
||||
trigger_to_state: str | None = None
|
||||
source: str = Field(default="observed", max_length=40)
|
||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||
observed_at: datetime
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.ha.reader import HaReader
|
||||
_MAX_PATTERNS = 500
|
||||
_MAX_EXECUTION_EVENTS = 100
|
||||
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
||||
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
||||
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
|
||||
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
|
||||
@@ -198,12 +199,18 @@ class BehaviorEngine:
|
||||
)
|
||||
if entity_id and entity_id in entities and entities[entity_id].state is not None
|
||||
}
|
||||
current_context_changed_at = {
|
||||
entity_id: entities[entity_id].last_changed
|
||||
for entity_id in current_context
|
||||
}
|
||||
prediction = predict_behavior(
|
||||
record.behavior.patterns,
|
||||
current_context=current_context,
|
||||
current_context_changed_at=current_context_changed_at,
|
||||
now=now,
|
||||
min_support=self._settings.min_behavior_actions,
|
||||
window_minutes=self._settings.prediction_window_minutes,
|
||||
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
|
||||
timezone_name=self._settings.timezone,
|
||||
)
|
||||
behavior = record.behavior.model_copy(
|
||||
@@ -326,7 +333,12 @@ class BehaviorEngine:
|
||||
if _matches_own_execution(point, own_executions):
|
||||
continue
|
||||
source, weight = _action_source(point, logbook)
|
||||
if source == "automation":
|
||||
trigger = _recent_context_transition(
|
||||
context_history,
|
||||
context_ids,
|
||||
point.timestamp,
|
||||
)
|
||||
if source == "automation" and trigger is None:
|
||||
continue
|
||||
contexts = {
|
||||
entity_id: state
|
||||
@@ -340,6 +352,9 @@ class BehaviorEngine:
|
||||
minute_of_day=local.hour * 60 + local.minute,
|
||||
weekday=local.weekday(),
|
||||
context_states=contexts,
|
||||
trigger_entity_id=trigger[0] if trigger else None,
|
||||
trigger_from_state=trigger[1] if trigger else None,
|
||||
trigger_to_state=trigger[2] if trigger else None,
|
||||
source=source,
|
||||
weight=weight,
|
||||
observed_at=point.timestamp,
|
||||
@@ -373,14 +388,52 @@ def predict_behavior(
|
||||
now: datetime,
|
||||
min_support: int,
|
||||
window_minutes: int,
|
||||
current_context_changed_at: dict[str, datetime | None] | None = None,
|
||||
causal_window_seconds: int = 120,
|
||||
timezone_name: str = "Europe/Berlin",
|
||||
) -> BehaviorPrediction | None:
|
||||
if not patterns:
|
||||
return None
|
||||
local = now.astimezone(ZoneInfo(timezone_name))
|
||||
minute_of_day = local.hour * 60 + local.minute
|
||||
changed_at = current_context_changed_at or {}
|
||||
by_state: dict[str, list[float]] = {}
|
||||
causal_support_by_state: dict[str, int] = {}
|
||||
for pattern in patterns:
|
||||
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
||||
trigger_changed_at = changed_at.get(pattern.trigger_entity_id)
|
||||
trigger_age = (
|
||||
(now - trigger_changed_at).total_seconds()
|
||||
if trigger_changed_at is not None
|
||||
else None
|
||||
)
|
||||
if not (
|
||||
current_context.get(pattern.trigger_entity_id)
|
||||
== pattern.trigger_to_state
|
||||
and trigger_age is not None
|
||||
and 0 <= trigger_age <= causal_window_seconds
|
||||
):
|
||||
continue
|
||||
comparable = [
|
||||
(entity_id, expected)
|
||||
for entity_id, expected in pattern.context_states.items()
|
||||
if entity_id in current_context
|
||||
]
|
||||
context_score = (
|
||||
sum(
|
||||
current_context[entity_id] == expected
|
||||
for entity_id, expected in comparable
|
||||
)
|
||||
/ len(comparable)
|
||||
if comparable
|
||||
else 0.5
|
||||
)
|
||||
score = pattern.weight * (0.85 + 0.15 * context_score)
|
||||
by_state.setdefault(pattern.target_state, []).append(score)
|
||||
causal_support_by_state[pattern.target_state] = (
|
||||
causal_support_by_state.get(pattern.target_state, 0) + 1
|
||||
)
|
||||
continue
|
||||
distance = _circular_minute_distance(minute_of_day, pattern.minute_of_day)
|
||||
if distance > window_minutes:
|
||||
continue
|
||||
@@ -414,6 +467,7 @@ def predict_behavior(
|
||||
key=lambda item: (sum(item[1]), len(item[1]), item[0]),
|
||||
)
|
||||
support = len(scores)
|
||||
causal_support = causal_support_by_state.get(target_state, 0)
|
||||
confidence = min(1.0, (sum(scores) / support) * min(1.0, support / min_support))
|
||||
if confidence <= 0:
|
||||
return None
|
||||
@@ -423,7 +477,12 @@ def predict_behavior(
|
||||
generated_at=now,
|
||||
matching_patterns=support,
|
||||
reason=(
|
||||
f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
|
||||
(
|
||||
f"{causal_support} historische Handlungen folgten demselben "
|
||||
"frischen Sensorwechsel."
|
||||
)
|
||||
if causal_support
|
||||
else f"{support} ähnliche Handlungsmuster passen zu Zeit und aktuellem Kontext."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -476,6 +535,32 @@ def _matches_own_execution(
|
||||
)
|
||||
|
||||
|
||||
def _recent_context_transition(
|
||||
history: dict[str, StateHistorySeries],
|
||||
context_ids: list[str],
|
||||
timestamp: datetime,
|
||||
) -> tuple[str, str, str] | None:
|
||||
nearest: tuple[timedelta, str, str, str] | None = None
|
||||
for entity_id in context_ids:
|
||||
series = history.get(entity_id)
|
||||
if series is None:
|
||||
continue
|
||||
previous_state: str | None = None
|
||||
for point in series.points:
|
||||
if point.timestamp > timestamp:
|
||||
break
|
||||
if previous_state is not None and point.state != previous_state:
|
||||
age = timestamp - point.timestamp
|
||||
if age <= _CONTEXT_TRIGGER_TOLERANCE and (
|
||||
nearest is None or age < nearest[0]
|
||||
):
|
||||
nearest = (age, entity_id, previous_state, point.state)
|
||||
previous_state = point.state
|
||||
if nearest is None:
|
||||
return None
|
||||
return nearest[1], nearest[2], nearest[3]
|
||||
|
||||
|
||||
def _circular_minute_distance(left: int, right: int) -> int:
|
||||
direct = abs(left - right)
|
||||
return min(direct, 1440 - direct)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -15,6 +17,7 @@ class HaEntitySummary(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
state: str | None = None
|
||||
last_changed: datetime | None = None
|
||||
state_class: str | None = None
|
||||
device_class: str | None = None
|
||||
unit_of_measurement: str | None = None
|
||||
|
||||
@@ -53,6 +53,7 @@ class HaReader:
|
||||
entity_id=entity_id,
|
||||
domain=domain,
|
||||
state=_optional_str(item.get("state")),
|
||||
last_changed=_optional_datetime(item.get("last_changed")),
|
||||
state_class=_optional_str(attributes.get("state_class")),
|
||||
device_class=_optional_str(attributes.get("device_class")),
|
||||
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
|
||||
@@ -116,3 +117,13 @@ def _optional_str(value: object) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
def _optional_datetime(value: object) -> datetime | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo is not None else None
|
||||
|
||||
@@ -77,7 +77,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Next API",
|
||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||
version="0.5.0",
|
||||
version="0.5.2",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
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; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
||||
.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; }
|
||||
.warn { color: #f3c969; }
|
||||
.bad { color: #ff8f8f; }
|
||||
@@ -34,34 +38,61 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1>SillyHome Next</h1>
|
||||
<p>Du wählst nur die Aktoren. SillyHome findet Kontext, lernt Gewohnheiten und trifft Vorhersagen im Shadow-Modus.</p>
|
||||
<p class="notice">Geschaltet wird erst nach deiner ausdrücklichen Freigabe pro Aktor.</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">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
|
||||
</header>
|
||||
<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>
|
||||
<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 class="chips" id="status-chips"></div>
|
||||
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Aktor freigeben</h2>
|
||||
<p class="muted">Nach der Auswahl analysiert SillyHome automatisch passende Sensoren, Zustände und Historie.</p>
|
||||
<label for="actuator-select">Home-Assistant-Aktor</label>
|
||||
<h2>1. Gerät zum Lernen auswählen</h2>
|
||||
<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">Gerät aus Home Assistant</label>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="wide">
|
||||
<h2>Automatisch erkannter Lernkontext</h2>
|
||||
<div id="actuator-detail" class="muted">Wähle einen Aktor aus der Liste.</div>
|
||||
<h2>3. Lernfortschritt und Freigabe</h2>
|
||||
<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>
|
||||
</main>
|
||||
<script>
|
||||
@@ -81,6 +112,7 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function lifecycleLabel(record) {
|
||||
if (record.behavior.status === "trained") return "Kontext erkannt";
|
||||
const labels = {
|
||||
trained: "lernt",
|
||||
pending_history: "sammelt Historie",
|
||||
@@ -93,6 +125,7 @@ function lifecycleLabel(record) {
|
||||
}
|
||||
|
||||
function statusClass(record) {
|
||||
if (record.behavior.status === "trained") return "ok";
|
||||
if (record.lifecycle.status === "trained") return "ok";
|
||||
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
|
||||
return "bad";
|
||||
@@ -120,7 +153,7 @@ async function loadOverview() {
|
||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</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("");
|
||||
} catch (error) {
|
||||
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||
@@ -171,7 +204,7 @@ async function loadConfiguredActuators() {
|
||||
const rows = await api("v1/actuators");
|
||||
box.innerHTML = rows.length ? `
|
||||
<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 => `
|
||||
<tr>
|
||||
<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>`)
|
||||
.join("");
|
||||
const prediction = record.behavior.prediction;
|
||||
const requiredUserActions = 3;
|
||||
const learnedAutomationActions = record.behavior.patterns.filter(
|
||||
pattern => pattern.source === "automation",
|
||||
).length;
|
||||
const missingUserActions = Math.max(
|
||||
0,
|
||||
requiredUserActions - record.behavior.high_confidence_sample_count,
|
||||
);
|
||||
const activationButton = record.behavior.mode === "active"
|
||||
? `<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>`
|
||||
: "<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 = `
|
||||
<div class="grid-two">
|
||||
<div>
|
||||
<h3>${escapeHtml(record.actuator_entity_id)}</h3>
|
||||
<p><strong>Status:</strong> <span class="${statusClass(record)}">${escapeHtml(lifecycleLabel(record))}</span></p>
|
||||
<p><strong>Zuordnung:</strong> automatisch</p>
|
||||
<p><strong>Sicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</p>
|
||||
<p><strong>Bewertung:</strong> ${escapeHtml(record.assignment.reason)}</p>
|
||||
<p><strong>Kontextzuordnung:</strong> automatisch erledigt</p>
|
||||
<p><strong>Zuordnungssicherheit:</strong> ${Math.round(record.assignment.confidence * 100)} %</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>
|
||||
<h3>Verhaltensmodell</h3>
|
||||
<p><strong>Modus:</strong> ${escapeHtml(behaviorLabel(record))}</p>
|
||||
<h3>Lernfortschritt</h3>
|
||||
<p><strong>Betriebsart:</strong> ${escapeHtml(behaviorLabel(record))}</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 erkannte HA-Automationen:</strong> ${learnedAutomationActions}</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}
|
||||
<button class="secondary" onclick="evaluateActuator('${escapeHtml(record.actuator_entity_id)}')">Vorhersage jetzt prüfen</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Aktuelle Vorhersage</h3>
|
||||
<h3>Was SillyHome aktuell vorhersagt</h3>
|
||||
${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 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>"}
|
||||
`;
|
||||
} catch (error) {
|
||||
@@ -276,7 +321,7 @@ async function removeActuator(actuatorId) {
|
||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}`, {method: "DELETE"});
|
||||
if (currentActuatorId === actuatorId) {
|
||||
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();
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "0.5.0"
|
||||
version = "0.5.2"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import (
|
||||
AssignmentSource,
|
||||
LifecycleStatus,
|
||||
ManualOverride,
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
@@ -181,8 +182,62 @@ def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Pat
|
||||
record = service.configure_actuator("switch.garage_pump")
|
||||
|
||||
assert record.assignment.review_required is True
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy"
|
||||
assert record.lifecycle.status is LifecycleStatus.TRAINED
|
||||
assert record.assignment.selected_numeric_entity_id is None
|
||||
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:
|
||||
|
||||
@@ -76,6 +76,7 @@ def test_entities_returns_reader_data() -> None:
|
||||
"entity_id": "sensor.temperature",
|
||||
"domain": "sensor",
|
||||
"state": None,
|
||||
"last_changed": None,
|
||||
"state_class": None,
|
||||
"device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.actuators.models import BehaviorMode, BehaviorStatus
|
||||
from app.actuators.models import BehaviorMode, BehaviorPattern, BehaviorStatus
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
|
||||
from app.config import Settings
|
||||
@@ -189,6 +189,98 @@ def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
|
||||
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
|
||||
|
||||
|
||||
def test_engine_learns_causal_automation_for_shadow_without_user_credit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
actuator_points: list[StateHistoryPoint] = []
|
||||
door_points: list[StateHistoryPoint] = []
|
||||
logbook: list[LogbookEntry] = []
|
||||
for days_ago in (3, 2, 1):
|
||||
action_at = now - timedelta(days=days_ago)
|
||||
actuator_points.extend(
|
||||
[
|
||||
StateHistoryPoint(
|
||||
timestamp=action_at - timedelta(minutes=1),
|
||||
state="off",
|
||||
),
|
||||
StateHistoryPoint(timestamp=action_at, state="on"),
|
||||
]
|
||||
)
|
||||
door_points.extend(
|
||||
[
|
||||
StateHistoryPoint(
|
||||
timestamp=action_at - timedelta(minutes=1),
|
||||
state="off",
|
||||
),
|
||||
StateHistoryPoint(
|
||||
timestamp=action_at - timedelta(seconds=1),
|
||||
state="on",
|
||||
),
|
||||
]
|
||||
)
|
||||
logbook.append(
|
||||
LogbookEntry(
|
||||
entity_id="light.storage",
|
||||
timestamp=action_at,
|
||||
message="turned on",
|
||||
context_domain="automation",
|
||||
context_service="trigger",
|
||||
)
|
||||
)
|
||||
actuator_points.sort(key=lambda point: point.timestamp)
|
||||
door_points.sort(key=lambda point: point.timestamp)
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("light.storage")
|
||||
store.upsert(
|
||||
record.model_copy(
|
||||
update={
|
||||
"assignment": record.assignment.model_copy(
|
||||
update={
|
||||
"selected_context_entity_ids": [
|
||||
"binary_sensor.storage_door"
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
reader = FakeBehaviorReader(
|
||||
entities=[],
|
||||
history=[
|
||||
StateHistorySeries(
|
||||
entity_id="light.storage",
|
||||
points=actuator_points,
|
||||
),
|
||||
StateHistorySeries(
|
||||
entity_id="binary_sensor.storage_door",
|
||||
points=door_points,
|
||||
),
|
||||
],
|
||||
logbook=logbook,
|
||||
)
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
trained = engine.train("light.storage")
|
||||
automation_patterns = [
|
||||
pattern
|
||||
for pattern in trained.behavior.patterns
|
||||
if pattern.source == "automation"
|
||||
]
|
||||
|
||||
assert len(automation_patterns) == 3
|
||||
assert trained.behavior.high_confidence_sample_count == 0
|
||||
assert {
|
||||
(
|
||||
pattern.trigger_entity_id,
|
||||
pattern.trigger_from_state,
|
||||
pattern.trigger_to_state,
|
||||
)
|
||||
for pattern in automation_patterns
|
||||
} == {("binary_sensor.storage_door", "off", "on")}
|
||||
|
||||
|
||||
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
@@ -259,3 +351,66 @@ def test_prediction_requires_temporal_support() -> None:
|
||||
min_support=3,
|
||||
window_minutes=30,
|
||||
) is None
|
||||
|
||||
|
||||
def test_prediction_uses_fresh_causal_context_transition_outside_time_window() -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
patterns = [
|
||||
BehaviorPattern(
|
||||
target_state="on",
|
||||
minute_of_day=60,
|
||||
weekday=0,
|
||||
context_states={"binary_sensor.storage_door": "on"},
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_from_state="off",
|
||||
trigger_to_state="on",
|
||||
source="automation",
|
||||
weight=0.7,
|
||||
observed_at=now - timedelta(days=days_ago),
|
||||
)
|
||||
for days_ago in (3, 2, 1)
|
||||
]
|
||||
|
||||
prediction = predict_behavior(
|
||||
patterns,
|
||||
current_context={"binary_sensor.storage_door": "on"},
|
||||
current_context_changed_at={
|
||||
"binary_sensor.storage_door": now - timedelta(seconds=10)
|
||||
},
|
||||
now=now,
|
||||
min_support=3,
|
||||
window_minutes=30,
|
||||
)
|
||||
|
||||
assert prediction is not None
|
||||
assert prediction.target_state == "on"
|
||||
assert prediction.matching_patterns == 3
|
||||
assert prediction.confidence == 0.7
|
||||
assert "frischen Sensorwechsel" in prediction.reason
|
||||
|
||||
|
||||
def test_prediction_ignores_stale_causal_context_state() -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
pattern = BehaviorPattern(
|
||||
target_state="on",
|
||||
minute_of_day=60,
|
||||
weekday=0,
|
||||
context_states={"binary_sensor.storage_door": "on"},
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_from_state="off",
|
||||
trigger_to_state="on",
|
||||
source="automation",
|
||||
weight=0.7,
|
||||
observed_at=now - timedelta(days=1),
|
||||
)
|
||||
|
||||
assert predict_behavior(
|
||||
[pattern],
|
||||
current_context={"binary_sensor.storage_door": "on"},
|
||||
current_context_changed_at={
|
||||
"binary_sensor.storage_door": now - timedelta(minutes=5)
|
||||
},
|
||||
now=now,
|
||||
min_support=1,
|
||||
window_minutes=30,
|
||||
) is None
|
||||
|
||||
@@ -15,6 +15,7 @@ class FakeHaClient(HaClient):
|
||||
{
|
||||
"entity_id": "sensor.temperature",
|
||||
"state": "21.5",
|
||||
"last_changed": "2026-06-14T12:00:00+00:00",
|
||||
"attributes": {
|
||||
"state_class": "measurement",
|
||||
"device_class": "temperature",
|
||||
@@ -87,6 +88,7 @@ def test_ha_reader_returns_summaries() -> None:
|
||||
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
|
||||
assert sensor.unit_of_measurement == "°C"
|
||||
assert sensor.state == "21.5"
|
||||
assert sensor.last_changed == datetime(2026, 6, 14, 12, 0, tzinfo=timezone.utc)
|
||||
assert sensor.area_name == "Kueche"
|
||||
assert sensor.device_name == "Thermometer"
|
||||
|
||||
|
||||
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 "SillyHome Next" in response.text
|
||||
assert "Aktor freigeben" in response.text
|
||||
assert "ausdrücklichen Freigabe pro Aktor" in response.text
|
||||
assert "So gehst du vor" 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 "Manuelle Overrides" not in response.text
|
||||
|
||||
Reference in New Issue
Block a user