new kamil adc
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"""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)
|
||||
_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._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._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))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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:
|
||||
"""Store ``sweep`` as the latest mailbox value, overwriting any unread one."""
|
||||
with self._mailbox_cv:
|
||||
self._latest_sweep = sweep
|
||||
self._published_count += 1
|
||||
self._mailbox_cv.notify()
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user