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