BEHAVIOR-001: learn and predict actuator actions

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

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