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, 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_rejects_ambiguous_numeric_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.assignment.selected_numeric_entity_id is None assert record.lifecycle.status is LifecycleStatus.ARCHIVED def test_reconciliation_does_not_cross_assign_other_room_light_energy( tmp_path: Path, ) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ HaEntitySummary( entity_id=( "light.lichtschalter_abstellraum_" "lichtschalter_abstellraum_s1" ), domain="light", friendly_name="Licht Abstellraum", ), HaEntitySummary( entity_id="sensor.licht_badezimmer_energy", domain="sensor", device_class="energy", state_class="total_increasing", unit_of_measurement="kWh", friendly_name="Lichtschalter_Badezimmer Licht Badezimmer energy", ), HaEntitySummary( entity_id="binary_sensor.abstellraum_ture", domain="binary_sensor", device_class="door", friendly_name="Abstellraum Türe", ), HaEntitySummary( entity_id="binary_sensor.briefkasten_open", domain="binary_sensor", device_class="opening", friendly_name="Briefkasten open", ), ] service = _service( tmp_path, entities, {"sensor.licht_badezimmer_energy": _points(8, start, 1.0)}, ) record = service.configure_actuator( "light.lichtschalter_abstellraum_lichtschalter_abstellraum_s1" ) assert record.assignment.selected_numeric_entity_id is None assert record.assignment.selected_context_entity_ids == [ "binary_sensor.abstellraum_ture" ] assert record.assignment.source is AssignmentSource.AUTOMATIC assert record.assignment.confidence == 1.0 assert record.assignment.review_required is False assert record.lifecycle.status is LifecycleStatus.ARCHIVED def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context( tmp_path: Path, ) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ HaEntitySummary( entity_id="light.abstellkammer", domain="light", friendly_name="Licht Abstellkammer", area_name="Monitoring", ), HaEntitySummary( entity_id="binary_sensor.disk_overheating", domain="binary_sensor", device_class="problem", friendly_name="Max. fehlerhafte Sektoren ueberschritten", area_name="Monitoring", ), HaEntitySummary( entity_id="sensor.router_power", domain="sensor", device_class="power", state_class="measurement", unit_of_measurement="W", friendly_name="Router Leistung", area_name="Monitoring", ), ] service = _service(tmp_path, entities, {"sensor.router_power": _points(8, start, 1.0)}) record = service.configure_actuator("light.abstellkammer") assert record.assignment.selected_numeric_entity_id is None assert record.assignment.selected_context_entity_ids == [] assert record.assignment.review_required is True assert record.lifecycle.status is LifecycleStatus.ARCHIVED def test_manual_assignment_persists_and_wins_over_automatic_mapping(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") service.set_manual_assignment( "light.abstellkammer", numeric_entity_id="sensor.abstellkammer_power", context_entity_ids=["sensor.abstellkammer_illuminance"], note="Manuell wichtiger Sensor", ) restarted = _service(tmp_path, entities, history) record = restarted.reconcile_actuator("light.abstellkammer") assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power" assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"] assert record.assignment.source is AssignmentSource.MANUAL assert record.manual_override is not None