"""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()