"""Single-device S21 acquisition over the LibreVNA USB protocol.""" from __future__ import annotations from dataclasses import dataclass, field from contextlib import suppress import time from python_app.hardware_full.librevna_driver import LibreVNADevice, VNASweepSettings from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData from python_app.models.run_config_schema import RadarSweepModel @dataclass(slots=True) class LibreVnaService: """Native backend using direct USB LibreVNA Python driver.""" serial: str | None = None strict_protocol_version: int = 14 _device: LibreVNADevice | None = field(init=False, default=None, repr=False) _settings: RadarSweepModel | None = field(init=False, default=None, repr=False) def open(self) -> None: """Open device connection if it is not already open.""" if self._device is not None: return device = self._device = LibreVNADevice() try: device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0) except Exception: with suppress(Exception): self.close() raise @property def is_open(self) -> bool: """Return `True` when native device connection is open.""" return self._device is not None def close(self) -> None: """Close device connection when open.""" if self._device is None: return device, self._device = self._device, None device.disconnect() def configure(self, sweep: RadarSweepModel) -> None: """Store and apply sweep settings to connected hardware.""" self._settings = sweep if self._device is None: return settings = VNASweepSettings( f_start_hz=sweep.start_hz, f_stop_hz=sweep.stop_hz, points=sweep.points, if_bandwidth_hz=sweep.if_bandwidth_hz, power_start_dbm=sweep.power_dbm, power_stop_dbm=sweep.power_dbm, dwell_s=sweep.dwell_us / 1_000_000, ) self._device.vna.configure(settings) def read_device_limits(self) -> dict[str, float | int]: """Read frequency/IFBW/power/points limits from connected device.""" opened_here = not self.is_open try: if opened_here: self.open() assert self._device is not None limits = self._device.get_device_info().limits return { "min_frequency_hz": float(limits.min_frequency_hz), "max_frequency_hz": float(limits.max_frequency_hz), "min_ifbw_hz": float(limits.min_ifbw_hz), "max_ifbw_hz": float(limits.max_ifbw_hz), "max_points": int(limits.max_points), "min_power_dbm": float(limits.min_power_dbm), "max_power_dbm": float(limits.max_power_dbm), } finally: if opened_here: self.close() def prepare_combo(self, combo: ComboKey) -> None: """Standby acquisition starts a fresh sweep after the physical switch.""" def acquire(self) -> SweepResult: """Acquire one sweep from hardware.""" if self._settings is None: raise RuntimeError("Radar service is not configured") if self._device is None: raise RuntimeError("Device not found") capture_start_ns = time.monotonic_ns() result = self._device.vna.acquire() result.capture_start_ns = capture_start_ns result.capture_end_ns = time.monotonic_ns() return result def acquire_collection(self, collection_id: int = 1, *, combo: ComboKey | None = None) -> SweepCollection: """Expose one physical S21 path to the shared switch collector.""" result = self.acquire() return SweepCollection( collection_id=collection_id, monotonic_ns=result.capture_end_ns, traces=[TraceData( ComboKey(0, 0), result.x, result.trace("s21"), result.capture_start_ns, result.capture_end_ns, )], capture_start_ns=result.capture_start_ns, capture_end_ns=result.capture_end_ns, )