M8-T10: add WarmteLink serial worker manager

This commit is contained in:
2026-08-23 21:22:06 +02:00
parent afe653bafa
commit 4884a19e3d
6 changed files with 721 additions and 11 deletions
+14
View File
@@ -27,10 +27,21 @@ from app.services.meter_sources import (
create_source, delete_source, list_bindings, list_sources, update_binding, update_source,
)
from app.services import timezone as _tz_mod
from app.services.warmtelink_worker import warmtelink_worker_manager
router = APIRouter(prefix="/api/energy", tags=["api-energy-meter-sources"])
def _reconcile_warmtelink_after_commit() -> None:
"""Runtime convergence is best-effort; the already committed API result wins."""
try:
warmtelink_worker_manager.reconcile()
except Exception:
# The manager records individual source failures itself. Do not turn a
# successful durable create/update/delete into a misleading HTTP 500.
return
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=_tz_mod.local_tz()).astimezone(UTC)
@@ -118,6 +129,7 @@ def post_source(body: MeterSourceCreate, db: Session = Depends(get_db),
source = create_source(db, name=body.name, kind=body.kind, config=body.config, enabled=body.enabled)
db.commit()
db.refresh(source)
_reconcile_warmtelink_after_commit()
return _source_response(source)
except (SourceProfileError, MeterSourceError) as exc:
db.rollback()
@@ -138,6 +150,7 @@ def patch_source(source_uuid: str, body: MeterSourcePatch, db: Session = Depends
updated = update_source(db, source.id, name=body.name, enabled=body.enabled, config_patch=body.config)
db.commit()
db.refresh(updated)
_reconcile_warmtelink_after_commit()
return _source_response(updated)
except (SourceProfileError, MeterSourceError) as exc:
db.rollback()
@@ -156,6 +169,7 @@ def remove_source(source_uuid: str, db: Session = Depends(get_db),
try:
delete_source(db, source.id)
db.commit()
_reconcile_warmtelink_after_commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except SourceDeleteRestrictedError as exc:
db.rollback()
+21 -10
View File
@@ -38,6 +38,7 @@ from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SE
from app.services.ha_discovery import publish_discovery, publish_states
from app.services.tibber_prices import refresh_prices
from app.services.energy_cost import compute_closed_periods
from app.services.warmtelink_worker import warmtelink_worker_manager
from app.services.timezone import local_tz
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
@@ -273,18 +274,28 @@ async def lifespan(_: FastAPI):
_startup_runtime_settings = build_runtime_settings(_startup_session, get_settings())
finally:
_startup_session.close()
mqtt_manager.connect(_startup_runtime_settings)
serial_started = False
try:
mqtt_manager.connect(_startup_runtime_settings)
# DSMR sources carry their own runtime configuration and are reconciled
# after the MQTT manager is connected.
apply_dsmr_subscription()
# DSMR sources carry their own runtime configuration and are reconciled
# after the MQTT manager is connected.
apply_dsmr_subscription()
# Mark it before reconcile: a partial reconcile can already own a fd or
# a non-daemon thread and must receive the same orderly shutdown.
serial_started = True
warmtelink_worker_manager.start()
yield
# MQTT: clean shutdown before the process exits.
mqtt_manager.disconnect()
scheduler.shutdown(wait=False)
yield
finally:
# Serial descriptors/workers must be handled first on every exit path.
if serial_started:
try:
warmtelink_worker_manager.shutdown()
except Exception:
logger.exception("WarmteLink shutdown failed")
mqtt_manager.disconnect()
scheduler.shutdown(wait=False)
def create_app() -> FastAPI:
+339
View File
@@ -0,0 +1,339 @@
"""Read-only WarmteLink serial workers and their lifecycle manager.
The worker deliberately owns no long-lived SQLAlchemy session and never
retains telegram bytes after handing a complete frame to the ingestor.
"""
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
import logging
import threading
from typing import Protocol
import serial
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import get_session_local
from app.integrations.p1 import TelegramFramer
from app.models.meter_source import MeterSource
from app.services.warmtelink_ingest import WarmteLinkIngestor
logger = logging.getLogger(__name__)
_BACKOFF_SECONDS = (1, 2, 4, 8, 16, 32, 60)
_JOIN_TIMEOUT_SECONDS = 5
class ReadOnlySerial(Protocol):
def read(self, size: int = 1) -> bytes: ...
def close(self) -> None: ...
SerialFactory = Callable[[dict], ReadOnlySerial]
SessionFactory = Callable[[], Session]
def _default_session_factory() -> Session:
"""Resolve the cached sessionmaker at call time, then open one session."""
return get_session_local()()
class WorkerClock(Protocol):
"""Injectable interruptible clock, keeping retry tests deterministic."""
def wait(self, stop_event: threading.Event, seconds: float) -> bool: ...
class _EventClock:
def wait(self, stop_event: threading.Event, seconds: float) -> bool:
return stop_event.wait(seconds)
def open_warmtelink_serial(config: dict) -> ReadOnlySerial:
"""Open the fixed WarmteLink P1 profile; no write-capable API is exposed."""
return serial.Serial(
port=config["path"], baudrate=115200, bytesize=serial.SEVENBITS,
parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1,
)
@dataclass(frozen=True)
class _WorkerConfig:
source_id: int
config: dict
class WarmteLinkWorker:
"""One interruptible, read-only serial loop for one meter source."""
def __init__(
self, source_id: int, config: dict, *, session_factory: SessionFactory = _default_session_factory,
serial_factory: SerialFactory = open_warmtelink_serial,
stop_event: threading.Event | None = None,
ingestor: WarmteLinkIngestor | None = None,
clock: WorkerClock | None = None,
) -> None:
self.source_id = source_id
self.config = dict(config)
self._session_factory = session_factory
self._serial_factory = serial_factory
self._stop_event = stop_event or threading.Event()
self._ingestor = ingestor or WarmteLinkIngestor()
self._clock = clock or _EventClock()
self._serial: ReadOnlySerial | None = None
self._serial_lock = threading.Lock()
# Never inherit a daemon flag from a caller's background thread: a serial
# descriptor and its orderly shutdown must remain visible to the process.
self._thread = threading.Thread(
target=self._run, name=f"warmtelink-{source_id}", daemon=False
)
@property
def thread(self) -> threading.Thread:
return self._thread
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._close_serial()
def join(self, timeout: float = _JOIN_TIMEOUT_SECONDS) -> bool:
self._thread.join(timeout)
return not self._thread.is_alive()
def _close_serial(self) -> None:
with self._serial_lock:
device, self._serial = self._serial, None
if device is not None:
with suppress(Exception):
device.close()
def _record_error(self, message: str) -> None:
try:
with self._session_factory() as session:
source = session.get(MeterSource, self.source_id)
if source is not None:
source.status = "error"
source.last_error = message
source.updated_at = datetime.now(UTC)
session.commit()
except Exception:
# A source-status failure must not end another source's worker.
return
def _run(self) -> None:
backoff_index = 0
framer = TelegramFramer()
while not self._stop_event.is_set():
try:
device = self._serial_factory(self.config)
with self._serial_lock:
if self._stop_event.is_set():
with suppress(Exception):
device.close()
return
self._serial = device
while not self._stop_event.is_set():
chunk = device.read(1024)
if not chunk:
# ``timeout`` reads are normal, but still yield so a bad
# fake/device cannot turn an empty read into a busy spin.
self._clock.wait(self._stop_event, 0.05)
continue
frames = framer.feed(chunk)
for frame in frames:
if self._stop_event.is_set():
break
self._ingestor.handle_frame(
self.source_id, frame, session_factory=self._session_factory
)
# A complete frame proves transport recovery even if its
# contents are rejected by the privacy/admission layer.
backoff_index = 0
except Exception:
self._record_error("WarmteLink serial connection failed")
delay = _BACKOFF_SECONDS[min(backoff_index, len(_BACKOFF_SECONDS) - 1)]
backoff_index += 1
self._clock.wait(self._stop_event, delay)
finally:
self._close_serial()
class WarmteLinkWorkerManager:
"""Reconcile enabled serial sources into exactly one worker each."""
def __init__(
self, *, session_factory: SessionFactory = _default_session_factory,
serial_factory: SerialFactory = open_warmtelink_serial,
worker_factory: Callable[..., WarmteLinkWorker] = WarmteLinkWorker,
) -> None:
self._session_factory = session_factory
self._serial_factory = serial_factory
self._worker_factory = worker_factory
self._workers: dict[int, tuple[_WorkerConfig, WarmteLinkWorker]] = {}
self._lock = threading.Lock()
self._reapers: set[int] = set()
self._shutting_down = False
@property
def worker_count(self) -> int:
with self._lock:
return len(self._workers)
def reconcile(self) -> None:
# Reading desired state under the same lock which applies it prevents a
# delayed pre-commit snapshot from rolling a newer commit backwards.
with self._lock:
if self._shutting_down:
return
desired = self._read_desired()
self._reconcile_locked(desired)
def start(self) -> None:
"""Enable reconciliation for a newly entered application lifespan."""
with self._lock:
self._shutting_down = False
self.reconcile()
def _read_desired(self) -> dict[int, _WorkerConfig]:
with self._session_factory() as session:
return {
source.id: _WorkerConfig(source.id, dict(source.config))
for source in session.execute(
select(MeterSource).where(
MeterSource.kind == "warmtelink_serial", MeterSource.enabled.is_(True)
)
).scalars()
}
def _record_manager_error(self, source_id: int) -> None:
"""Best-effort, deliberately non-sensitive lifecycle failure status."""
try:
with self._session_factory() as session:
source = session.get(MeterSource, source_id)
if source is not None:
source.status = "error"
source.last_error = "WarmteLink worker failed"
source.updated_at = datetime.now(UTC)
session.commit()
except Exception:
return
def _reconcile_locked(self, desired: dict[int, _WorkerConfig]) -> None:
stale = [
source_id for source_id, (config, _) in self._workers.items()
if source_id not in desired or desired[source_id] != config
]
blocked: set[int] = set()
for source_id in stale:
_, worker = self._workers[source_id]
try:
worker.stop()
stopped = worker.join()
except Exception:
self._record_manager_error(source_id)
blocked.add(source_id)
continue
if stopped:
self._workers.pop(source_id, None)
else:
logger.error("WarmteLink worker did not stop for source %s", source_id)
blocked.add(source_id)
self._schedule_reaper_locked(source_id, worker)
for source_id, config in desired.items():
if source_id in self._workers or source_id in blocked:
continue
worker: WarmteLinkWorker | None = None
try:
worker = self._worker_factory(
source_id, config.config, session_factory=self._session_factory,
serial_factory=self._serial_factory,
)
self._workers[source_id] = (config, worker)
worker.start()
except Exception:
self._record_manager_error(source_id)
# A failed start normally has no thread. If an unusual worker
# did start before raising, keep it tracked until it is reaped.
if not self._worker_is_alive(worker):
self._workers.pop(source_id, None)
else:
self._schedule_reaper_locked(source_id, worker)
@staticmethod
def _worker_is_alive(worker: object | None) -> bool:
thread = getattr(worker, "thread", None)
return bool(thread is not None and thread.is_alive())
def _schedule_reaper_locked(self, source_id: int, worker: WarmteLinkWorker) -> None:
if source_id in self._reapers:
return
self._reapers.add(source_id)
threading.Thread(
target=self._reap_worker, args=(source_id, worker),
# This bookkeeping watcher must not turn a deliberately bounded
# application shutdown into an unbounded process wait. The actual
# serial worker itself is explicitly non-daemon.
name=f"warmtelink-reaper-{source_id}", daemon=True,
).start()
def _reap_worker(self, source_id: int, worker: WarmteLinkWorker) -> None:
"""Wait for one timed-out worker, then converge without another API call."""
try:
while True:
try:
if worker.join():
break
except Exception:
self._record_manager_error(source_id)
return
# A custom worker can report a bounded join timeout immediately;
# yield before asking again so its reaper cannot busy-spin.
threading.Event().wait(0.05)
with self._lock:
current = self._workers.get(source_id)
if current is not None and current[1] is worker:
self._workers.pop(source_id)
self._reapers.discard(source_id)
should_reconcile = not self._shutting_down
if should_reconcile:
self.reconcile()
finally:
with self._lock:
self._reapers.discard(source_id)
def shutdown(self) -> None:
with self._lock:
self._shutting_down = True
workers = list(self._workers.items())
for source_id, (_, worker) in workers:
try:
worker.stop()
except Exception:
self._record_manager_error(source_id)
for source_id, (_, worker) in workers:
try:
stopped = worker.join()
except Exception:
self._record_manager_error(source_id)
continue
if not stopped:
logger.error("WarmteLink worker did not stop during shutdown for source %s", source_id)
with self._lock:
self._schedule_reaper_locked(source_id, worker)
else:
with self._lock:
current = self._workers.get(source_id)
if current is not None and current[1] is worker:
self._workers.pop(source_id)
warmtelink_worker_manager = WarmteLinkWorkerManager()
+1 -1
View File
@@ -651,7 +651,7 @@ T01T06 先把现有 DSMR 安全迁到统一 source/bindingT07T11 再接
### M8-T10 — Serial worker manager 与应用生命周期 [structural]
- **Status**: `todo`
- **Status**: `done`
- **Depends**: M8-T09, M8-T06
- **Context**: 以可测试的 manager 为每个 enabled WarmteLink source 持有一个只读 worker。
+38
View File
@@ -79,6 +79,44 @@ def test_source_profiles_and_crud_mask_secrets(auth_database):
engine.dispose()
def test_serial_source_reconciles_only_after_commit(auth_database, monkeypatch):
"""Creation and mutation trigger the worker manager after a successful commit."""
from app.api.routes.api import meter_sources
calls = []
monkeypatch.setattr(meter_sources.warmtelink_worker_manager, "reconcile", lambda: calls.append(True))
client, engine = _client(auth_database)
with client:
_login(client)
baseline = len(calls)
response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
"name": "Serial", "kind": "warmtelink_serial", "config": {"path": "/dev/ttyUSB0"},
})
assert response.status_code == 201
source_uuid = response.json()["uuid"]
assert len(calls) == baseline + 1
assert client.patch(f"/api/energy/sources/{source_uuid}", headers={"X-CSRF-Token": _CSRF}, json={"enabled": False}).status_code == 200
assert len(calls) == baseline + 2
engine.dispose()
def test_serial_source_commit_is_not_reported_as_failed_when_reconcile_raises(auth_database, monkeypatch):
from app.api.routes.api import meter_sources
client, engine = _client(auth_database)
with client:
_login(client)
monkeypatch.setattr(
meter_sources.warmtelink_worker_manager, "reconcile",
lambda: (_ for _ in ()).throw(RuntimeError()),
)
response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={
"name": "Serial", "kind": "warmtelink_serial", "config": {"path": "/dev/ttyUSB0"},
})
assert response.status_code == 201
engine.dispose()
def test_binding_routes_and_atomic_meter_declaration(auth_database):
client, engine = _client(auth_database)
with client:
+308
View File
@@ -0,0 +1,308 @@
"""M8-T10 lifecycle tests; all serial I/O is fake and read-only."""
from __future__ import annotations
import time
import threading
from datetime import UTC, datetime
from queue import Queue
import anyio
from fastapi import FastAPI
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import Base
from app.models.meter_source import MeterSource
from app.services.warmtelink_worker import WarmteLinkWorker, WarmteLinkWorkerManager, open_warmtelink_serial
def _session_factory(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'worker.db'}", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime.now(UTC)
with factory() as session:
session.add_all([
MeterSource(name="one", kind="warmtelink_serial", enabled=True, config={"path": "/dev/a"}, created_at=now, updated_at=now),
MeterSource(name="two", kind="warmtelink_serial", enabled=True, config={"path": "/dev/b"}, created_at=now, updated_at=now),
])
session.commit()
return factory, engine
class _FakeWorker:
made: list["_FakeWorker"] = []
def __init__(self, source_id, config, **_kwargs):
self.source_id, self.config, self.started, self.stopped = source_id, config, False, False
self.__class__.made.append(self)
def start(self): self.started = True
def stop(self): self.stopped = True
def join(self, timeout=5): return True
def test_manager_reconciles_n_sources_without_duplicates_and_replaces_config(tmp_path):
factory, engine = _session_factory(tmp_path)
_FakeWorker.made = []
manager = WarmteLinkWorkerManager(session_factory=factory, worker_factory=_FakeWorker)
manager.reconcile()
assert manager.worker_count == 2
assert len(_FakeWorker.made) == 2
manager.reconcile()
assert len(_FakeWorker.made) == 2
with factory() as session:
one = session.query(MeterSource).filter_by(name="one").one()
one.config = {"path": "/dev/new"}
session.query(MeterSource).filter_by(name="two").one().enabled = False
session.commit()
manager.reconcile()
assert manager.worker_count == 1
assert _FakeWorker.made[0].stopped and _FakeWorker.made[1].stopped
assert len(_FakeWorker.made) == 3
manager.shutdown()
assert _FakeWorker.made[-1].stopped
engine.dispose()
class _Clock:
def __init__(self): self.delays: list[float] = []
def wait(self, event, seconds):
self.delays.append(seconds)
return event.wait(0.001)
class _BrokenSerial:
def __init__(self): self.closed = False
def read(self, _size=1): raise OSError("disconnected")
def close(self): self.closed = True
class _Ingestor:
def __init__(self): self.frames = []
def handle_frame(self, source_id, frame, *, session_factory): self.frames.append((source_id, frame))
def test_worker_retries_with_capped_backoff_and_stop_closes_descriptor():
clock = _Clock()
devices: list[_BrokenSerial] = []
worker = WarmteLinkWorker(
7, {"path": "/dev/private"}, session_factory=lambda: None,
serial_factory=lambda _config: devices.append(_BrokenSerial()) or devices[-1], clock=clock,
)
worker.start()
deadline = time.monotonic() + 1
while len(clock.delays) < 8 and time.monotonic() < deadline:
time.sleep(0.005)
worker.stop()
assert worker.join(1)
assert clock.delays[:7] == [1, 2, 4, 8, 16, 32, 60]
assert all(device.closed for device in devices)
def test_worker_frames_are_read_only_and_success_resets_backoff():
class _Device:
def __init__(self):
self.closed = False
self.calls = 0
def read(self, _size=1):
self.calls += 1
if self.calls == 1:
return b"/X\r\n!\r\n"
raise OSError("gone")
def close(self):
self.closed = True
device, clock, ingestor = _Device(), _Clock(), _Ingestor()
worker = WarmteLinkWorker(3, {"path": "/dev/secret"}, session_factory=lambda: None,
serial_factory=lambda _config: device, ingestor=ingestor, clock=clock)
worker.start()
deadline = time.monotonic() + 1
while not clock.delays and time.monotonic() < deadline:
time.sleep(0.005)
worker.stop()
assert worker.join(1)
assert ingestor.frames == [(3, b"/X\r\n!\r\n")]
assert clock.delays[0] == 1
assert not hasattr(device, "write")
assert device.closed
def test_worker_resets_backoff_after_failures_then_a_complete_frame():
class _FrameThenBreak:
def __init__(self): self.calls = 0
def read(self, _size=1):
self.calls += 1
if self.calls == 1:
return b"/X\r\n!\r\n"
raise OSError("gone")
def close(self): pass
clock, ingestor = _Clock(), _Ingestor()
devices = iter([_BrokenSerial(), _BrokenSerial(), _FrameThenBreak(), _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 len(clock.delays) < 3 and time.monotonic() < deadline:
time.sleep(0.005)
worker.stop()
assert worker.join(1)
assert clock.delays[:3] == [1, 2, 1]
def test_worker_is_non_daemon_even_when_constructed_by_a_daemon_parent():
result: Queue[bool] = Queue()
def construct():
worker = WarmteLinkWorker(8, {"path": "/dev/private"}, session_factory=lambda: None)
result.put(worker.thread.daemon)
parent = threading.Thread(target=construct, daemon=True)
parent.start()
parent.join(1)
assert result.get_nowait() is False
class _TimedOutWorker(_FakeWorker):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.live = True
self.join_calls = 0
def join(self, timeout=5):
self.join_calls += 1
return not self.live
def test_timed_out_worker_is_reaped_and_current_desired_config_restarts(tmp_path):
factory, engine = _session_factory(tmp_path)
_TimedOutWorker.made = []
manager = WarmteLinkWorkerManager(session_factory=factory, worker_factory=_TimedOutWorker)
manager.reconcile()
old = _TimedOutWorker.made[0]
with factory() as session:
source = session.query(MeterSource).filter_by(name="one").one()
source.config = {"path": "/dev/new"}
session.commit()
manager.reconcile()
assert len(_TimedOutWorker.made) == 2
assert old.stopped and manager.worker_count == 2
old.live = False
deadline = time.monotonic() + 1
while len(_TimedOutWorker.made) < 3 and time.monotonic() < deadline:
time.sleep(0.005)
assert len(_TimedOutWorker.made) == 3
assert _TimedOutWorker.made[-1].config == {"path": "/dev/new"}
manager.shutdown()
engine.dispose()
def test_manager_contains_lifecycle_failures_and_still_starts_other_sources(tmp_path):
factory, engine = _session_factory(tmp_path)
class _FaultyWorker(_FakeWorker):
def start(self):
if self.source_id == 1:
raise RuntimeError("private path must not escape")
super().start()
_FaultyWorker.made = []
manager = WarmteLinkWorkerManager(session_factory=factory, worker_factory=_FaultyWorker)
manager.reconcile()
assert [worker.source_id for worker in _FaultyWorker.made] == [1, 2]
assert _FaultyWorker.made[1].started
assert manager.worker_count == 1
with factory() as session:
failed = session.get(MeterSource, 1)
assert failed.status == "error"
assert failed.last_error == "WarmteLink worker failed"
manager.shutdown()
engine.dispose()
def test_late_reconcile_snapshot_cannot_undo_a_newer_committed_config(tmp_path, monkeypatch):
factory, engine = _session_factory(tmp_path)
_FakeWorker.made = []
manager = WarmteLinkWorkerManager(session_factory=factory, worker_factory=_FakeWorker)
entered, release = threading.Event(), threading.Event()
original = manager._read_desired
def delayed_read():
result = original()
if not entered.is_set():
entered.set()
assert release.wait(1)
return result
monkeypatch.setattr(manager, "_read_desired", delayed_read)
old_reconcile = threading.Thread(target=manager.reconcile)
old_reconcile.start()
assert entered.wait(1)
with factory() as session:
session.query(MeterSource).filter_by(name="one").one().config = {"path": "/dev/new"}
session.commit()
new_reconcile = threading.Thread(target=manager.reconcile)
new_reconcile.start()
release.set()
old_reconcile.join(1)
new_reconcile.join(1)
assert _FakeWorker.made[-1].config == {"path": "/dev/new"}
manager.shutdown()
engine.dispose()
def test_lifespan_exception_still_shuts_serial_before_other_runtime_teardown(monkeypatch):
import app.main as main
events = []
class _Scheduler:
def __init__(self, **_kwargs): pass
def add_job(self, *_args, **_kwargs): pass
def start(self): events.append("scheduler-start")
def shutdown(self, **_kwargs): events.append("scheduler-stop")
class _Session:
def close(self): events.append("session-close")
class _Manager:
def start(self): events.append("serial-start")
def shutdown(self): events.append("serial-stop-close-join")
monkeypatch.setattr(main, "ensure_runtime_dirs", lambda: None)
monkeypatch.setattr(main, "ensure_auth_db_ready", lambda: None)
monkeypatch.setattr(main, "BackgroundScheduler", _Scheduler)
monkeypatch.setattr(main, "get_session_local", lambda: _Session)
monkeypatch.setattr(main, "build_runtime_settings", lambda *_args: object())
monkeypatch.setattr(main, "apply_dsmr_subscription", lambda: None)
monkeypatch.setattr(main.mqtt_manager, "connect", lambda _settings: events.append("mqtt-start"))
monkeypatch.setattr(main.mqtt_manager, "disconnect", lambda: events.append("mqtt-stop"))
monkeypatch.setattr(main, "warmtelink_worker_manager", _Manager())
async def exercise():
try:
async with main.lifespan(FastAPI()):
raise RuntimeError("body failed")
except RuntimeError:
pass
anyio.run(exercise)
assert events.index("serial-stop-close-join") < events.index("mqtt-stop") < events.index("scheduler-stop")
def test_production_factory_uses_fixed_115200_7n1(monkeypatch):
calls = {}
class _Serial:
def __init__(self, **kwargs): calls.update(kwargs)
def read(self, _size=1): return b""
def close(self): pass
monkeypatch.setattr("app.services.warmtelink_worker.serial.Serial", _Serial)
open_warmtelink_serial({"path": "/dev/ttyUSB0", "baudrate": 1})
assert calls["port"] == "/dev/ttyUSB0"
assert calls["baudrate"] == 115200