595 lines
24 KiB
Python
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")
|