"""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 Empty, 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_ingest import WarmteLinkIngestor 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_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() 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 = [] runtime_settings = object() applied_dsmr_settings = [] 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: runtime_settings) def apply_dsmr_subscription(settings=runtime_settings): assert settings is runtime_settings applied_dsmr_settings.append(settings) monkeypatch.setattr(main, "apply_dsmr_subscription", apply_dsmr_subscription) 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 applied_dsmr_settings == [runtime_settings] 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