M8-R05: recover WarmteLink ingestion and discovery automatically
This commit is contained in:
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import time
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from queue import Queue
|
||||
from queue import Empty, Queue
|
||||
|
||||
import anyio
|
||||
from fastapi import FastAPI
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db import Base
|
||||
from app.models.meter_source import MeterSource
|
||||
from app.services.warmtelink_ingest import WarmteLinkIngestor
|
||||
from app.services.warmtelink_worker import WarmteLinkWorker, WarmteLinkWorkerManager, open_warmtelink_serial
|
||||
|
||||
|
||||
@@ -156,6 +157,128 @@ def test_worker_resets_backoff_after_failures_then_a_complete_frame():
|
||||
assert clock.delays[:3] == [1, 2, 1]
|
||||
|
||||
|
||||
def test_worker_discards_partial_frame_when_a_new_serial_descriptor_reconnects():
|
||||
"""A new device must not complete bytes buffered from its predecessor."""
|
||||
class _PartialThenDisconnect:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.calls = 0
|
||||
|
||||
def read(self, _size=1):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return b"/stale-partial"
|
||||
raise OSError("disconnected")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class _FreshDevice:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.calls = 0
|
||||
|
||||
def read(self, _size=1):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return b"/fresh\r\n!\r\n"
|
||||
raise OSError("disconnected")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
stale, fresh = _PartialThenDisconnect(), _FreshDevice()
|
||||
clock, ingestor = _Clock(), _Ingestor()
|
||||
devices = iter([stale, fresh, _BrokenSerial()])
|
||||
worker = WarmteLinkWorker(
|
||||
3, {"path": "/dev/private"}, session_factory=lambda: None,
|
||||
serial_factory=lambda _config: next(devices), ingestor=ingestor, clock=clock,
|
||||
)
|
||||
worker.start()
|
||||
deadline = time.monotonic() + 1
|
||||
while not ingestor.frames and time.monotonic() < deadline:
|
||||
time.sleep(0.005)
|
||||
worker.stop()
|
||||
assert worker.join(1)
|
||||
assert ingestor.frames == [(3, b"/fresh\r\n!\r\n")]
|
||||
assert stale.closed and fresh.closed
|
||||
assert clock.delays[:2] == [1, 1]
|
||||
|
||||
|
||||
def test_worker_recovers_from_initial_open_failure_and_ingests_without_discovery(tmp_path):
|
||||
"""Startup retries independently of a manual discovery request."""
|
||||
from app.integrations.p1 import dsmr_crc16
|
||||
from app.models.meter_source import MeterSourceChannel
|
||||
|
||||
factory, engine = _session_factory(tmp_path)
|
||||
received_at = datetime(2026, 8, 22, 10, 0, 30, tzinfo=UTC)
|
||||
with factory() as session:
|
||||
source = session.query(MeterSource).filter_by(name="one").one()
|
||||
source_id = source.id
|
||||
session.query(MeterSource).filter_by(name="two").one().enabled = False
|
||||
session.commit()
|
||||
|
||||
class _QueueSerial:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.frames: Queue[bytes] = Queue()
|
||||
|
||||
def read(self, _size=1):
|
||||
try:
|
||||
return self.frames.get(timeout=0.01)
|
||||
except Empty:
|
||||
return b""
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def frame() -> bytes:
|
||||
body = (
|
||||
b"/WARMTE\r\n"
|
||||
b"0-0:1.0.0(260822120000S)\r\n"
|
||||
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"
|
||||
b"0-1:24.2.1(260822120000S)(5.900*m3)\r\n"
|
||||
b"0-2:24.1.0(012)\r\n"
|
||||
b"0-2:96.1.0(REDACTED)\r\n"
|
||||
b"0-2:24.2.1(260822120000S)(0.017*GJ)\r\n"
|
||||
)
|
||||
payload = body + b"!"
|
||||
return payload + f"{dsmr_crc16(payload):04X}".encode() + b"\r\n"
|
||||
|
||||
serial, clock = _QueueSerial(), _Clock()
|
||||
attempts = 0
|
||||
|
||||
def open_after_one_failure(_config):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
raise OSError("unavailable")
|
||||
return serial
|
||||
|
||||
worker = WarmteLinkWorker(
|
||||
source_id, {"path": "/dev/fake"}, session_factory=factory,
|
||||
serial_factory=open_after_one_failure, ingestor=WarmteLinkIngestor(clock=lambda: received_at), clock=clock,
|
||||
)
|
||||
worker.start()
|
||||
serial.frames.put(frame())
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
with factory() as session:
|
||||
source = session.get(MeterSource, source_id)
|
||||
if source is not None and source.status == "online":
|
||||
assert session.query(MeterSourceChannel).filter_by(source_id=source_id).count() == 2
|
||||
break
|
||||
time.sleep(0.005)
|
||||
else:
|
||||
raise AssertionError("worker did not recover and ingest its startup frame")
|
||||
worker.stop()
|
||||
assert worker.join(1)
|
||||
assert attempts >= 2 and clock.delays[0] == 1 and serial.closed
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_worker_is_non_daemon_even_when_constructed_by_a_daemon_parent():
|
||||
result: Queue[bool] = Queue()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user