533 lines
18 KiB
Python
533 lines
18 KiB
Python
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
|