BEHAVIOR-001: learn and predict actuator actions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-14 10:37:59 +02:00
parent 6305f52cd2
commit fa250216be
34 changed files with 1614 additions and 489 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View File

@@ -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"),
[

View File

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

View File

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

View File

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

View File

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