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=31, 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.now(timezone.utc) - timedelta(days=1) 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_reconciliation_does_not_auto_select_overload_sensors_by_power_area( tmp_path: Path, ) -> None: entities = [ HaEntitySummary( entity_id="light.treppe_unten", domain="light", friendly_name="Licht Treppe Unten", area_name="Strom", ), HaEntitySummary( entity_id="binary_sensor.shelly_schrank_channel_1_overload", domain="binary_sensor", device_class="problem", friendly_name="Shelly Schrank Channel 1 Überlast", area_name="Strom", ), HaEntitySummary( entity_id="binary_sensor.terrasse_terasse_overheating", domain="binary_sensor", device_class="problem", friendly_name="Terrasse Terasse Überhitzung", area_name="Strom", ), ] service = _service(tmp_path, entities, {}) record = service.configure_actuator("light.treppe_unten") assert record.assignment.selected_context_entity_ids == [] assert all(candidate.auto_accepted is False for candidate in record.context_candidates) def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ HaEntitySummary( entity_id="fan.bad_lueftung", domain="fan", friendly_name="Bad Lüftung", area_name="Bad", ), HaEntitySummary( entity_id="sensor.bad_luftfeuchtigkeit", domain="sensor", device_class="humidity", state_class="measurement", unit_of_measurement="%", friendly_name="Bad Luftfeuchtigkeit", area_name="Bad", ), HaEntitySummary( entity_id="sensor.bad_power", domain="sensor", device_class="power", state_class="measurement", unit_of_measurement="W", friendly_name="Bad Leistung", area_name="Bad", ), ] service = _service( tmp_path, entities, { "sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0), "sensor.bad_power": _points(8, start, 5.0), }, ) record = service.configure_actuator("fan.bad_lueftung") assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit" def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None: entities = [ HaEntitySummary( entity_id="light.lidl_kuche", domain="light", friendly_name="Lidl Küche", ), HaEntitySummary( entity_id="light.lidl_wohnzimmer", domain="light", friendly_name="Lidl Wohnzimmer", ), HaEntitySummary( entity_id="binary_sensor.pir_kuche_motion_detection", domain="binary_sensor", device_class="motion", friendly_name="Bewegungsmelder", device_name="PIR_Küche", ), HaEntitySummary( entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any", domain="binary_sensor", device_class="motion", friendly_name="Bewegungsmelder", device_name="PIR_Wohnzimmer", ), ] service = _service(tmp_path, entities, {}) record = service.configure_actuator("light.lidl_kuche") assert record.assignment.selected_context_entity_ids == [ "binary_sensor.pir_kuche_motion_detection" ] def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None: entities = [ HaEntitySummary( entity_id="button.smart_mailbox_als_geleert_markieren", domain="button", friendly_name="Smart Mailbox Als geleert markieren", ), HaEntitySummary( entity_id="binary_sensor.schrank_strasse_open", domain="binary_sensor", device_class="door", friendly_name="Schrank Straße", ), ] service = _service(tmp_path, entities, {}) record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren") assert record.assignment.selected_context_entity_ids == [ "binary_sensor.schrank_strasse_open" ] assert record.assignment.review_required is False def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ HaEntitySummary( entity_id="humidifier.gastewc_luftung", domain="humidifier", friendly_name="GästeWC Lüftung", ), HaEntitySummary( entity_id="sensor.pir_gastewc_humidity", domain="sensor", device_class="humidity", state_class="measurement", unit_of_measurement="%", friendly_name="Gäste WC Luftfeuchtigkeit", ), HaEntitySummary( entity_id="input_boolean.gaste_wc_occupied", domain="input_boolean", friendly_name="gaste_wc_occupied", ), ] service = _service( tmp_path, entities, {"sensor.pir_gastewc_humidity": _points(8, start, 55.0)}, ) record = service.configure_actuator("humidifier.gastewc_luftung") assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity" assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids 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 def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None: entities = [ HaEntitySummary( entity_id="light.abstellkammer", domain="light", friendly_name="Abstellkammer Licht", area_name="Abstellkammer", ), HaEntitySummary( entity_id="binary_sensor.abstellkammer_motion", domain="binary_sensor", device_class="motion", friendly_name="Abstellkammer Bewegung", area_name="Abstellkammer", ), ] service = _service(tmp_path, entities, {}) service.configure_actuator("light.abstellkammer") for _ in range(3): service.set_manual_assignment( "light.abstellkammer", numeric_entity_id=None, context_entity_ids=["binary_sensor.abstellkammer_motion"], note="Manuell gesetzt", ) record = service.get_actuator("light.abstellkammer") candidate = next( item for item in record.context_candidates if item.entity_id == "binary_sensor.abstellkammer_motion" ) assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1