383 lines
13 KiB
Python
383 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
from time import perf_counter
|
|
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.behavior.engine import BehaviorEngine
|
|
from app.config import Settings
|
|
from app.api.v1.actuators import _deduplicate_actuator_ids
|
|
from app.ha.discovery import DiscoveredEntity
|
|
from app.ha.discovery import discover_entities
|
|
from app.ha.history import (
|
|
EntityHistorySeries,
|
|
LogbookEntry,
|
|
NumericHistoryPoint,
|
|
StateHistorySeries,
|
|
)
|
|
from app.ha.models import HaAutomationSummary, 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
|
|
self.read_entities_calls = 0
|
|
|
|
def read_entities(self) -> list[HaEntitySummary]:
|
|
self.read_entities_calls += 1
|
|
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 read_state_history(
|
|
self,
|
|
entity_ids: list[str],
|
|
start_time: datetime,
|
|
end_time: datetime,
|
|
) -> list[StateHistorySeries]:
|
|
return []
|
|
|
|
def read_logbook(
|
|
self,
|
|
entity_id: str,
|
|
start_time: datetime,
|
|
end_time: datetime,
|
|
) -> list[LogbookEntry]:
|
|
return []
|
|
|
|
def call_service(
|
|
self,
|
|
domain: str,
|
|
service: str,
|
|
service_data: dict[str, object],
|
|
) -> list[object]:
|
|
return []
|
|
|
|
def find_automations_for_entity(
|
|
self,
|
|
entity_id: str,
|
|
) -> list[HaAutomationSummary]:
|
|
return []
|
|
|
|
|
|
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",
|
|
),
|
|
HaEntitySummary(
|
|
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
|
domain="sensor",
|
|
device_class="data_size",
|
|
state_class="measurement",
|
|
unit_of_measurement="KiB",
|
|
friendly_name="pfSense Interface VPN inbytes",
|
|
),
|
|
]
|
|
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,
|
|
)
|
|
app.state.behavior_engine = BehaviorEngine(
|
|
ha_reader=app.state.ha_reader,
|
|
store=app.state.actuator_store,
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
def test_actuator_api_configures_reconciles_and_removes(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"
|
|
assert listed.json()[0]["behavior"]["mode"] == "shadow"
|
|
|
|
evaluation = client.post("/v1/actuators/light.abstellkammer/evaluate")
|
|
assert evaluation.status_code == 200
|
|
|
|
premature_activation = client.post(
|
|
"/v1/actuators/light.abstellkammer/activation",
|
|
json={"active": True},
|
|
)
|
|
assert premature_activation.status_code == 409
|
|
|
|
reconciliation = client.post("/v1/actuators/reconciliation/run")
|
|
assert reconciliation.status_code == 200
|
|
assert reconciliation.json()["trained_models"] == 1
|
|
|
|
removed = client.delete("/v1/actuators/light.abstellkammer")
|
|
assert removed.status_code == 204
|
|
assert client.get("/v1/actuators").json() == []
|
|
|
|
|
|
def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
|
|
|
response = client.post(
|
|
"/v1/actuators/light.abstellkammer/assignment",
|
|
json={
|
|
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
|
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
|
"note": "Manuell gesetzt",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["assignment"]["source"] == "manual"
|
|
assert payload["assignment"]["selected_numeric_entity_id"] == (
|
|
"sensor.abstellkammer_illuminance"
|
|
)
|
|
assert payload["assignment"]["selected_context_entity_ids"] == [
|
|
"binary_sensor.abstellkammer_motion"
|
|
]
|
|
|
|
|
|
def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
|
client.post(
|
|
"/v1/actuators/light.abstellkammer/assignment",
|
|
json={
|
|
"numeric_entity_id": "sensor.abstellkammer_illuminance",
|
|
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
|
|
},
|
|
)
|
|
|
|
response = client.post(
|
|
"/v1/actuators/light.abstellkammer/weights",
|
|
json={
|
|
"sensor_weights": {
|
|
"sensor.abstellkammer_illuminance": 0.75,
|
|
"binary_sensor.abstellkammer_motion": 0.5,
|
|
},
|
|
"sensor_weight_groups": [
|
|
{
|
|
"group_id": "abstellkammer_context",
|
|
"name": "Abstellkammer Kontext",
|
|
"entity_ids": [
|
|
"sensor.abstellkammer_illuminance",
|
|
"binary_sensor.abstellkammer_motion",
|
|
],
|
|
"weight": 0.8,
|
|
}
|
|
],
|
|
"note": "Gewichtung korrigiert",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload["manual_override"]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.75
|
|
assert payload["manual_override"]["sensor_weight_groups"][0]["group_id"] == (
|
|
"abstellkammer_context"
|
|
)
|
|
numeric = {
|
|
candidate["entity_id"]: candidate
|
|
for candidate in payload["numeric_candidates"]
|
|
}
|
|
assert numeric["sensor.abstellkammer_illuminance"]["manual_weight"] == 0.75
|
|
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
|
|
|
|
|
|
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
client.get("/v1/actuators/discovery")
|
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
|
|
|
response = client.get("/v1/actuators/summary")
|
|
|
|
assert response.status_code == 200
|
|
payload = response.json()
|
|
assert payload[0]["actuator_entity_id"] == "light.abstellkammer"
|
|
assert payload[0]["friendly_name"] == "Abstellkammer Licht"
|
|
assert payload[0]["area_name"] == "Abstellkammer"
|
|
assert "behavior" not in payload[0]
|
|
assert "numeric_candidates" not in payload[0]
|
|
|
|
|
|
def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
reader = app.state.ha_reader
|
|
client.get("/v1/actuators/discovery")
|
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
|
calls_before = reader.read_entities_calls
|
|
|
|
response = client.get("/v1/actuators/dashboard")
|
|
|
|
assert response.status_code == 200
|
|
assert reader.read_entities_calls == calls_before
|
|
payload = response.json()
|
|
assert payload["cache"]["available"] is True
|
|
assert payload["cache"]["entity_count"] == 4
|
|
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
|
assert payload["discovery_groups"]
|
|
|
|
|
|
def test_dashboard_start_path_stays_within_five_second_budget(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
client.get("/v1/actuators/discovery")
|
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
|
|
|
root_started_at = perf_counter()
|
|
root_response = client.get("/")
|
|
root_elapsed = perf_counter() - root_started_at
|
|
|
|
dashboard_started_at = perf_counter()
|
|
dashboard_response = client.get("/v1/actuators/dashboard")
|
|
dashboard_elapsed = perf_counter() - dashboard_started_at
|
|
|
|
assert root_response.status_code == 200
|
|
assert dashboard_response.status_code == 200
|
|
assert root_elapsed < 5.0
|
|
assert dashboard_elapsed < 5.0
|
|
|
|
|
|
def test_discovery_reads_entities_once_and_reuses_them(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
reader = app.state.ha_reader
|
|
|
|
response = client.get("/v1/actuators/discovery", params={"refresh": True})
|
|
|
|
assert response.status_code == 200
|
|
assert reader.read_entities_calls == 1
|
|
|
|
|
|
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
|
|
with TestClient(app) as client:
|
|
_install_service(tmp_path)
|
|
|
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
|
response = client.get(
|
|
"/v1/actuators/context-options",
|
|
params={"actuator_entity_id": "light.abstellkammer"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
entity_ids = {item["entity_id"] for item in response.json()}
|
|
assert "sensor.abstellkammer_illuminance" in entity_ids
|
|
assert "binary_sensor.abstellkammer_motion" in entity_ids
|
|
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids
|
|
|
|
|
|
def test_actuator_discovery_prefers_light_over_duplicate_switch() -> None:
|
|
entities = {
|
|
"light.schreibtisch": HaEntitySummary(
|
|
entity_id="light.schreibtisch",
|
|
domain="light",
|
|
friendly_name="Schreibtisch Licht",
|
|
device_id="device-1",
|
|
),
|
|
"switch.schreibtisch": HaEntitySummary(
|
|
entity_id="switch.schreibtisch",
|
|
domain="switch",
|
|
friendly_name="Schreibtisch Schalter",
|
|
device_id="device-1",
|
|
),
|
|
"cover.rollladen": HaEntitySummary(
|
|
entity_id="cover.rollladen",
|
|
domain="cover",
|
|
friendly_name="Rollladen",
|
|
device_id="device-2",
|
|
),
|
|
}
|
|
|
|
result = _deduplicate_actuator_ids(
|
|
[
|
|
("switch.schreibtisch", "switch_socket"),
|
|
("light.schreibtisch", "light"),
|
|
("cover.rollladen", "cover_shutter"),
|
|
],
|
|
entities,
|
|
)
|
|
|
|
assert result == ["cover.rollladen", "light.schreibtisch"]
|