M8-T11: add WarmteLink discovery and history API
This commit is contained in:
@@ -8,8 +8,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
import threading
|
||||
from typing import Protocol
|
||||
@@ -27,6 +27,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_BACKOFF_SECONDS = (1, 2, 4, 8, 16, 32, 60)
|
||||
_JOIN_TIMEOUT_SECONDS = 5
|
||||
_DISCOVERY_LOCK_TIMEOUT_SECONDS = 0.05
|
||||
_DISCOVERY_WAIT_SECONDS = 0.1
|
||||
_DISCOVERY_TIMEOUT_SECONDS = 5
|
||||
|
||||
|
||||
class ReadOnlySerial(Protocol):
|
||||
@@ -69,6 +72,18 @@ class _WorkerConfig:
|
||||
config: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveryRequest:
|
||||
"""One source-scoped request, completed only by its serial owner."""
|
||||
|
||||
request_id: int
|
||||
source_id: int
|
||||
deadline: datetime
|
||||
status: str = "pending"
|
||||
detail: str | None = None
|
||||
completed: threading.Event = field(default_factory=threading.Event)
|
||||
|
||||
|
||||
class WarmteLinkWorker:
|
||||
"""One interruptible, read-only serial loop for one meter source."""
|
||||
|
||||
@@ -88,6 +103,8 @@ class WarmteLinkWorker:
|
||||
self._clock = clock or _EventClock()
|
||||
self._serial: ReadOnlySerial | None = None
|
||||
self._serial_lock = threading.Lock()
|
||||
self._discovery_lock = threading.Lock()
|
||||
self._discoveries: list[DiscoveryRequest] = []
|
||||
# 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(
|
||||
@@ -105,6 +122,35 @@ class WarmteLinkWorker:
|
||||
self._stop_event.set()
|
||||
self._close_serial()
|
||||
|
||||
def request_discovery(self, request: DiscoveryRequest) -> None:
|
||||
"""Queue a read request; this worker remains the sole serial owner."""
|
||||
with self._discovery_lock:
|
||||
self._discoveries.append(request)
|
||||
|
||||
def _finish_discoveries(self, status: str, detail: str | None = None) -> None:
|
||||
now = datetime.now(UTC)
|
||||
with self._discovery_lock:
|
||||
pending, self._discoveries = self._discoveries, []
|
||||
for request in pending:
|
||||
if request.completed.is_set():
|
||||
continue
|
||||
if request.deadline <= now and status == "completed":
|
||||
request.status, request.detail = "error", "Discovery timed out."
|
||||
else:
|
||||
request.status, request.detail = status, detail
|
||||
request.completed.set()
|
||||
|
||||
def _expire_discoveries(self) -> None:
|
||||
now = datetime.now(UTC)
|
||||
with self._discovery_lock:
|
||||
expired = [request for request in self._discoveries if request.deadline <= now]
|
||||
self._discoveries = [request for request in self._discoveries if request.deadline > now]
|
||||
for request in expired:
|
||||
if request.completed.is_set():
|
||||
continue
|
||||
request.status, request.detail = "error", "Discovery timed out."
|
||||
request.completed.set()
|
||||
|
||||
def join(self, timeout: float = _JOIN_TIMEOUT_SECONDS) -> bool:
|
||||
self._thread.join(timeout)
|
||||
return not self._thread.is_alive()
|
||||
@@ -142,6 +188,7 @@ class WarmteLinkWorker:
|
||||
return
|
||||
self._serial = device
|
||||
while not self._stop_event.is_set():
|
||||
self._expire_discoveries()
|
||||
chunk = device.read(1024)
|
||||
if not chunk:
|
||||
# ``timeout`` reads are normal, but still yield so a bad
|
||||
@@ -152,13 +199,16 @@ class WarmteLinkWorker:
|
||||
for frame in frames:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
self._ingestor.handle_frame(
|
||||
admitted = self._ingestor.handle_frame(
|
||||
self.source_id, frame, session_factory=self._session_factory
|
||||
)
|
||||
if admitted:
|
||||
self._finish_discoveries("completed")
|
||||
# A complete frame proves transport recovery even if its
|
||||
# contents are rejected by the privacy/admission layer.
|
||||
backoff_index = 0
|
||||
except Exception:
|
||||
self._finish_discoveries("error", "WarmteLink discovery failed.")
|
||||
self._record_error("WarmteLink serial connection failed")
|
||||
delay = _BACKOFF_SECONDS[min(backoff_index, len(_BACKOFF_SECONDS) - 1)]
|
||||
backoff_index += 1
|
||||
@@ -181,6 +231,7 @@ class WarmteLinkWorkerManager:
|
||||
self._workers: dict[int, tuple[_WorkerConfig, WarmteLinkWorker]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._reapers: set[int] = set()
|
||||
self._next_discovery_id = 0
|
||||
self._shutting_down = False
|
||||
|
||||
@property
|
||||
@@ -203,6 +254,48 @@ class WarmteLinkWorkerManager:
|
||||
self._shutting_down = False
|
||||
self.reconcile()
|
||||
|
||||
def request_discovery(self, source_id: int) -> DiscoveryRequest:
|
||||
"""Ask the current source worker for one bounded read/discovery attempt.
|
||||
|
||||
This intentionally does not reconcile or open a descriptor. Lifecycle
|
||||
convergence remains separate; a request can neither replace nor stop a
|
||||
worker when an HTTP client times out or disconnects.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
request = DiscoveryRequest(0, source_id, now)
|
||||
if not self._lock.acquire(timeout=_DISCOVERY_LOCK_TIMEOUT_SECONDS):
|
||||
request.status, request.detail = "error", "Discovery queue is busy."
|
||||
request.completed.set()
|
||||
return request
|
||||
try:
|
||||
self._next_discovery_id += 1
|
||||
request.request_id = self._next_discovery_id
|
||||
request.deadline = now + timedelta(seconds=_DISCOVERY_TIMEOUT_SECONDS)
|
||||
if self._shutting_down:
|
||||
request.status, request.detail = "error", "WarmteLink manager is stopped."
|
||||
request.completed.set()
|
||||
elif (entry := self._workers.get(source_id)) is None:
|
||||
request.status, request.detail = "error", "WarmteLink worker is not running."
|
||||
request.completed.set()
|
||||
else:
|
||||
entry[1].request_discovery(request)
|
||||
timer = threading.Timer(_DISCOVERY_TIMEOUT_SECONDS, self._timeout_discovery, args=(request,))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
finally:
|
||||
self._lock.release()
|
||||
# A tiny bounded wait makes an immediately available frame observable,
|
||||
# without turning an HTTP call into serial I/O or an unbounded wait.
|
||||
request.completed.wait(_DISCOVERY_WAIT_SECONDS)
|
||||
return request
|
||||
|
||||
@staticmethod
|
||||
def _timeout_discovery(request: DiscoveryRequest) -> None:
|
||||
"""Resolve a stale HTTP request without touching its healthy worker."""
|
||||
if not request.completed.is_set():
|
||||
request.status, request.detail = "error", "Discovery timed out."
|
||||
request.completed.set()
|
||||
|
||||
def _read_desired(self) -> dict[int, _WorkerConfig]:
|
||||
with self._session_factory() as session:
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user