241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""Background TTY reader publishing the latest completed Kamil ADC sweep.
|
|
|
|
The collector emits sweeps continuously, faster than callers invoke
|
|
:meth:`KamilAdcService.acquire`. A daemon thread drains the device end of the
|
|
TTY non-stop, feeds the bytes to a :class:`KamilAdcStreamParser`, and stores the
|
|
most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep`
|
|
returns the freshest sweep; if a newer one arrives before the consumer reads, it
|
|
overwrites the previous unread value — by design, since consumers always want the
|
|
latest data. Parser/stream errors are captured and re-raised on the consumer
|
|
thread (fail-fast; the supervisor relaunches a clean collector).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import errno
|
|
import logging
|
|
import os
|
|
import select
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
from python_app.hardware_full.kamil_adc.protocol import KamilAdcStreamParser, RawSweep
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Large reads keep up with bursty CDC-ACM/PTY writers without raising the syscall
|
|
# rate; 64 KiB matches the typical Linux PTY buffer size.
|
|
_READ_CHUNK_BYTES = 65536
|
|
# select() poll interval — short enough to react to close() promptly, long enough
|
|
# that idle CPU stays near zero.
|
|
_READ_POLL_INTERVAL_S = 0.1
|
|
|
|
|
|
def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
|
"""Raise if the external collector process has exited."""
|
|
if process is None:
|
|
return
|
|
return_code = process.poll()
|
|
if return_code is not None:
|
|
raise RuntimeError(f"Kamil ADC collector exited with code {return_code}")
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class KamilAdcTtyReader:
|
|
"""Daemon-thread TTY reader publishing the latest completed sweep."""
|
|
|
|
tty_path: str
|
|
_fd: int | None = field(init=False, default=None, repr=False)
|
|
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
|
|
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
|
_mailbox_cv: threading.Condition = field(init=False, default_factory=threading.Condition, repr=False)
|
|
_latest_sweep: RawSweep | None = field(init=False, default=None, repr=False)
|
|
# Latest clean sweep per switch combination, for the switch-aware collector.
|
|
# read_sweep() ignores this and serves the single newest sweep (calibration /
|
|
# non-switch mode); read_sweep_for() serves a specific combination.
|
|
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
|
|
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
|
_published_count: int = field(init=False, default=0, repr=False)
|
|
|
|
def open(self) -> None:
|
|
"""Open the TTY and start the background reader thread."""
|
|
if self._fd is not None:
|
|
return
|
|
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
|
self._stop_event.clear()
|
|
self._latest_sweep = None
|
|
self._combo_slots = {}
|
|
self._reader_error = None
|
|
self._published_count = 0
|
|
self._thread = threading.Thread(
|
|
target=self._reader_loop,
|
|
name=f"kamil-adc-tty-reader[{self.tty_path}]",
|
|
daemon=True,
|
|
)
|
|
self._thread.start()
|
|
logger.info("Kamil ADC TTY reader started on %s", self.tty_path)
|
|
|
|
def close(self) -> None:
|
|
"""Stop the reader thread and close the TTY descriptor."""
|
|
logger.debug("Stopping Kamil ADC TTY reader on %s", self.tty_path)
|
|
self._stop_event.set()
|
|
with self._mailbox_cv:
|
|
self._mailbox_cv.notify_all()
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=1.0)
|
|
if self._thread.is_alive():
|
|
logger.warning("Kamil ADC reader thread did not stop within 1.0s")
|
|
self._thread = None
|
|
if self._fd is not None:
|
|
try:
|
|
os.close(self._fd)
|
|
finally:
|
|
self._fd = None
|
|
self._latest_sweep = None
|
|
self._combo_slots = {}
|
|
self._reader_error = None
|
|
|
|
@property
|
|
def published_count(self) -> int:
|
|
"""Total number of sweeps the reader thread has produced."""
|
|
with self._mailbox_cv:
|
|
return self._published_count
|
|
|
|
def read_sweep(
|
|
self,
|
|
*,
|
|
timeout_s: float,
|
|
process: subprocess.Popen[bytes] | None = None,
|
|
) -> RawSweep:
|
|
"""Wait for and return the next published sweep.
|
|
|
|
Raises :class:`TimeoutError` if none arrives within ``timeout_s``,
|
|
:class:`RuntimeError` if the collector process exited, and re-raises any
|
|
error caught by the reader thread.
|
|
"""
|
|
if self._thread is None:
|
|
raise RuntimeError("Kamil ADC TTY reader is not open")
|
|
deadline = time.monotonic() + float(timeout_s)
|
|
with self._mailbox_cv:
|
|
while True:
|
|
# Deliver a pending sweep first: if the reader both published a
|
|
# sweep and then died, the consumer still sees the good data and
|
|
# only meets the error on the next call.
|
|
if self._latest_sweep is not None:
|
|
sweep = self._latest_sweep
|
|
self._latest_sweep = None
|
|
return sweep
|
|
if self._reader_error is not None:
|
|
raise self._reader_error
|
|
raise_if_process_exited(process)
|
|
remaining_s = deadline - time.monotonic()
|
|
if remaining_s <= 0.0:
|
|
raise TimeoutError(
|
|
f"Timed out waiting for Kamil ADC sweep after {float(timeout_s):.3f}s"
|
|
)
|
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
|
|
|
def read_sweep_for(
|
|
self,
|
|
combo: tuple[int, int],
|
|
*,
|
|
timeout_s: float,
|
|
process: subprocess.Popen[bytes] | None = None,
|
|
) -> RawSweep:
|
|
"""Wait for and return the latest clean sweep for ``combo``.
|
|
|
|
Used in switch-aware mode, where the collector drives the switches and tags
|
|
each sweep with its combination. Only clean sweeps are delivered (the reader
|
|
thread drops the dirty ones); the slot is consumed on read so each caller
|
|
gets a fresh capture. Raises like :meth:`read_sweep`.
|
|
"""
|
|
if self._thread is None:
|
|
raise RuntimeError("Kamil ADC TTY reader is not open")
|
|
deadline = time.monotonic() + float(timeout_s)
|
|
with self._mailbox_cv:
|
|
while True:
|
|
sweep = self._combo_slots.pop(combo, None)
|
|
if sweep is not None:
|
|
return sweep
|
|
if self._reader_error is not None:
|
|
raise self._reader_error
|
|
raise_if_process_exited(process)
|
|
remaining_s = deadline - time.monotonic()
|
|
if remaining_s <= 0.0:
|
|
raise TimeoutError(
|
|
f"Timed out waiting for Kamil ADC sweep for combo {combo} "
|
|
f"after {float(timeout_s):.3f}s"
|
|
)
|
|
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
|
|
|
# ------------------------------------------------------------------
|
|
# Reader-thread internals
|
|
# ------------------------------------------------------------------
|
|
|
|
def _reader_loop(self) -> None:
|
|
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
|
|
parser = KamilAdcStreamParser()
|
|
try:
|
|
while not self._stop_event.is_set():
|
|
chunk = self._read_available()
|
|
if not chunk:
|
|
continue
|
|
for sweep in parser.feed(chunk):
|
|
self._publish_sweep(sweep)
|
|
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
|
|
logger.exception("Kamil ADC reader thread failed on %s", self.tty_path)
|
|
self._publish_error(exc)
|
|
|
|
def _read_available(self) -> bytes:
|
|
"""Block on ``select`` up to the poll interval; return new bytes (maybe empty).
|
|
|
|
Returns ``b""`` when no data is ready yet or a stop was requested; raises
|
|
:class:`RuntimeError` on stream close or an unrecoverable read error.
|
|
"""
|
|
fd = self._fd
|
|
if fd is None or self._stop_event.is_set():
|
|
return b""
|
|
try:
|
|
readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S)
|
|
except InterruptedError:
|
|
return b""
|
|
if not readable:
|
|
return b""
|
|
try:
|
|
chunk = os.read(fd, _READ_CHUNK_BYTES)
|
|
except BlockingIOError:
|
|
return b""
|
|
except OSError as exc:
|
|
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
|
return b""
|
|
raise RuntimeError(f"Failed to read Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
|
|
if not chunk:
|
|
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while reading")
|
|
return chunk
|
|
|
|
def _publish_sweep(self, sweep: RawSweep) -> None:
|
|
"""Publish a completed sweep to the mailbox(es), waking any waiter.
|
|
|
|
Dirty sweeps (those that straddled a switch transition) are counted but not
|
|
delivered: the collector re-takes that combination on the next sweep. Clean
|
|
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
|
|
only update the single newest-sweep mailbox that read_sweep() serves.
|
|
"""
|
|
with self._mailbox_cv:
|
|
self._published_count += 1
|
|
if sweep.dirty:
|
|
self._mailbox_cv.notify_all()
|
|
return
|
|
self._latest_sweep = sweep
|
|
if sweep.combo is not None:
|
|
self._combo_slots[sweep.combo] = sweep
|
|
self._mailbox_cv.notify_all()
|
|
|
|
def _publish_error(self, exc: Exception) -> None:
|
|
"""Record ``exc`` as the reader fault and wake any waiter."""
|
|
with self._mailbox_cv:
|
|
self._reader_error = exc
|
|
self._mailbox_cv.notify_all()
|