ACT-001: actuator-first sensor lifecycle
This commit is contained in:
18
tests/actuators/test_actuator_store.py
Normal file
18
tests/actuators/test_actuator_store.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.actuators.models import ReconciliationState
|
||||
from app.actuators.store import ActuatorStore
|
||||
|
||||
|
||||
def test_actuator_store_persists_record_and_reconciliation_state(tmp_path: Path) -> None:
|
||||
store = ActuatorStore(tmp_path)
|
||||
store.configure("light.abstellkammer")
|
||||
state = ReconciliationState(last_summary="ok", configured_actuators=1)
|
||||
|
||||
store.save_reconciliation_state(state)
|
||||
|
||||
restarted = ActuatorStore(tmp_path)
|
||||
assert restarted.get("light.abstellkammer").actuator_entity_id == "light.abstellkammer"
|
||||
assert restarted.load_reconciliation_state().last_summary == "ok"
|
||||
238
tests/actuators/test_lifecycle.py
Normal file
238
tests/actuators/test_lifecycle.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import (
|
||||
AssignmentSource,
|
||||
LifecycleStatus,
|
||||
ManualOverride,
|
||||
model_id_for_actuator,
|
||||
)
|
||||
from app.actuators.store import ActuatorStore
|
||||
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.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
|
||||
|
||||
class FakeActuatorReader(HaReader):
|
||||
def __init__(
|
||||
self,
|
||||
entities: list[HaEntitySummary],
|
||||
history_by_entity: dict[str, list[NumericHistoryPoint]],
|
||||
) -> None:
|
||||
self._entities = entities
|
||||
self._history_by_entity = history_by_entity
|
||||
|
||||
def read_entities(self) -> list[HaEntitySummary]:
|
||||
return list(self._entities)
|
||||
|
||||
def discover(
|
||||
self,
|
||||
domains: set[str] | None = None,
|
||||
learnable: bool | None = None,
|
||||
) -> list[DiscoveredEntity]:
|
||||
return discover_entities(self._entities, domains=domains, learnable=learnable)
|
||||
|
||||
def read_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[EntityHistorySeries]:
|
||||
series: list[EntityHistorySeries] = []
|
||||
for entity_id in entity_ids:
|
||||
points = [
|
||||
point
|
||||
for point in self._history_by_entity.get(entity_id, [])
|
||||
if start_time <= point.timestamp <= end_time
|
||||
]
|
||||
if points:
|
||||
series.append(EntityHistorySeries(entity_id=entity_id, points=points))
|
||||
return series
|
||||
|
||||
|
||||
def _points(count: int, start: datetime, value: float) -> list[NumericHistoryPoint]:
|
||||
return [
|
||||
NumericHistoryPoint(timestamp=start + timedelta(hours=index), value=value + index)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _service(
|
||||
tmp_path: Path,
|
||||
entities: list[HaEntitySummary],
|
||||
history_by_entity: dict[str, list[NumericHistoryPoint]],
|
||||
) -> ActuatorReconciliationService:
|
||||
return ActuatorReconciliationService(
|
||||
ha_reader=FakeActuatorReader(entities, history_by_entity),
|
||||
store=ActuatorStore(tmp_path / "actuators"),
|
||||
registry=ModelRegistry(tmp_path / "models"),
|
||||
settings=Settings(
|
||||
ha_url="http://ha.local",
|
||||
ha_token="token",
|
||||
model_store=str(tmp_path / "models"),
|
||||
automation_store=str(tmp_path / "automations"),
|
||||
actuator_store=str(tmp_path / "actuators"),
|
||||
history_days=14,
|
||||
min_training_points=5,
|
||||
retrain_stale_hours=24,
|
||||
reconcile_interval_seconds=900,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="light.abstellkammer",
|
||||
domain="light",
|
||||
friendly_name="Abstellkammer Licht",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.abstellkammer_illuminance",
|
||||
domain="sensor",
|
||||
device_class="illuminance",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="lx",
|
||||
friendly_name="Abstellkammer Helligkeit",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.abstellkammer_motion",
|
||||
domain="binary_sensor",
|
||||
device_class="motion",
|
||||
friendly_name="Abstellkammer Bewegung",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.kitchen_temperature",
|
||||
domain="sensor",
|
||||
device_class="temperature",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="°C",
|
||||
friendly_name="Kueche Temperatur",
|
||||
area_name="Kueche",
|
||||
),
|
||||
]
|
||||
service = _service(
|
||||
tmp_path,
|
||||
entities,
|
||||
{
|
||||
"sensor.abstellkammer_illuminance": _points(8, start, 10.0),
|
||||
"sensor.kitchen_temperature": _points(8, start, 18.0),
|
||||
},
|
||||
)
|
||||
|
||||
record = service.configure_actuator("light.abstellkammer")
|
||||
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance"
|
||||
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_motion"]
|
||||
assert record.assignment.review_required is False
|
||||
assert record.lifecycle.status is LifecycleStatus.TRAINED
|
||||
artifact = service._registry.load_artifact(model_id_for_actuator("light.abstellkammer"))
|
||||
assert artifact.supported_sensors == ("sensor.abstellkammer_illuminance",)
|
||||
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
|
||||
|
||||
|
||||
def test_reconciliation_requires_review_for_ambiguous_sensor_mapping(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="switch.garage_pump",
|
||||
domain="switch",
|
||||
friendly_name="Garage Pumpe",
|
||||
area_name="Garage",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.garage_power",
|
||||
domain="sensor",
|
||||
device_class="power",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="W",
|
||||
friendly_name="Garage Leistung",
|
||||
area_name="Garage",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.garage_energy",
|
||||
domain="sensor",
|
||||
device_class="energy",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="kWh",
|
||||
friendly_name="Garage Energie",
|
||||
area_name="Garage",
|
||||
),
|
||||
]
|
||||
service = _service(
|
||||
tmp_path,
|
||||
entities,
|
||||
{
|
||||
"sensor.garage_power": _points(8, start, 10.0),
|
||||
"sensor.garage_energy": _points(8, start, 11.0),
|
||||
},
|
||||
)
|
||||
|
||||
record = service.configure_actuator("switch.garage_pump")
|
||||
|
||||
assert record.assignment.review_required is True
|
||||
assert record.lifecycle.status is LifecycleStatus.REVIEW_REQUIRED
|
||||
|
||||
|
||||
def test_manual_override_persists_and_wins_after_restart(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="light.abstellkammer",
|
||||
domain="light",
|
||||
friendly_name="Abstellkammer Licht",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.abstellkammer_illuminance",
|
||||
domain="sensor",
|
||||
device_class="illuminance",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="lx",
|
||||
friendly_name="Abstellkammer Helligkeit",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.abstellkammer_power",
|
||||
domain="sensor",
|
||||
device_class="power",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="W",
|
||||
friendly_name="Abstellkammer Leistung",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
]
|
||||
history = {
|
||||
"sensor.abstellkammer_illuminance": _points(8, start, 10.0),
|
||||
"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",
|
||||
),
|
||||
)
|
||||
|
||||
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"
|
||||
135
tests/api/test_actuators.py
Normal file
135
tests/api/test_actuators.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.store import ActuatorStore
|
||||
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.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.main import app
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
|
||||
|
||||
class FakeHaReader(HaReader):
|
||||
def __init__(self, entities: list[HaEntitySummary], history: dict[str, list[float]]) -> None:
|
||||
self._entities = entities
|
||||
self._history = history
|
||||
|
||||
def read_entities(self) -> list[HaEntitySummary]:
|
||||
return list(self._entities)
|
||||
|
||||
def discover(
|
||||
self,
|
||||
domains: set[str] | None = None,
|
||||
learnable: bool | None = None,
|
||||
) -> list[DiscoveredEntity]:
|
||||
return discover_entities(self._entities, domains=domains, learnable=learnable)
|
||||
|
||||
def read_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[EntityHistorySeries]:
|
||||
base = start_time
|
||||
return [
|
||||
EntityHistorySeries(
|
||||
entity_id=entity_id,
|
||||
points=[
|
||||
NumericHistoryPoint(
|
||||
timestamp=base + timedelta(hours=index),
|
||||
value=value,
|
||||
)
|
||||
for index, value in enumerate(self._history.get(entity_id, []))
|
||||
],
|
||||
)
|
||||
for entity_id in entity_ids
|
||||
if entity_id in self._history
|
||||
]
|
||||
|
||||
|
||||
def _install_service(tmp_path: Path) -> None:
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="light.abstellkammer",
|
||||
domain="light",
|
||||
friendly_name="Abstellkammer Licht",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.abstellkammer_illuminance",
|
||||
domain="sensor",
|
||||
device_class="illuminance",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="lx",
|
||||
friendly_name="Abstellkammer Helligkeit",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.abstellkammer_motion",
|
||||
domain="binary_sensor",
|
||||
device_class="motion",
|
||||
friendly_name="Abstellkammer Bewegung",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
]
|
||||
settings = Settings(
|
||||
ha_url="http://ha.local",
|
||||
ha_token="token",
|
||||
model_store=str(tmp_path / "models"),
|
||||
automation_store=str(tmp_path / "automations"),
|
||||
actuator_store=str(tmp_path / "actuators"),
|
||||
history_days=14,
|
||||
min_training_points=5,
|
||||
retrain_stale_hours=24,
|
||||
reconcile_interval_seconds=900,
|
||||
)
|
||||
app.state.registry = ModelRegistry(tmp_path / "models")
|
||||
app.state.actuator_store = ActuatorStore(tmp_path / "actuators")
|
||||
app.state.ha_reader = FakeHaReader(
|
||||
entities,
|
||||
{"sensor.abstellkammer_illuminance": [10, 11, 12, 13, 14, 15]},
|
||||
)
|
||||
app.state.actuator_service = ActuatorReconciliationService(
|
||||
ha_reader=app.state.ha_reader,
|
||||
store=app.state.actuator_store,
|
||||
registry=app.state.registry,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
def test_actuator_api_configures_reconciles_and_overrides(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
created = client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
assert created.status_code == 201
|
||||
assert created.json()["assignment"]["selected_numeric_entity_id"] == (
|
||||
"sensor.abstellkammer_illuminance"
|
||||
)
|
||||
|
||||
listed = client.get("/v1/actuators")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()[0]["lifecycle"]["status"] == "trained"
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
assert override.status_code == 200
|
||||
assert override.json()["assignment"]["source"] == "manual"
|
||||
|
||||
reconciliation = client.post("/v1/actuators/reconciliation/run")
|
||||
assert reconciliation.status_code == 200
|
||||
assert reconciliation.json()["trained_models"] == 1
|
||||
@@ -78,6 +78,11 @@ def test_entities_returns_reader_data() -> None:
|
||||
"state_class": None,
|
||||
"device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
"friendly_name": None,
|
||||
"area_id": None,
|
||||
"area_name": None,
|
||||
"device_id": None,
|
||||
"device_name": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -88,6 +88,26 @@ def test_get_history_calls_home_assistant_history_api() -> None:
|
||||
assert call.kwargs["params"]["end_time"] == "2026-06-02T00:00:00+00:00"
|
||||
|
||||
|
||||
def test_list_entity_metadata_calls_template_api() -> None:
|
||||
response = _response()
|
||||
response.text = (
|
||||
'[{"entity_id":"sensor.temperature","area_name":"Kueche","device_name":"Thermometer"}]'
|
||||
)
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||
client._session.post = Mock(return_value=response) # type: ignore[method-assign]
|
||||
|
||||
metadata = client.list_entity_metadata(["sensor.temperature"])
|
||||
|
||||
assert metadata == {
|
||||
"sensor.temperature": {
|
||||
"area_id": None,
|
||||
"area_name": "Kueche",
|
||||
"device_id": None,
|
||||
"device_name": "Thermometer",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_ids", "start", "end"),
|
||||
[
|
||||
|
||||
@@ -44,6 +44,16 @@ class FakeHaClient(HaClient):
|
||||
]
|
||||
]
|
||||
|
||||
def list_entity_metadata(self, entity_ids: list[str]) -> dict[str, dict[str, str | None]]:
|
||||
return {
|
||||
"sensor.temperature": {
|
||||
"area_id": "kitchen",
|
||||
"area_name": "Kueche",
|
||||
"device_id": "device-1",
|
||||
"device_name": "Thermometer",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_ha_reader_returns_summaries() -> None:
|
||||
reader = HaReader(FakeHaClient())
|
||||
@@ -53,6 +63,8 @@ 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.area_name == "Kueche"
|
||||
assert sensor.device_name == "Thermometer"
|
||||
|
||||
|
||||
def test_ha_reader_discovers_learnable_sensors() -> None:
|
||||
|
||||
@@ -10,6 +10,11 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
|
||||
monkeypatch.setenv("SILLYHOME_HA_TOKEN", "secret")
|
||||
monkeypatch.setenv("SILLYHOME_MODEL_STORE", "/tmp/models")
|
||||
monkeypatch.setenv("SILLYHOME_AUTOMATION_STORE", "/tmp/automations")
|
||||
monkeypatch.setenv("SILLYHOME_ACTUATOR_STORE", "/tmp/actuators")
|
||||
monkeypatch.setenv("SILLYHOME_HISTORY_DAYS", "7")
|
||||
monkeypatch.setenv("SILLYHOME_MIN_TRAINING_POINTS", "12")
|
||||
monkeypatch.setenv("SILLYHOME_RETRAIN_STALE_HOURS", "48")
|
||||
monkeypatch.setenv("SILLYHOME_RECONCILE_INTERVAL_SECONDS", "600")
|
||||
|
||||
settings = load_settings()
|
||||
|
||||
@@ -17,4 +22,9 @@ def test_load_settings_reads_documented_environment(monkeypatch: MonkeyPatch) ->
|
||||
assert settings.ha_token == "secret"
|
||||
assert settings.model_store == "/tmp/models"
|
||||
assert settings.automation_store == "/tmp/automations"
|
||||
assert settings.actuator_store == "/tmp/actuators"
|
||||
assert settings.history_days == 7
|
||||
assert settings.min_training_points == 12
|
||||
assert settings.retrain_stale_hours == 48
|
||||
assert settings.reconcile_interval_seconds == 600
|
||||
assert settings.ha_configured
|
||||
|
||||
Reference in New Issue
Block a user