M8-T11: add WarmteLink discovery and history API
This commit is contained in:
@@ -2,7 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from queue import Empty, Queue
|
||||
from types import SimpleNamespace
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -10,7 +15,7 @@ from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.energy import DsmrReading, Meter
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel, WarmteLinkReading
|
||||
|
||||
_CSRF = "test-csrf-token"
|
||||
|
||||
@@ -212,6 +217,7 @@ def test_source_and_binding_error_contracts_csrf_timezone_and_dsmr_compatibility
|
||||
readings = client.get(f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings")
|
||||
assert readings.status_code == 200
|
||||
assert readings.json()["total"] == 1
|
||||
assert readings.json()["items"] == [{"recorded_at": now.isoformat().replace("+00:00", ""), "value": None, "quality": None}]
|
||||
assert "telegram_id" not in readings.text
|
||||
latest = client.get("/api/energy/dsmr/latest")
|
||||
assert latest.status_code == 200
|
||||
@@ -314,7 +320,9 @@ def test_source_channel_binding_response_contract_and_discover_capabilities(auth
|
||||
assert discovered.status_code == 200
|
||||
assert discovered.json() == {
|
||||
"requested": False, "supported": True, "status": "managed_by_runtime",
|
||||
"request_id": None,
|
||||
"detail": "This source is discovered by its runtime subscription; no connection was opened.",
|
||||
"channels": [],
|
||||
}
|
||||
channels = client.get(f"/api/energy/sources/{source['uuid']}/channels")
|
||||
assert channels.status_code == 200
|
||||
@@ -322,8 +330,9 @@ def test_source_channel_binding_response_contract_and_discover_capabilities(auth
|
||||
assert channel["uuid"] == channel_uuid
|
||||
assert set(channel) == {
|
||||
"uuid", "label", "suggested_commodity", "unit", "device_type", "latest_value",
|
||||
"latest_at", "latest_quality", "binding_count", "bound_meter_ids",
|
||||
"latest_at", "latest_quality", "binding_count", "bound_meter_ids", "binding_summary",
|
||||
}
|
||||
assert channels.json()["source_status"] == "unknown"
|
||||
|
||||
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
||||
@@ -381,3 +390,261 @@ def test_binding_patch_omitted_null_and_adjacent_half_open_boundaries(auth_datab
|
||||
})
|
||||
assert adjacent.status_code == 201
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_warmtelink_discover_and_minute_history_are_bounded_and_private(auth_database, monkeypatch):
|
||||
"""Discover delegates to the manager; readings expose accepted minute samples only."""
|
||||
from app.api.routes.api import meter_sources
|
||||
|
||||
requested: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
meter_sources.warmtelink_worker_manager, "request_discovery",
|
||||
lambda source_id: requested.append(source_id) or SimpleNamespace(
|
||||
status="completed", request_id=1, detail=None, completed=SimpleNamespace(is_set=lambda: False),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(meter_sources.warmtelink_worker_manager, "reconcile", lambda: None)
|
||||
client, engine = _client(auth_database)
|
||||
with client:
|
||||
_login(client)
|
||||
created = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"name": "WarmteLink", "kind": "warmtelink_serial", "config": {"path": "/dev/fake"},
|
||||
})
|
||||
assert created.status_code == 201
|
||||
source_uuid = created.json()["uuid"]
|
||||
now = datetime(2030, 1, 1, 12, 0, 30, tzinfo=UTC)
|
||||
with Session(engine) as session:
|
||||
source = session.execute(select(MeterSource).where(MeterSource.uuid == source_uuid)).scalar_one()
|
||||
source.status = "online"
|
||||
channel = MeterSourceChannel(
|
||||
source_id=source.id, channel_key="heating", label="Heating", unit="GJ",
|
||||
latest_value=Decimal("7.002"), latest_at=now, latest_quality="unverifiable",
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
session.add(channel)
|
||||
session.flush()
|
||||
session.add_all([
|
||||
WarmteLinkReading(
|
||||
channel_id=channel.id, recorded_at=now - timedelta(minutes=1), received_at=now,
|
||||
value=Decimal("7.001"), unit="GJ", quality="unverifiable", equipment_fingerprint="masked",
|
||||
),
|
||||
WarmteLinkReading(
|
||||
channel_id=channel.id, recorded_at=now, received_at=now,
|
||||
value=Decimal("7.002"), unit="GJ", quality="unverifiable", equipment_fingerprint="masked",
|
||||
),
|
||||
])
|
||||
session.commit()
|
||||
channel_uuid = channel.uuid
|
||||
|
||||
discover = client.post(f"/api/energy/sources/{source_uuid}/discover", headers={"X-CSRF-Token": _CSRF})
|
||||
assert discover.status_code == 200
|
||||
assert discover.json()["status"] == "completed"
|
||||
assert requested and "fingerprint" not in discover.text and "channel_key" not in discover.text
|
||||
assert discover.json()["channels"][0]["latest_quality"] == "unverifiable"
|
||||
|
||||
history = client.get(
|
||||
f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings",
|
||||
params={"from": "2030-01-01T11:59:00Z", "to": "2030-01-01T12:01:00Z", "limit": 1},
|
||||
)
|
||||
assert history.status_code == 200
|
||||
assert history.json()["total"] == 1
|
||||
assert history.json()["items"] == [{
|
||||
"recorded_at": "2030-01-01T11:59:30", "value": "7.001", "quality": "unverifiable",
|
||||
}]
|
||||
assert client.get(
|
||||
f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings",
|
||||
params={"from": "2030-01-01T12:01:00Z", "to": "2030-01-01T12:00:00Z"},
|
||||
).status_code == 422
|
||||
assert client.get(
|
||||
f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings", params={"limit": 0}
|
||||
).status_code == 422
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_warmtelink_discover_auth_csrf_disabled_and_source_ownership(auth_database, monkeypatch):
|
||||
from app.api.routes.api import meter_sources
|
||||
|
||||
monkeypatch.setattr(
|
||||
meter_sources.warmtelink_worker_manager, "request_discovery",
|
||||
lambda _source_id: SimpleNamespace(
|
||||
status="pending", request_id=1, detail=None, completed=SimpleNamespace(is_set=lambda: False),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(meter_sources.warmtelink_worker_manager, "reconcile", lambda: None)
|
||||
client, engine = _client(auth_database)
|
||||
with client:
|
||||
serial = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"name": "Serial", "kind": "warmtelink_serial", "enabled": False, "config": {"path": "/dev/fake"},
|
||||
})
|
||||
assert serial.status_code == 401 # no session yet
|
||||
_login(client)
|
||||
serial = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"name": "Serial", "kind": "warmtelink_serial", "enabled": False, "config": {"path": "/dev/fake"},
|
||||
})
|
||||
other = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, other["uuid"])
|
||||
assert client.post(f"/api/energy/sources/{serial.json()['uuid']}/discover").status_code == 403
|
||||
disabled = client.post(
|
||||
f"/api/energy/sources/{serial.json()['uuid']}/discover", headers={"X-CSRF-Token": _CSRF}
|
||||
)
|
||||
assert disabled.status_code == 200 and disabled.json()["status"] == "error"
|
||||
assert client.get(
|
||||
f"/api/energy/sources/{serial.json()['uuid']}/channels/{channel_uuid}/readings"
|
||||
).status_code == 404
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_discovery_manager_is_source_scoped_and_never_replaces_a_worker(auth_database):
|
||||
"""The real manager queues requests on one fake read-only serial owner."""
|
||||
from app.services.warmtelink_worker import WarmteLinkWorkerManager
|
||||
|
||||
engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False})
|
||||
with Session(engine) as session:
|
||||
now = datetime.now(UTC)
|
||||
source = MeterSource(
|
||||
name="Serial", kind="warmtelink_serial", enabled=True, config={"path": "/dev/fake"},
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
session.add(source)
|
||||
session.commit()
|
||||
source_id = source.id
|
||||
|
||||
class FakeReadOnlyWorker:
|
||||
instances: list["FakeReadOnlyWorker"] = []
|
||||
|
||||
def __init__(self, _source_id, _config, **_kwargs):
|
||||
self.requests = []
|
||||
self.thread = SimpleNamespace(is_alive=lambda: True)
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def stop(self):
|
||||
return None
|
||||
|
||||
def join(self, timeout=5):
|
||||
return True
|
||||
|
||||
def request_discovery(self, request):
|
||||
self.requests.append(request)
|
||||
|
||||
manager = WarmteLinkWorkerManager(
|
||||
session_factory=lambda: Session(engine), worker_factory=FakeReadOnlyWorker,
|
||||
)
|
||||
manager.reconcile()
|
||||
assert manager.worker_count == 1
|
||||
first = manager.request_discovery(source_id)
|
||||
assert first.status == "pending"
|
||||
worker = FakeReadOnlyWorker.instances[0]
|
||||
assert len(worker.requests) == 1
|
||||
# A client timing out/cancelling leaves the queued request and worker alone;
|
||||
# completing it later cannot open another descriptor or create bindings.
|
||||
worker.requests[0].status = "completed"
|
||||
worker.requests[0].completed.set()
|
||||
results = []
|
||||
threads = [threading.Thread(target=lambda: results.append(manager.request_discovery(source_id))) for _ in range(2)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
assert manager.worker_count == 1
|
||||
assert len(FakeReadOnlyWorker.instances) == 1
|
||||
assert len(worker.requests) == 3
|
||||
with Session(engine) as session:
|
||||
assert session.query(Meter).count() == 0
|
||||
assert session.query(MeterSourceBinding).count() == 0
|
||||
manager.shutdown()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_real_warmtelink_discovery_waits_for_admission_and_keeps_its_serial_owner(
|
||||
auth_database, monkeypatch,
|
||||
):
|
||||
"""A rejected candidate is neither a discovery success nor exposed metadata."""
|
||||
from app.integrations.p1 import dsmr_crc16
|
||||
from app.services import warmtelink_worker
|
||||
from app.services.warmtelink_worker import WarmteLinkWorkerManager
|
||||
|
||||
engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False})
|
||||
with Session(engine) as session:
|
||||
now = datetime.now(UTC)
|
||||
source = MeterSource(
|
||||
name="Serial", kind="warmtelink_serial", enabled=True, config={"path": "/dev/fake"},
|
||||
created_at=now, updated_at=now,
|
||||
)
|
||||
session.add(source)
|
||||
session.commit()
|
||||
source_id = source.id
|
||||
|
||||
def frame(second: int, *, crc: bool = False) -> bytes:
|
||||
body = (
|
||||
b"/WARMTE\r\n"
|
||||
+ f"0-0:1.0.0(2608221200{second:02d}S)\r\n".encode()
|
||||
+ b"0-0:96.1.1(REDACTED)\r\n"
|
||||
+ b"0-1:24.1.0(006)\r\n"
|
||||
+ b"0-1:96.1.0(REDACTED)\r\n"
|
||||
+ f"0-1:24.2.1(2608221200{second:02d}S)(5.900*m3)\r\n".encode()
|
||||
+ b"0-2:24.1.0(012)\r\n"
|
||||
+ b"0-2:96.1.0(REDACTED)\r\n"
|
||||
+ f"0-2:24.2.1(2608221200{second:02d}S)(0.017*GJ)\r\n".encode()
|
||||
)
|
||||
payload = body + b"!"
|
||||
return payload + (f"{dsmr_crc16(payload):04X}".encode() if crc else b"") + b"\r\n"
|
||||
|
||||
class FakeReadOnlySerial:
|
||||
instances: list["FakeReadOnlySerial"] = []
|
||||
|
||||
def __init__(self):
|
||||
self.frames: Queue[bytes] = Queue()
|
||||
self.closed = False
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def read(self, _size: int = 1) -> bytes:
|
||||
try:
|
||||
return self.frames.get(timeout=0.01)
|
||||
except Empty:
|
||||
return b""
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
monkeypatch.setattr(warmtelink_worker, "_DISCOVERY_TIMEOUT_SECONDS", 0.15)
|
||||
monkeypatch.setattr(warmtelink_worker, "_DISCOVERY_WAIT_SECONDS", 0.02)
|
||||
manager = WarmteLinkWorkerManager(
|
||||
session_factory=lambda: Session(engine), serial_factory=lambda _config: FakeReadOnlySerial(),
|
||||
)
|
||||
manager.reconcile()
|
||||
serial = FakeReadOnlySerial.instances[0]
|
||||
|
||||
request = manager.request_discovery(source_id)
|
||||
assert request.status == "pending"
|
||||
serial.frames.put(frame(0)) # First unverifiable candidate is not admitted.
|
||||
time.sleep(0.04)
|
||||
assert not request.completed.is_set()
|
||||
with Session(engine) as session:
|
||||
assert session.query(MeterSourceChannel).filter_by(source_id=source_id).count() == 0
|
||||
assert session.query(MeterSourceBinding).count() == 0
|
||||
|
||||
serial.frames.put(frame(10)) # Strictly continuous successor admits both channels.
|
||||
assert request.completed.wait(1)
|
||||
assert request.status == "completed"
|
||||
with Session(engine) as session:
|
||||
assert session.query(MeterSourceChannel).filter_by(source_id=source_id).count() == 2
|
||||
assert session.query(MeterSourceBinding).count() == 0
|
||||
|
||||
rejected = manager.request_discovery(source_id)
|
||||
assert rejected.status == "pending"
|
||||
serial.frames.put(b"/malformed!\r\n")
|
||||
assert rejected.completed.wait(1)
|
||||
assert rejected.status == "error" and rejected.detail == "Discovery timed out."
|
||||
assert manager.worker_count == 1 and len(FakeReadOnlySerial.instances) == 1
|
||||
|
||||
recovered = manager.request_discovery(source_id)
|
||||
serial.frames.put(frame(20, crc=True))
|
||||
assert recovered.completed.wait(1)
|
||||
assert recovered.status == "completed"
|
||||
assert manager.worker_count == 1 and len(FakeReadOnlySerial.instances) == 1
|
||||
manager.shutdown()
|
||||
assert serial.closed
|
||||
engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user