Compare commits
5 Commits
fix/sensor
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| fb76d89204 | |||
| 1370d02c15 | |||
| 100f5af578 | |||
| ede6b87dbd | |||
| ef7e0c5600 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# 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`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "0.5.3"
|
||||
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
|
||||
|
||||
@@ -239,12 +239,25 @@ class ActuatorReconciliationService:
|
||||
(candidate for candidate in numeric_candidates if candidate.auto_accepted),
|
||||
None,
|
||||
)
|
||||
top_contexts = [
|
||||
candidate.entity_id
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,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",
|
||||
@@ -124,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";
|
||||
@@ -237,11 +239,21 @@ async function showActuator(actuatorId) {
|
||||
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
||||
.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>
|
||||
@@ -257,6 +269,7 @@ async function showActuator(actuatorId) {
|
||||
<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>Was noch passiert:</strong> ${escapeHtml(record.behavior.reason)}</p>
|
||||
${activationButton}
|
||||
|
||||
@@ -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,
|
||||
@@ -233,6 +234,9 @@ def test_reconciliation_does_not_cross_assign_other_room_light_energy(
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -14,5 +14,9 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
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