M8-T11: add WarmteLink discovery and history API

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent 4884a19e3d
commit a9458394f2
8 changed files with 671 additions and 38 deletions
+270 -3
View File
@@ -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()