M8-T10: add WarmteLink serial worker manager
This commit is contained in:
@@ -27,10 +27,21 @@ from app.services.meter_sources import (
|
|||||||
create_source, delete_source, list_bindings, list_sources, update_binding, update_source,
|
create_source, delete_source, list_bindings, list_sources, update_binding, update_source,
|
||||||
)
|
)
|
||||||
from app.services import timezone as _tz_mod
|
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"])
|
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:
|
def _as_utc(value: datetime) -> datetime:
|
||||||
if value.tzinfo is None:
|
if value.tzinfo is None:
|
||||||
return value.replace(tzinfo=_tz_mod.local_tz()).astimezone(UTC)
|
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)
|
source = create_source(db, name=body.name, kind=body.kind, config=body.config, enabled=body.enabled)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(source)
|
db.refresh(source)
|
||||||
|
_reconcile_warmtelink_after_commit()
|
||||||
return _source_response(source)
|
return _source_response(source)
|
||||||
except (SourceProfileError, MeterSourceError) as exc:
|
except (SourceProfileError, MeterSourceError) as exc:
|
||||||
db.rollback()
|
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)
|
updated = update_source(db, source.id, name=body.name, enabled=body.enabled, config_patch=body.config)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(updated)
|
db.refresh(updated)
|
||||||
|
_reconcile_warmtelink_after_commit()
|
||||||
return _source_response(updated)
|
return _source_response(updated)
|
||||||
except (SourceProfileError, MeterSourceError) as exc:
|
except (SourceProfileError, MeterSourceError) as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -156,6 +169,7 @@ def remove_source(source_uuid: str, db: Session = Depends(get_db),
|
|||||||
try:
|
try:
|
||||||
delete_source(db, source.id)
|
delete_source(db, source.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
_reconcile_warmtelink_after_commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except SourceDeleteRestrictedError as exc:
|
except SourceDeleteRestrictedError as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|||||||
+21
-10
@@ -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.ha_discovery import publish_discovery, publish_states
|
||||||
from app.services.tibber_prices import refresh_prices
|
from app.services.tibber_prices import refresh_prices
|
||||||
from app.services.energy_cost import compute_closed_periods
|
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 app.services.timezone import local_tz
|
||||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
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())
|
_startup_runtime_settings = build_runtime_settings(_startup_session, get_settings())
|
||||||
finally:
|
finally:
|
||||||
_startup_session.close()
|
_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
|
# DSMR sources carry their own runtime configuration and are reconciled
|
||||||
# after the MQTT manager is connected.
|
# after the MQTT manager is connected.
|
||||||
apply_dsmr_subscription()
|
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
|
yield
|
||||||
|
finally:
|
||||||
# MQTT: clean shutdown before the process exits.
|
# Serial descriptors/workers must be handled first on every exit path.
|
||||||
mqtt_manager.disconnect()
|
if serial_started:
|
||||||
|
try:
|
||||||
scheduler.shutdown(wait=False)
|
warmtelink_worker_manager.shutdown()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("WarmteLink shutdown failed")
|
||||||
|
mqtt_manager.disconnect()
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -651,7 +651,7 @@ T01~T06 先把现有 DSMR 安全迁到统一 source/binding;T07~T11 再接
|
|||||||
|
|
||||||
### M8-T10 — Serial worker manager 与应用生命周期 [structural]
|
### M8-T10 — Serial worker manager 与应用生命周期 [structural]
|
||||||
|
|
||||||
- **Status**: `todo`
|
- **Status**: `done`
|
||||||
- **Depends**: M8-T09, M8-T06
|
- **Depends**: M8-T09, M8-T06
|
||||||
- **Context**: 以可测试的 manager 为每个 enabled WarmteLink source 持有一个只读 worker。
|
- **Context**: 以可测试的 manager 为每个 enabled WarmteLink source 持有一个只读 worker。
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,44 @@ def test_source_profiles_and_crud_mask_secrets(auth_database):
|
|||||||
engine.dispose()
|
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):
|
def test_binding_routes_and_atomic_meter_declaration(auth_database):
|
||||||
client, engine = _client(auth_database)
|
client, engine = _client(auth_database)
|
||||||
with client:
|
with client:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user