"""Service for acquiring sweeps from the external Kamil ADC collector. The external `kamil_adc` binary publishes its samples on a PTY/TTY device as a stream of 8-byte frames: * **Start marker**: `0x000A 0xFFFF 0xFFFF 0xFFFF` — delimits sweep boundaries. * **Point frame**: `0x000A step real_i16 imag_i16` — one complex sample per frame, with `step` running 1, 2, …, N for an N-point sweep. The hardware emits sweeps continuously, faster than callers tend to invoke :meth:`KamilAdcService.acquire`. To avoid TTY-buffer overruns and stale data, a daemon thread drains the device end of the TTY non-stop, parses complete sweeps as they arrive, and publishes the **latest** one to a one-slot mailbox. :meth:`acquire` simply waits for the next sweep to appear in that mailbox. Sweep length is determined by the first sweep observed at runtime and stays constant for the life of the service; any later mismatch is treated as a protocol violation rather than something to silently discard. """ from __future__ import annotations from contextlib import suppress from dataclasses import dataclass, field import errno import logging import os from pathlib import Path import select import signal import stat import struct import subprocess import threading import time import numpy as np from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.run_config_model import RadarSweepModel, RunConfigModel logger = logging.getLogger(__name__) # Wire-format constants for the Kamil ADC TTY protocol. KAMIL_ADC_MARKER = 0x000A KAMIL_ADC_START_STEP = 0xFFFF KAMIL_ADC_FRAME_BYTES = 8 _START_FRAME: bytes = struct.pack( " complex: """Parse one 8-byte point frame; validate marker and step ordering.""" marker, step, real, imag = _POINT_STRUCT.unpack(frame) if marker != KAMIL_ADC_MARKER: raise ValueError(f"Kamil ADC marker mismatch: got 0x{marker:04x}, expected 0x000a") if step != expected_step: raise ValueError(f"Kamil ADC step mismatch: got {step}, expected {expected_step}") return complex(real, imag) @dataclass(slots=True) class KamilAdcTtyReader: """Background-thread TTY reader publishing the latest completed sweep. The reader spawns a daemon thread on :meth:`open` which continuously drains the TTY, parses frames into complete sweeps, and stores the most recent one in a single-slot mailbox. Consumers call :meth:`read_sweep` to take that sweep; if a newer one arrives before the consumer reads, it overwrites the previous unread value — by design, since consumers always want the freshest data. """ 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: np.ndarray | None = field(init=False, default=None, repr=False) _reader_error: Exception | None = field(init=False, default=None, repr=False) _locked_points: int | 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._locked_points = 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 self._locked_points = None @property def locked_points(self) -> int | None: """Return the sweep point count established by the first sweep, or `None`.""" return self._locked_points @property def published_count(self) -> int: """Return the 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, ) -> np.ndarray: """Wait for and return the next published sweep. Raises :class:`TimeoutError` if no sweep arrives within `timeout_s`, :class:`RuntimeError` if the external collector process exited, and propagates any exception 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: # Always deliver a pending sweep first: if the reader thread # both published a sweep and then died, the consumer should # still see the good data and only meet 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 self._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" ) # Wake periodically so we can re-check process liveness. self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s)) # ------------------------------------------------------------------ # Reader-thread internals # ------------------------------------------------------------------ def _reader_loop(self) -> None: """Drain the TTY, parse frames, and publish completed sweeps until stop. Runs on the background reader thread. Any exception is logged and stored so the next :meth:`read_sweep` re-raises it on the consumer thread. """ buffer = bytearray() try: if not self._skip_to_first_start_marker(buffer): return while not self._stop_event.is_set(): sweep = self._read_one_sweep(buffer) if sweep is None: return 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 _skip_to_first_start_marker(self, buffer: bytearray) -> bool: """Discard pre-roll bytes until a start marker is consumed from `buffer`.""" while not self._stop_event.is_set(): start_index = buffer.find(_START_FRAME) if start_index >= 0: del buffer[: start_index + KAMIL_ADC_FRAME_BYTES] return True # Keep just enough trailing bytes that a marker split across read # boundaries can still be reassembled on the next chunk. if len(buffer) >= KAMIL_ADC_FRAME_BYTES: del buffer[: -(KAMIL_ADC_FRAME_BYTES - 1)] if not self._read_more(buffer): return False return False def _read_one_sweep(self, buffer: bytearray) -> np.ndarray | None: """Parse frames from `buffer` until the next start marker; return the sweep.""" values: list[complex] = [] expected_step = 1 while not self._stop_event.is_set(): while len(buffer) < KAMIL_ADC_FRAME_BYTES: if not self._read_more(buffer): return None frame = bytes(buffer[:KAMIL_ADC_FRAME_BYTES]) del buffer[:KAMIL_ADC_FRAME_BYTES] if frame == _START_FRAME: if not values: # Two consecutive markers — ignore the empty sweep and keep parsing. continue self._validate_and_lock_point_count(len(values)) return np.asarray(values, dtype=np.complex64) if self._locked_points is not None and expected_step > self._locked_points: raise RuntimeError( f"Kamil ADC sweep exceeded locked point count {self._locked_points} " "without a start marker" ) values.append(_parse_point_frame(frame, expected_step)) expected_step += 1 return None def _validate_and_lock_point_count(self, points: int) -> None: """Lock the point count on the first sweep; reject mismatches thereafter.""" if self._locked_points is None: self._locked_points = points logger.info("Kamil ADC sweep point count locked to %d", points) return if points != self._locked_points: raise RuntimeError( f"Kamil ADC sweep length changed: locked={self._locked_points}, got={points}" ) def _read_more(self, buffer: bytearray) -> bool: """Block on `select` until bytes arrive, then append them to `buffer`. Returns `False` if the reader was asked to stop, `True` if at least one byte was appended. Raises on stream-level errors. """ fd = self._fd if fd is None: return False while not self._stop_event.is_set(): try: readable, _, _ = select.select([fd], [], [], _READ_POLL_INTERVAL_S) except InterruptedError: continue if not readable: continue try: chunk = os.read(fd, _READ_CHUNK_BYTES) except BlockingIOError: continue except OSError as exc: if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}: continue 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") buffer.extend(chunk) return True return False def _publish_sweep(self, sweep: np.ndarray) -> None: """Store `sweep` as the latest mailbox value, overwriting any prior 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() @staticmethod def _raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None: if process is None: return return_code = process.poll() if return_code is not None: raise RuntimeError(f"Kamil ADC process exited with code {return_code}") @dataclass(slots=True) class KamilAdcService: """Launch the external `kamil_adc` collector and serve its sweeps.""" config: RunConfigModel _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False) _reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False) _settings: RadarSweepModel | None = field(init=False, default=None, repr=False) _frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False) def __post_init__(self) -> None: self._validate_config() @property def command(self) -> list[str]: """Return external collector command including the generated TTY argument.""" adc = self.config.radar.kamil_adc executable_path = str(Path(adc.executable_path).expanduser()) return [executable_path, *adc.args, f"tty:{adc.tty_path}"] def open(self, *, stop_event: threading.Event | None = None) -> None: """Launch the collector and start the TTY reader thread. An optional `stop_event` lets a caller abort the TTY-wait loop promptly (e.g. on shutdown) instead of blocking for the full startup timeout. """ if self._reader is not None: return previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path) try: self._start_process() self._wait_for_tty(previous_tty_identity, stop_event=stop_event) reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path) reader.open() self._reader = reader logger.info("Kamil ADC service opened") except Exception: logger.exception("Kamil ADC service failed to open; cleaning up") self.close() raise def close(self) -> None: """Stop the TTY reader and the external collector process.""" logger.debug("Closing Kamil ADC service") if self._reader is not None: with suppress(Exception): self._reader.close() self._reader = None self._stop_process() def configure(self, sweep: RadarSweepModel) -> None: """Store sweep settings used to build the synthetic frequency axis.""" self._validate_sweep(sweep) self._settings = sweep self._frequency_hz = None logger.debug( "Kamil ADC configured: frequency axis %s-%s Hz", sweep.start_hz, sweep.stop_hz ) def read_device_limits(self) -> dict[str, float | int]: """Kamil ADC has no runtime-readable sweep limit API.""" raise RuntimeError("Kamil ADC device limits are not available") def acquire(self) -> SweepResult: """Return the most recent completed sweep as S21 (S11 filled with zeros).""" if self._settings is None: raise RuntimeError("Kamil ADC service is not configured") if self._reader is None: raise RuntimeError("Kamil ADC service is not open") process = self._process if process is None or process.poll() is not None: return_code = None if process is None else process.poll() raise RuntimeError(f"Kamil ADC process is not running (code={return_code})") s21 = self._reader.read_sweep( timeout_s=self.config.radar.kamil_adc.sweep_timeout_s, process=process, ) points = int(s21.size) if self._frequency_hz is None or self._frequency_hz.size != points: logger.debug("Building Kamil ADC frequency axis for %d points", points) self._frequency_hz = self._build_frequency_axis(points) return SweepResult( x=self._frequency_hz.copy(), traces={ "s11": np.zeros(points, dtype=np.complex64), "s21": s21, }, ) # ------------------------------------------------------------------ # Process / TTY lifecycle # ------------------------------------------------------------------ def _start_process(self) -> None: if self._process is not None and self._process.poll() is None: return adc = self.config.radar.kamil_adc env = os.environ.copy() env.update(adc.env) logger.info("Starting Kamil ADC collector: %s", " ".join(self.command)) self._process = subprocess.Popen( self.command, cwd=str(Path(adc.project_dir).expanduser()), env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, start_new_session=True, ) def _stop_process(self) -> None: process = self._process self._process = None if process is None or process.poll() is not None: return logger.info("Stopping Kamil ADC collector (pid=%d)", process.pid) with suppress(ProcessLookupError): os.killpg(process.pid, signal.SIGTERM) try: process.wait(timeout=self.config.radar.kamil_adc.stop_timeout_s) return except subprocess.TimeoutExpired: pass logger.warning( "Kamil ADC collector (pid=%d) ignored SIGTERM; sending SIGKILL", process.pid ) with suppress(ProcessLookupError): os.killpg(process.pid, signal.SIGKILL) process.wait(timeout=1.0) def _wait_for_tty( self, previous_identity: tuple[object, ...] | None, *, stop_event: threading.Event | None = None, ) -> None: adc = self.config.radar.kamil_adc deadline = time.monotonic() + adc.startup_timeout_s while time.monotonic() < deadline: # Abort promptly if a stop was requested mid-wait. if stop_event is not None and stop_event.is_set(): raise RuntimeError("Kamil ADC startup aborted before TTY became available") KamilAdcTtyReader._raise_if_process_exited(self._process) identity = _tty_identity(adc.tty_path) if identity is not None and identity != previous_identity: return # Use the stop event's wait() so a set() breaks the poll immediately. if stop_event is not None: if stop_event.wait(0.05): raise RuntimeError("Kamil ADC startup aborted before TTY became available") else: time.sleep(0.05) raise TimeoutError( f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector" ) # ------------------------------------------------------------------ # Validation helpers # ------------------------------------------------------------------ def _validate_config(self) -> None: if not self.config.is_kamil_adc: raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'") if self.config.radar.driver_mode != "native": raise RuntimeError("Kamil ADC requires radar.driver_mode='native'") adc = self.config.radar.kamil_adc if not adc.project_dir: raise ValueError("radar.kamil_adc.project_dir is required") if not adc.executable_path: raise ValueError("radar.kamil_adc.executable_path is required") if not adc.tty_path: raise ValueError("radar.kamil_adc.tty_path is required") if any(arg.startswith("tty:") for arg in adc.args): raise ValueError("radar.kamil_adc.args must not contain tty:; use tty_path instead") if adc.startup_timeout_s <= 0.0: raise ValueError("radar.kamil_adc.startup_timeout_s must be > 0") if adc.sweep_timeout_s <= 0.0: raise ValueError("radar.kamil_adc.sweep_timeout_s must be > 0") if adc.stop_timeout_s <= 0.0: raise ValueError("radar.kamil_adc.stop_timeout_s must be > 0") project_dir = Path(adc.project_dir).expanduser() if not project_dir.is_dir(): raise RuntimeError(f"radar.kamil_adc.project_dir is not a directory: {project_dir}") executable_path = Path(adc.executable_path).expanduser() if not executable_path.is_file(): raise RuntimeError(f"radar.kamil_adc.executable_path is not a file: {executable_path}") if not os.access(executable_path, os.X_OK): raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}") @staticmethod def _validate_sweep(sweep: RadarSweepModel) -> None: if float(sweep.stop_hz) < float(sweep.start_hz): raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz") def _build_frequency_axis(self, points: int) -> np.ndarray: if self._settings is None: raise RuntimeError("Kamil ADC service is not configured") return np.linspace( float(self._settings.start_hz), float(self._settings.stop_hz), int(points), dtype=np.float32, ) def _tty_identity(path: str) -> tuple[object, ...] | None: try: if os.path.islink(path): stat_result = os.lstat(path) return ( "link", os.readlink(path), int(stat_result.st_dev), int(stat_result.st_ino), int(stat_result.st_mtime_ns), ) stat_result = os.stat(path) except FileNotFoundError: return None return ( "node", int(stat_result.st_dev), int(stat_result.st_ino), int(stat_result.st_mtime_ns), ) def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None: """Remove stale generated TTY links before starting the external collector.""" try: stat_result = os.lstat(path) except FileNotFoundError: return None if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode): os.unlink(path) return None return None def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool: """Apply the configured laser settings through the legacy device_main command sequence. Connects to the laser controller, resets it, and applies either manual or variation mode per ``radar.laser_control``. Returns `True` when settings were applied, `False` when laser control is disabled. The controller is always disconnected before returning. """ laser = config.radar.laser_control if not laser.enabled: logger.debug("Kamil ADC laser control disabled; skipping") return False _validate_laser_control_config(config) from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController from python_app.hardware_full.laser_control.models import VariationType controller = LaserController( port=laser.port, pi_coeff1_p=laser.pi_coeff1_p, pi_coeff1_i=laser.pi_coeff1_i, pi_coeff2_p=laser.pi_coeff2_p, pi_coeff2_i=laser.pi_coeff2_i, ) try: controller.connect() controller.reset() mode = laser.mode.strip().lower() logger.info("Applying Kamil ADC laser control in %s mode", mode) if mode == "manual": manual = laser.manual controller.set_manual_mode( temp1=manual.temp1, temp2=manual.temp2, current1=manual.current1, current2=manual.current2, message_id=DEVICE_MAIN_MESSAGE_ID, ) return True if mode == "variation": variation = laser.variation try: variation_type = VariationType[variation.variation_type] except KeyError as exc: raise ValueError( f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}" ) from exc controller.set_manual_mode( temp1=variation.static_temp1, temp2=variation.static_temp2, current1=variation.static_current1, current2=variation.static_current2, message_id=DEVICE_MAIN_MESSAGE_ID, ) controller.start_variation( variation_type=variation_type, params={ "static_temp1": variation.static_temp1, "static_temp2": variation.static_temp2, "static_current1": variation.static_current1, "static_current2": variation.static_current2, "min_value": variation.min_value, "max_value": variation.max_value, "step": variation.step, "time_step": variation.time_step, "delay_time": variation.delay_time, }, ) return True raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}") finally: controller.disconnect() def _validate_laser_control_config(config: RunConfigModel) -> None: laser = config.radar.laser_control if not laser.port: raise ValueError("radar.laser_control.port is required when laser_control is enabled") mode = laser.mode.strip().lower() if mode not in {"manual", "variation"}: raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'") if mode == "variation" and not laser.variation.variation_type: raise ValueError("radar.laser_control.variation.variation_type is required")