BEHAVIOR-001: learn and predict actuator actions
This commit is contained in:
@@ -5,7 +5,6 @@ from pathlib import Path
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import (
|
||||
AssignmentSource,
|
||||
LifecycleStatus,
|
||||
ManualOverride,
|
||||
model_id_for_actuator,
|
||||
@@ -142,7 +141,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_requires_review_for_ambiguous_sensor_mapping(tmp_path: Path) -> None:
|
||||
def test_reconciliation_uses_best_automatic_mapping_when_ambiguous(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
@@ -182,10 +181,11 @@ def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: P
|
||||
record = service.configure_actuator("switch.garage_pump")
|
||||
|
||||
assert record.assignment.review_required is True
|
||||
assert record.lifecycle.status is LifecycleStatus.REVIEW_REQUIRED
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.garage_energy"
|
||||
assert record.lifecycle.status is LifecycleStatus.TRAINED
|
||||
|
||||
|
||||
def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None:
|
||||
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
@@ -218,21 +218,21 @@ def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None
|
||||
"sensor.abstellkammer_power": _points(8, start, 30.0),
|
||||
}
|
||||
service = _service(tmp_path, entities, history)
|
||||
service.configure_actuator("light.abstellkammer")
|
||||
|
||||
updated = service.set_override(
|
||||
"light.abstellkammer",
|
||||
ManualOverride(
|
||||
numeric_entity_id="sensor.abstellkammer_power",
|
||||
context_entity_ids=[],
|
||||
note="Manuelle Leistungs-Zuordnung",
|
||||
),
|
||||
configured = service.configure_actuator("light.abstellkammer")
|
||||
legacy = configured.model_copy(
|
||||
update={
|
||||
"manual_override": ManualOverride(
|
||||
numeric_entity_id="sensor.abstellkammer_power",
|
||||
context_entity_ids=[],
|
||||
note="Alte manuelle Zuordnung",
|
||||
)
|
||||
}
|
||||
)
|
||||
service._store.upsert(legacy)
|
||||
|
||||
restarted = _service(tmp_path, entities, history)
|
||||
record = restarted.reconcile_actuator("light.abstellkammer")
|
||||
|
||||
assert updated.assignment.source is AssignmentSource.MANUAL
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
|
||||
assert record.manual_override is not None
|
||||
assert record.manual_override.numeric_entity_id == "sensor.abstellkammer_power"
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
|
||||
assert record.assignment.source.value == "automatic"
|
||||
assert record.manual_override is None
|
||||
|
||||
@@ -7,10 +7,16 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
from app.config import Settings
|
||||
from app.ha.discovery import DiscoveredEntity
|
||||
from app.ha.discovery import discover_entities
|
||||
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
|
||||
from app.ha.history import (
|
||||
EntityHistorySeries,
|
||||
LogbookEntry,
|
||||
NumericHistoryPoint,
|
||||
StateHistorySeries,
|
||||
)
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.main import app
|
||||
@@ -54,6 +60,30 @@ class FakeHaReader(HaReader):
|
||||
if entity_id in self._history
|
||||
]
|
||||
|
||||
def read_state_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[StateHistorySeries]:
|
||||
return []
|
||||
|
||||
def read_logbook(
|
||||
self,
|
||||
entity_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[LogbookEntry]:
|
||||
return []
|
||||
|
||||
def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, object],
|
||||
) -> list[object]:
|
||||
return []
|
||||
|
||||
|
||||
def _install_service(tmp_path: Path) -> None:
|
||||
entities = [
|
||||
@@ -103,9 +133,14 @@ def _install_service(tmp_path: Path) -> None:
|
||||
registry=app.state.registry,
|
||||
settings=settings,
|
||||
)
|
||||
app.state.behavior_engine = BehaviorEngine(
|
||||
ha_reader=app.state.ha_reader,
|
||||
store=app.state.actuator_store,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None:
|
||||
def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
@@ -118,18 +153,34 @@ def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> Non
|
||||
listed = client.get("/v1/actuators")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["lifecycle"]["status"] == "trained"
|
||||
assert listed.json()[0]["behavior"]["mode"] == "shadow"
|
||||
|
||||
override = client.post(
|
||||
"/v1/actuators/light.abstellkammer/override",
|
||||
json={
|
||||
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
||||
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
||||
"note": "Explizit bestaetigt",
|
||||
},
|
||||
evaluation = client.post("/v1/actuators/light.abstellkammer/evaluate")
|
||||
assert evaluation.status_code == 200
|
||||
|
||||
premature_activation = client.post(
|
||||
"/v1/actuators/light.abstellkammer/activation",
|
||||
json={"active": True},
|
||||
)
|
||||
assert override.status_code == 200
|
||||
assert override.json()["assignment"]["source"] == "manual"
|
||||
assert premature_activation.status_code == 409
|
||||
|
||||
reconciliation = client.post("/v1/actuators/reconciliation/run")
|
||||
assert reconciliation.status_code == 200
|
||||
assert reconciliation.json()["trained_models"] == 1
|
||||
|
||||
removed = client.delete("/v1/actuators/light.abstellkammer")
|
||||
assert removed.status_code == 204
|
||||
assert client.get("/v1/actuators").json() == []
|
||||
|
||||
|
||||
def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
|
||||
response = client.post(
|
||||
"/v1/actuators/light.abstellkammer/override",
|
||||
json={"numeric_entity_id": "sensor.abstellkammer_illuminance"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -1,59 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.automations.store import AutomationStore
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _payload() -> dict[str, object]:
|
||||
return {
|
||||
"alias": "Licht bei Dunkelheit",
|
||||
"description": "Schaltet das Flurlicht unter dem Helligkeitsgrenzwert ein.",
|
||||
"trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
|
||||
"action": {
|
||||
"service": "light.turn_on",
|
||||
"entity_id": "light.hall",
|
||||
"data": {"brightness_pct": 40},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_proposal_requires_explicit_approval_before_yaml(tmp_path: Path) -> None:
|
||||
def test_automation_api_is_not_exposed() -> None:
|
||||
with TestClient(app) as client:
|
||||
app.state.automation_store = AutomationStore(tmp_path)
|
||||
created = client.post("/v1/automations/proposals", json=_payload())
|
||||
proposal_id = created.json()["proposal_id"]
|
||||
blocked = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
|
||||
approved = client.post(
|
||||
f"/v1/automations/proposals/{proposal_id}/approve",
|
||||
json={"expected_revision": 1},
|
||||
response = client.post(
|
||||
"/v1/automations/proposals",
|
||||
json={
|
||||
"alias": "Nicht mehr verfügbar",
|
||||
"trigger": {"entity_id": "sensor.hall_illuminance", "below": 10},
|
||||
"action": {"service": "light.turn_on", "entity_id": "light.hall"},
|
||||
},
|
||||
)
|
||||
exported = client.get(f"/v1/automations/proposals/{proposal_id}/yaml")
|
||||
assert created.status_code == 201
|
||||
assert created.json()["status"] == "draft"
|
||||
assert blocked.status_code == 409
|
||||
assert approved.json()["status"] == "approved"
|
||||
assert "service: light.turn_on" in exported.text
|
||||
|
||||
|
||||
def test_proposal_rejects_unsafe_service_domain(tmp_path: Path) -> None:
|
||||
payload = _payload()
|
||||
payload["action"] = {
|
||||
"service": "shell_command.run",
|
||||
"entity_id": "light.hall",
|
||||
"data": {},
|
||||
}
|
||||
with TestClient(app) as client:
|
||||
app.state.automation_store = AutomationStore(tmp_path)
|
||||
response = client.post("/v1/automations/proposals", json=payload)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_proposal_requires_numeric_threshold(tmp_path: Path) -> None:
|
||||
payload = _payload()
|
||||
payload["trigger"] = {"entity_id": "sensor.hall_illuminance"}
|
||||
with TestClient(app) as client:
|
||||
app.state.automation_store = AutomationStore(tmp_path)
|
||||
response = client.post("/v1/automations/proposals", json=payload)
|
||||
assert response.status_code == 422
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -73,9 +73,10 @@ def test_entities_returns_reader_data() -> None:
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"entity_id": "sensor.temperature",
|
||||
"domain": "sensor",
|
||||
"state_class": None,
|
||||
"entity_id": "sensor.temperature",
|
||||
"domain": "sensor",
|
||||
"state": None,
|
||||
"state_class": None,
|
||||
"device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
"friendly_name": None,
|
||||
|
||||
261
tests/behavior/test_engine.py
Normal file
261
tests/behavior/test_engine.py
Normal file
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.actuators.models import BehaviorMode, BehaviorStatus
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine, predict_behavior, service_for_state
|
||||
from app.config import Settings
|
||||
from app.ha.history import (
|
||||
LogbookEntry,
|
||||
StateHistoryPoint,
|
||||
StateHistorySeries,
|
||||
)
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
|
||||
class FakeBehaviorReader(HaReader):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
entities: list[HaEntitySummary],
|
||||
history: list[StateHistorySeries],
|
||||
logbook: list[LogbookEntry],
|
||||
) -> None:
|
||||
self.entities = entities
|
||||
self.history = history
|
||||
self.logbook = logbook
|
||||
self.service_calls: list[tuple[str, str, dict[str, object]]] = []
|
||||
|
||||
def read_entities(self) -> list[HaEntitySummary]:
|
||||
return list(self.entities)
|
||||
|
||||
def read_state_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[StateHistorySeries]:
|
||||
return [series for series in self.history if series.entity_id in entity_ids]
|
||||
|
||||
def read_logbook(
|
||||
self,
|
||||
entity_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[LogbookEntry]:
|
||||
return [entry for entry in self.logbook if entry.entity_id == entity_id]
|
||||
|
||||
def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, object],
|
||||
) -> list[object]:
|
||||
self.service_calls.append((domain, service, service_data))
|
||||
return []
|
||||
|
||||
|
||||
def _settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
actuator_store=str(tmp_path / "actuators"),
|
||||
model_store=str(tmp_path / "models"),
|
||||
automation_store=str(tmp_path / "automations"),
|
||||
history_days=14,
|
||||
min_behavior_actions=3,
|
||||
prediction_confidence=0.8,
|
||||
prediction_window_minutes=30,
|
||||
execution_cooldown_seconds=900,
|
||||
timezone="Europe/Berlin",
|
||||
)
|
||||
|
||||
|
||||
def _reader(now: datetime) -> FakeBehaviorReader:
|
||||
actuator_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"),
|
||||
StateHistoryPoint(timestamp=action_at + timedelta(hours=6), state="off"),
|
||||
]
|
||||
)
|
||||
logbook.extend(
|
||||
[
|
||||
LogbookEntry(
|
||||
entity_id="light.office",
|
||||
timestamp=action_at,
|
||||
message="turned on",
|
||||
context_user_id="user-1",
|
||||
),
|
||||
LogbookEntry(
|
||||
entity_id="light.office",
|
||||
timestamp=action_at + timedelta(hours=6),
|
||||
message="turned off",
|
||||
context_domain="automation",
|
||||
context_service="trigger",
|
||||
),
|
||||
]
|
||||
)
|
||||
actuator_points.sort(key=lambda point: point.timestamp)
|
||||
context_points = [
|
||||
StateHistoryPoint(timestamp=now - timedelta(days=7), state="on"),
|
||||
]
|
||||
return FakeBehaviorReader(
|
||||
entities=[
|
||||
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.office_presence",
|
||||
domain="binary_sensor",
|
||||
state="on",
|
||||
),
|
||||
],
|
||||
history=[
|
||||
StateHistorySeries(entity_id="light.office", points=actuator_points),
|
||||
StateHistorySeries(
|
||||
entity_id="binary_sensor.office_presence",
|
||||
points=context_points,
|
||||
),
|
||||
],
|
||||
logbook=logbook,
|
||||
)
|
||||
|
||||
|
||||
def _engine(tmp_path: Path, now: datetime) -> tuple[BehaviorEngine, FakeBehaviorReader]:
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("light.office")
|
||||
store.upsert(
|
||||
record.model_copy(
|
||||
update={
|
||||
"assignment": record.assignment.model_copy(
|
||||
update={
|
||||
"selected_context_entity_ids": [
|
||||
"binary_sensor.office_presence"
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
reader = _reader(now)
|
||||
return (
|
||||
BehaviorEngine(ha_reader=reader, store=store, settings=settings),
|
||||
reader,
|
||||
)
|
||||
|
||||
|
||||
def test_engine_trains_predicts_in_shadow_and_executes_only_after_approval(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
engine, reader = _engine(tmp_path, now)
|
||||
|
||||
trained = engine.train("light.office")
|
||||
shadow = engine.evaluate("light.office")
|
||||
|
||||
assert trained.behavior.status is BehaviorStatus.TRAINED
|
||||
assert trained.behavior.sample_count == 3
|
||||
assert trained.behavior.high_confidence_sample_count == 3
|
||||
assert shadow.behavior.mode is BehaviorMode.SHADOW
|
||||
assert shadow.behavior.prediction is not None
|
||||
assert shadow.behavior.prediction.target_state == "on"
|
||||
assert reader.service_calls == []
|
||||
|
||||
engine.set_active("light.office", active=True)
|
||||
active = engine.evaluate("light.office")
|
||||
|
||||
assert active.behavior.mode is BehaviorMode.ACTIVE
|
||||
assert active.behavior.prediction is not None
|
||||
assert active.behavior.prediction.executed is True
|
||||
assert reader.service_calls == [
|
||||
("light", "turn_on", {"entity_id": "light.office"})
|
||||
]
|
||||
|
||||
|
||||
def test_engine_excludes_known_automation_actions(tmp_path: Path) -> None:
|
||||
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||
engine, _ = _engine(tmp_path, now)
|
||||
|
||||
trained = engine.train("light.office")
|
||||
|
||||
assert {pattern.target_state for pattern in trained.behavior.patterns} == {"on"}
|
||||
assert {pattern.source for pattern in trained.behavior.patterns} == {"user"}
|
||||
|
||||
|
||||
def test_active_mode_rejects_unsafe_domains(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("lock.front_door")
|
||||
store.upsert(
|
||||
record.model_copy(
|
||||
update={
|
||||
"behavior": record.behavior.model_copy(
|
||||
update={"status": BehaviorStatus.TRAINED}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
with pytest.raises(ValueError, match="nicht freigegeben"):
|
||||
engine.set_active("lock.front_door", active=True)
|
||||
|
||||
|
||||
def test_active_mode_requires_user_attributed_actions(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
store = ActuatorStore(settings.actuator_store)
|
||||
record = store.configure("light.office")
|
||||
store.upsert(
|
||||
record.model_copy(
|
||||
update={
|
||||
"behavior": record.behavior.model_copy(
|
||||
update={
|
||||
"status": BehaviorStatus.TRAINED,
|
||||
"sample_count": 3,
|
||||
"high_confidence_sample_count": 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
reader = FakeBehaviorReader(entities=[], history=[], logbook=[])
|
||||
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
|
||||
|
||||
with pytest.raises(ValueError, match="eindeutig dir zugeordnete"):
|
||||
engine.set_active("light.office", active=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("domain", "state", "service"),
|
||||
[
|
||||
("light", "on", "turn_on"),
|
||||
("switch", "off", "turn_off"),
|
||||
("cover", "open", "open_cover"),
|
||||
("cover", "closed", "close_cover"),
|
||||
("lock", "unlocked", None),
|
||||
],
|
||||
)
|
||||
def test_service_for_state_is_strictly_allowlisted(
|
||||
domain: str,
|
||||
state: str,
|
||||
service: str | None,
|
||||
) -> None:
|
||||
assert service_for_state(domain, state) == service
|
||||
|
||||
|
||||
def test_prediction_requires_temporal_support() -> None:
|
||||
assert predict_behavior(
|
||||
[],
|
||||
current_context={},
|
||||
now=datetime.now(timezone.utc),
|
||||
min_support=3,
|
||||
window_minutes=30,
|
||||
) is None
|
||||
@@ -108,6 +108,35 @@ def test_list_entity_metadata_calls_template_api() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_get_logbook_filters_entity_and_period() -> None:
|
||||
response = _response(payload=[{"entity_id": "light.office"}])
|
||||
client = _client_with_response(response)
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
||||
|
||||
payload = client.get_logbook("light.office", start, end)
|
||||
|
||||
assert payload == [{"entity_id": "light.office"}]
|
||||
call = client._session.get.call_args # type: ignore[attr-defined]
|
||||
assert "/api/logbook/2026-06-01T00:00:00+00:00" in call.args[0]
|
||||
assert call.kwargs["params"]["entity"] == "light.office"
|
||||
|
||||
|
||||
def test_call_service_posts_to_home_assistant() -> None:
|
||||
response = _response(payload=[])
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||
client._session.post = Mock(return_value=response) # type: ignore[method-assign]
|
||||
|
||||
result = client.call_service("light", "turn_on", {"entity_id": "light.office"})
|
||||
|
||||
assert result == []
|
||||
client._session.post.assert_called_once_with(
|
||||
"http://ha.local/api/services/light/turn_on",
|
||||
json={"entity_id": "light.office"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_ids", "start", "end"),
|
||||
[
|
||||
|
||||
@@ -54,6 +54,29 @@ class FakeHaClient(HaClient):
|
||||
}
|
||||
}
|
||||
|
||||
def get_logbook(
|
||||
self,
|
||||
entity_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[object]:
|
||||
return [
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"when": start_time.isoformat(),
|
||||
"message": "turned on",
|
||||
"context_user_id": "user-1",
|
||||
}
|
||||
]
|
||||
|
||||
def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, object],
|
||||
) -> list[object]:
|
||||
return []
|
||||
|
||||
|
||||
def test_ha_reader_returns_summaries() -> None:
|
||||
reader = HaReader(FakeHaClient())
|
||||
@@ -63,6 +86,7 @@ def test_ha_reader_returns_summaries() -> None:
|
||||
assert domains == {"sensor", "light"}
|
||||
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.area_name == "Kueche"
|
||||
assert sensor.device_name == "Thermometer"
|
||||
|
||||
@@ -87,3 +111,15 @@ def test_ha_reader_normalizes_history() -> None:
|
||||
|
||||
assert history[0].entity_id == "sensor.temperature"
|
||||
assert history[0].points[0].value == 21.5
|
||||
|
||||
|
||||
def test_ha_reader_normalizes_state_history_and_logbook() -> None:
|
||||
reader = HaReader(FakeHaClient())
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 6, 2, tzinfo=timezone.utc)
|
||||
|
||||
history = reader.read_state_history(["light.living_room"], start, end)
|
||||
logbook = reader.read_logbook("light.living_room", start, end)
|
||||
|
||||
assert history[0].points[0].state == "21.5"
|
||||
assert logbook[0].context_user_id == "user-1"
|
||||
|
||||
@@ -5,7 +5,11 @@ from datetime import datetime, timezone
|
||||
import pytest
|
||||
|
||||
from app.ha.exceptions import HaUnexpectedPayloadError
|
||||
from app.ha.history import normalize_history_payload
|
||||
from app.ha.history import (
|
||||
normalize_history_payload,
|
||||
normalize_logbook_payload,
|
||||
normalize_state_history_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_history_payload_groups_and_sorts_numeric_states() -> None:
|
||||
@@ -90,3 +94,46 @@ def test_normalize_history_payload_rejects_malformed_structure(payload: object)
|
||||
|
||||
def test_normalize_history_payload_accepts_empty_series() -> None:
|
||||
assert normalize_history_payload([[]]) == []
|
||||
|
||||
|
||||
def test_normalize_state_history_keeps_categorical_changes() -> None:
|
||||
result = normalize_state_history_payload(
|
||||
[
|
||||
[
|
||||
{
|
||||
"entity_id": "light.office",
|
||||
"state": "off",
|
||||
"last_changed": "2026-06-01T08:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"state": "on",
|
||||
"last_changed": "2026-06-01T08:05:00+00:00",
|
||||
},
|
||||
{
|
||||
"state": "on",
|
||||
"last_changed": "2026-06-01T08:06:00+00:00",
|
||||
},
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
assert [point.state for point in result[0].points] == ["off", "on"]
|
||||
|
||||
|
||||
def test_normalize_logbook_preserves_action_origin() -> None:
|
||||
result = normalize_logbook_payload(
|
||||
[
|
||||
{
|
||||
"entity_id": "light.office",
|
||||
"when": "2026-06-01T08:05:00+00:00",
|
||||
"message": "turned on",
|
||||
"context_user_id": "user-1",
|
||||
"context_domain": "light",
|
||||
"context_service": "turn_on",
|
||||
}
|
||||
],
|
||||
"light.office",
|
||||
)
|
||||
|
||||
assert result[0].context_user_id == "user-1"
|
||||
assert result[0].context_service == "turn_on"
|
||||
|
||||
@@ -15,6 +15,12 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
|
||||
monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12")
|
||||
monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48")
|
||||
monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600")
|
||||
monkeypatch.setenv("SILLYHOME_MIN_BEHAVIOR_ACTIONS", "4")
|
||||
monkeypatch.setenv("SILLYHOME_PREDICTION_CONFIDENCE", "0.9")
|
||||
monkeypatch.setenv("SILLYHOME_PREDICTION_WINDOW_MINUTES", "20")
|
||||
monkeypatch.setenv("SILLYHOME_PREDICTION_INTERVAL_SECONDS", "45")
|
||||
monkeypatch.setenv("SILLYHOME_EXECUTION_COOLDOWN_SECONDS", "1200")
|
||||
monkeypatch.setenv("SILLYHOME_TIMEZONE", "Europe/Berlin")
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
@@ -27,4 +33,10 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
|
||||
assert settings.min_training_points == 12
|
||||
assert settings.retrain_stale_hours == 48
|
||||
assert settings.reconcile_interval_seconds == 600
|
||||
assert settings.min_behavior_actions == 4
|
||||
assert settings.prediction_confidence == 0.9
|
||||
assert settings.prediction_window_minutes == 20
|
||||
assert settings.prediction_interval_seconds == 45
|
||||
assert settings.execution_cooldown_seconds == 1200
|
||||
assert settings.timezone == "Europe/Berlin"
|
||||
assert settings.ha_configured
|
||||
|
||||
@@ -9,4 +9,7 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "SillyHome Next" in response.text
|
||||
assert "Automation-Entwurf" in response.text
|
||||
assert "Aktor freigeben" in response.text
|
||||
assert "ausdrücklichen Freigabe pro Aktor" in response.text
|
||||
assert "Automation-Entwurf" not in response.text
|
||||
assert "Manuelle Overrides" not in response.text
|
||||
|
||||
Reference in New Issue
Block a user