"""Service for acquiring sweeps from the external Kamil ADC collector.""" 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 struct import subprocess import time from typing import Any 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__) KAMIL_ADC_MARKER = 0x000A KAMIL_ADC_START_STEP = 0xFFFF KAMIL_ADC_FRAME_BYTES = 8 KAMIL_ADC_MAX_STEP = 0xFFFE _RAW_FRAME_STRUCT = struct.Struct(" bool: """Return whether `frame` is the packet-start marker.""" return frame == _START_FRAME @staticmethod def parse_point(frame: bytes, expected_step: int) -> complex: """Parse one `0x000A step real imag` frame and validate ordering.""" if len(frame) != KAMIL_ADC_FRAME_BYTES: raise ValueError( f"Kamil ADC frame must be {KAMIL_ADC_FRAME_BYTES} bytes, got {len(frame)}" ) marker, step, real, imag = _POINT_FRAME_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: """Read full Kamil ADC sweep packets from a nonblocking TTY stream.""" tty_path: str _fd: int | None = field(init=False, default=None, repr=False) _buffer: bytearray = field(init=False, default_factory=bytearray, repr=False) def open(self) -> None: """Open the configured TTY path for binary reads.""" if self._fd is not None: return self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK) def close(self) -> None: """Close the TTY file descriptor.""" if self._fd is None: return try: os.close(self._fd) finally: self._fd = None self._buffer.clear() def read_sweep( self, *, points: int, timeout_s: float, process: subprocess.Popen[bytes] | None = None, ) -> np.ndarray: """Read one packet-start marker followed by exactly `points` IQ frames.""" if points <= 0: raise ValueError("Kamil ADC sweep points must be > 0") if points > KAMIL_ADC_MAX_STEP: raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}") if self._fd is None: raise RuntimeError("Kamil ADC TTY reader is not open") deadline = time.monotonic() + float(timeout_s) self._read_until_packet_start(deadline, process) values = np.empty(points, dtype=np.complex64) for index in range(points): frame = self._read_frame(deadline, process, received_points=index, expected_points=points) values[index] = KamilAdcFrameParser.parse_point(frame, index + 1) return values def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None: """Discard bytes already buffered before starting a new logical sweep.""" if self._fd is None: raise RuntimeError("Kamil ADC TTY reader is not open") self._buffer.clear() fd = self._require_fd() while True: self._raise_if_process_exited(process) try: readable, _, _ = select.select([fd], [], [], 0.0) except InterruptedError: continue if not readable: return try: chunk = os.read(fd, 4096) except BlockingIOError: return except OSError as exc: if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}: return raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc if not chunk: raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining") def _read_until_packet_start( self, deadline: float, process: subprocess.Popen[bytes] | None, ) -> None: while True: start_index = self._buffer.find(_START_FRAME) if start_index >= 0: del self._buffer[: start_index + KAMIL_ADC_FRAME_BYTES] return if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES: del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1] self._read_available(deadline, process) def _read_frame( self, deadline: float, process: subprocess.Popen[bytes] | None, *, received_points: int, expected_points: int, ) -> bytes: while len(self._buffer) < KAMIL_ADC_FRAME_BYTES: self._read_available(deadline, process, received_points, expected_points) frame = bytes(self._buffer[:KAMIL_ADC_FRAME_BYTES]) del self._buffer[:KAMIL_ADC_FRAME_BYTES] return frame def _read_available( self, deadline: float, process: subprocess.Popen[bytes] | None, received_points: int | None = None, expected_points: int | None = None, ) -> None: self._raise_if_process_exited(process) remaining_s = deadline - time.monotonic() if remaining_s <= 0.0: if received_points is None or expected_points is None: raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker") raise TimeoutError( f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points" ) fd = self._require_fd() wait_s = min(0.05, remaining_s) try: readable, _, _ = select.select([fd], [], [], wait_s) except InterruptedError: return if not readable: return try: chunk = os.read(fd, 4096) except BlockingIOError: return except OSError as exc: if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}: return 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") self._buffer.extend(chunk) def _require_fd(self) -> int: if self._fd is None: raise RuntimeError("Kamil ADC TTY reader is not open") return self._fd @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 `kamil_adc`, configure laser board, and acquire TTY 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) _laser_controller: Any | None = field(init=False, default=None, repr=False) _laser_variation_active: bool = field(init=False, default=False, 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) -> None: """Apply laser configuration, launch the collector, and open its TTY stream.""" if self._reader is not None: return previous_tty_identity = _tty_identity(self.config.radar.kamil_adc.tty_path) try: self._apply_laser_control() self._start_process() self._wait_for_tty(previous_tty_identity) reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path) reader.open() self._reader = reader except Exception: self.close() raise def close(self) -> None: """Close TTY, stop the external collector, and disconnect laser control.""" if self._reader is not None: with suppress(Exception): self._reader.close() self._reader = None self._stop_process() self._close_laser_control() def configure(self, sweep: RadarSweepModel) -> None: """Store sweep settings and construct the synthetic frequency axis.""" self._validate_sweep(sweep) self._settings = sweep self._frequency_hz = np.linspace( float(sweep.start_hz), float(sweep.stop_hz), int(sweep.points), dtype=np.float32, ) 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: """Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros.""" if self._settings is None or self._frequency_hz 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: code = None if process is None else process.poll() raise RuntimeError(f"Kamil ADC process is not running (code={code})") points = int(self._settings.points) self._reader.discard_pending(process) s21 = self._reader.read_sweep( points=points, timeout_s=self.config.radar.kamil_adc.sweep_timeout_s, process=process, ) return SweepResult( x=self._frequency_hz.copy(), traces={ "s11": np.zeros(points, dtype=np.complex64), "s21": s21, }, ) 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: return if process.poll() is not None: return 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 with suppress(ProcessLookupError): os.killpg(process.pid, signal.SIGKILL) process.wait(timeout=1.0) def _wait_for_tty(self, previous_identity: tuple[object, ...] | None) -> None: adc = self.config.radar.kamil_adc deadline = time.monotonic() + adc.startup_timeout_s while time.monotonic() < deadline: KamilAdcTtyReader._raise_if_process_exited(self._process) identity = _tty_identity(adc.tty_path) if identity is not None and identity != previous_identity: return time.sleep(0.05) raise TimeoutError( f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector" ) def _apply_laser_control(self) -> None: laser = self.config.radar.laser_control if not laser.enabled: return from python_app.hardware_full.laser_control.controller import 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() mode = laser.mode.strip().lower() if mode == "manual": manual = laser.manual controller.set_manual_mode( temp1=manual.temp1, temp2=manual.temp2, current1=manual.current1, current2=manual.current2, ) elif 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: " f"{variation.variation_type}" ) from exc 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, }, ) self._laser_variation_active = True else: raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}") except Exception: with suppress(Exception): controller.disconnect() raise self._laser_controller = controller def _close_laser_control(self) -> None: controller = self._laser_controller self._laser_controller = None if controller is None: self._laser_variation_active = False return try: if self._laser_variation_active: controller.stop_task() finally: self._laser_variation_active = False controller.disconnect() 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}") laser = self.config.radar.laser_control if laser.enabled: 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") @staticmethod def _validate_sweep(sweep: RadarSweepModel) -> None: points = int(sweep.points) if points <= 0: raise ValueError("Kamil ADC sweep points must be > 0") if points > KAMIL_ADC_MAX_STEP: raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}") if float(sweep.stop_hz) < float(sweep.start_hz): raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz") def _tty_identity(path: str) -> tuple[object, ...] | None: try: if os.path.islink(path): return ("link", os.readlink(path)) 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), )