Files
radar_system/python_app/hardware_full/kamil_adc_service.py
T
2026-05-28 14:33:12 +03:00

595 lines
24 KiB
Python

"""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(
"<HHHH", KAMIL_ADC_MARKER, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP, KAMIL_ADC_START_STEP
)
# Point frames carry signed 16-bit real/imag components; start markers reuse
# the same 8-byte slot but with all four words unsigned. Comparing the raw
# bytes against :data:`_START_FRAME` is therefore the correct boundary check.
_POINT_STRUCT = struct.Struct("<HHhh")
# Larger TTY reads keep up with bursty USB CDC-ACM writers without raising the
# syscall rate. 64 KiB matches the typical Linux PTY buffer size.
_READ_CHUNK_BYTES = 65536
# select() poll interval inside the reader thread — short enough to react to
# `close()` requests, long enough that idle CPU stays near zero.
_READ_POLL_INTERVAL_S = 0.1
def _parse_point_frame(frame: bytes, expected_step: int) -> 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()
def close(self) -> None:
"""Stop the reader thread and close the TTY descriptor."""
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)
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 TTY → parse frames → publish completed sweeps until stop."""
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
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) -> None:
"""Launch the collector and start the TTY reader thread."""
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)
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
reader.open()
self._reader = reader
except Exception:
self.close()
raise
def close(self) -> None:
"""Stop the TTY reader and the external collector process."""
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
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:
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
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"
)
# ------------------------------------------------------------------
# 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:<path>; 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 Kamil ADC laser settings exactly through the legacy device_main command sequence."""
laser = config.radar.laser_control
if not laser.enabled:
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()
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")