init commit

This commit is contained in:
Ayzen
2026-03-05 14:42:33 +03:00
commit fd4618b20d
964 changed files with 325114 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Hardware integration package for radar and switch devices."""
@@ -0,0 +1,163 @@
"""Backend adapters used by :mod:`python_app.hardware_full.librevna_service`."""
from __future__ import annotations
from dataclasses import dataclass, field
import math
from typing import Any, Protocol
import numpy as np
from python_app.models.run_config_model import RadarSweepModel
class LibreVnaBackend(Protocol):
"""Minimal backend contract required by `LibreVnaService`."""
def open(self) -> None:
"""Open backend resources."""
def close(self) -> None:
"""Close backend resources."""
@property
def is_open(self) -> bool:
"""Return whether backend currently holds open runtime resources."""
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings."""
def read_device_limits(self) -> dict[str, float | int]:
"""Query runtime device limits."""
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
"""Acquire one S21 trace."""
@dataclass(slots=True)
class NativeLibreVnaBackend:
"""Native backend using direct USB LibreVNA Python driver."""
serial: str | None
strict_protocol_version: int
_device: Any | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_libre_vna_device: type[Any] | None = field(init=False, default=None, repr=False)
_vna_sweep_settings: type[Any] | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
"""Import native driver classes lazily and initialize backend state."""
from python_app.hardware_full.librevna_driver import LibreVNADevice, VNASweepSettings
self._device: LibreVNADevice | None = None
self._settings: RadarSweepModel | None = None
self._libre_vna_device = LibreVNADevice
self._vna_sweep_settings = VNASweepSettings
def open(self) -> None:
"""Open device connection if it is not already open."""
if self._device is not None:
return
self._device = self._libre_vna_device()
self._device.connect(serial=self.serial, strict_protocol_version=self.strict_protocol_version, timeout_s=2.0)
@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
self._device.disconnect()
self._device = None
def configure(self, sweep: RadarSweepModel) -> None:
"""Store and apply sweep settings to connected hardware."""
self._settings = sweep
if self._device is None:
return
settings = self._vna_sweep_settings(
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,
excited_ports=(1, 2),
)
self._device.vna.configure(settings)
def read_device_limits(self) -> dict[str, float | int]:
"""Read frequency/IFBW/power/points limits from connected device."""
if self._device is None:
raise RuntimeError("Failed to connect to LibreVNA device")
device_info = self._device.get_device_info()
limits = 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),
}
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
"""Acquire one S21 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")
result = self._device.vna.acquire(expected_points=self._settings.points, timeout_s=20.0)
return np.asarray(result.x, dtype=np.float32), np.asarray(result.trace("s21"), dtype=np.complex64)
@dataclass(slots=True)
class MockLibreVnaBackend:
"""Synthetic backend used for local development and tests."""
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_mock_phase: float = field(init=False, default=0.0, repr=False)
def __post_init__(self) -> None:
"""Initialize mock backend state."""
self._settings = None
self._mock_phase = 0.0
def open(self) -> None:
"""No-op for mock backend."""
def close(self) -> None:
"""No-op for mock backend."""
@property
def is_open(self) -> bool:
"""Mock backend does not hold external resources."""
return False
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings used by synthetic acquisition."""
self._settings = sweep
def read_device_limits(self) -> dict[str, float | int]:
"""Mock backend does not support native device limits queries."""
raise RuntimeError("LibreVNA Python driver is not available")
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
"""Generate synthetic S21 values using deterministic phase envelope."""
if self._settings is None:
raise RuntimeError("Radar service is not configured")
points = self._settings.points
freq = np.linspace(self._settings.start_hz, self._settings.stop_hz, points, dtype=np.float32)
phase = (2.0 * math.pi * np.linspace(0.0, 1.0, points, dtype=np.float32)) + self._mock_phase
envelope = 0.6 + 0.4 * np.sin(phase * 0.5)
s21 = (envelope * np.cos(phase) + 1j * envelope * np.sin(phase)).astype(np.complex64)
self._mock_phase += 0.05
return freq, s21
@@ -0,0 +1,72 @@
"""Python driver for direct USB control of LibreVNA (protocol v14)."""
import logging
from .device import LibreVNADevice
from .enums import (
HardwareFamily,
PacketType,
SParameter,
SweepKind,
SweepScale,
SyncMode,
)
from .logging_utils import DEFAULT_LOG_LEVEL, configure_logging
from .exceptions import (
CRCError,
DeviceDisconnectedError,
IncompleteSweepError,
LibreVNAError,
NackError,
ParseError,
ProtocolVersionMismatch,
TimeoutError,
UnsupportedHardwareError,
)
from .models import (
DeviceConfigVariant,
DeviceInfo,
DeviceLimits,
DeviceStatus,
Packet,
StreamHandle,
SweepResult,
USBDeviceDescriptor,
VNADatapointPacket,
VNARawPoint,
VNASweepSettings,
)
__all__ = [
"CRCError",
"DEFAULT_LOG_LEVEL",
"DeviceConfigVariant",
"DeviceDisconnectedError",
"DeviceInfo",
"DeviceLimits",
"DeviceStatus",
"HardwareFamily",
"IncompleteSweepError",
"LibreVNADevice",
"LibreVNAError",
"NackError",
"Packet",
"PacketType",
"ParseError",
"ProtocolVersionMismatch",
"configure_logging",
"SParameter",
"StreamHandle",
"SweepKind",
"SweepResult",
"SweepScale",
"SyncMode",
"TimeoutError",
"USBDeviceDescriptor",
"UnsupportedHardwareError",
"VNADatapointPacket",
"VNARawPoint",
"VNASweepSettings",
]
logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -0,0 +1,9 @@
"""Public controller classes."""
from .config import ConfigController
from .vna import VNAController
__all__ = [
"ConfigController",
"VNAController",
]
@@ -0,0 +1,54 @@
"""Device configuration controller."""
from __future__ import annotations
import logging
from ..enums import PacketType
from ..exceptions import ParseError
from ..models import DeviceConfigVariant, Packet
from ..protocol import parse_device_config
from ..session import LibreVNASession
logger = logging.getLogger(__name__)
class ConfigController:
"""Device configuration read/write/reset operations."""
def __init__(self, session: LibreVNASession) -> None:
"""Bind controller to an active session."""
self._session = session
def get(self, *, timeout_s: float = 1.0) -> DeviceConfigVariant:
"""Read configuration block for active hardware family."""
packet = self._session.request(
PacketType.REQUEST_DEVICE_CONFIGURATION,
PacketType.DEVICE_CONFIGURATION,
timeout_s=timeout_s,
)
if not isinstance(packet.payload, (bytes, bytearray, memoryview)):
raise ParseError("DeviceConfiguration payload has unexpected type")
cfg = parse_device_config(bytes(packet.payload), self._session.hardware_family)
logger.info(
"Loaded device configuration for family=%s fields=%d",
cfg.family.name,
len(cfg.values),
)
return cfg
def set(self, cfg: DeviceConfigVariant, *, timeout_s: float = 1.0) -> None:
"""Write configuration block for active hardware family."""
if cfg.family != self._session.hardware_family:
raise ParseError("DeviceConfigVariant family does not match connected hardware family")
logger.info(
"Writing device configuration for family=%s fields=%d",
cfg.family.name,
len(cfg.values),
)
self._session.send(Packet(PacketType.DEVICE_CONFIGURATION, cfg), require_ack=True, timeout_s=timeout_s)
def reset(self, *, timeout_s: float = 1.0) -> None:
"""Reset device configuration to firmware defaults."""
logger.warning("Resetting device configuration to firmware defaults")
self._session.send(Packet(PacketType.RESET_DEVICE_CONFIGURATION), require_ack=True, timeout_s=timeout_s)
@@ -0,0 +1,99 @@
"""VNA high-level controller for direct protocol packets."""
from __future__ import annotations
from dataclasses import replace
import logging
from typing import Callable
from ..enums import PacketType
from ..exceptions import ParseError
from ..models import Packet, StreamHandle, SweepResult, VNADatapointPacket, VNARawPoint, VNASweepSettings
from ..session import LibreVNASession
from ..sweep.assembler import assemble_vna_sweep, datapoint_to_raw_point
logger = logging.getLogger(__name__)
class VNAController:
"""VNA operations built on direct packet protocol."""
def __init__(self, session: LibreVNASession) -> None:
"""Bind VNA controller to active session."""
self._session = session
self._settings: VNASweepSettings | None = None
def configure(self, settings: VNASweepSettings) -> None:
"""Send `SweepSettings` packet to configure VNA operation."""
effective = settings
if effective.sync_mode is None:
effective = replace(effective, sync_mode=self._session.default_sync_mode)
self._settings = effective
self._session.clear_queue(PacketType.VNA_DATAPOINT)
self._session.send(Packet(PacketType.SWEEP_SETTINGS, effective), require_ack=True)
logger.info(
"VNA configured: kind=%s start=%.3fHz stop=%.3fHz points=%d ifbw=%.3fHz ports=%s",
effective.kind.value,
effective.f_start_hz,
effective.f_stop_hz,
effective.points,
effective.if_bandwidth_hz,
effective.excited_ports,
)
def acquire(self, *, expected_points: int | None = None, timeout_s: float = 10.0) -> SweepResult:
"""Acquire one complete sweep and return assembled complex traces."""
if self._settings is None:
raise ParseError("VNA is not configured. Call vna.configure() before acquire().")
settings = self._settings
points_target = expected_points if expected_points is not None else settings.points
if points_target <= 0:
raise ValueError("expected_points must be > 0")
logger.info(
"VNA acquire start: expected_points=%d timeout=%.2fs standby=%s",
points_target,
timeout_s,
settings.standby,
)
self._session.clear_queue(PacketType.VNA_DATAPOINT)
if settings.standby:
self._session.send(Packet(PacketType.INITIATE_SWEEP), require_ack=True)
ordered = self._session.collect_indexed_payloads(
packet_type=PacketType.VNA_DATAPOINT,
expected_points=points_target,
timeout_s=timeout_s,
payload_type=VNADatapointPacket,
payload_error="Expected decoded VNADatapointPacket payload",
)
device_info = self._session.get_device_info()
result = assemble_vna_sweep(
ordered,
settings,
num_ports=device_info.num_ports,
expected_points=points_target,
)
logger.info("VNA acquire complete: received_points=%d", len(result.x))
return result
def stream(self, callback: Callable[[VNARawPoint], None]) -> StreamHandle:
"""Subscribe callback for every incoming VNA datapoint packet."""
if self._settings is None:
raise ParseError("VNA is not configured. Call vna.configure() before stream().")
settings = self._settings
num_ports = self._session.get_device_info().num_ports
def _on_packet(packet: Packet) -> None:
"""Decode datapoint packet and forward mapped raw point to callback."""
payload = packet.payload
if not isinstance(payload, VNADatapointPacket):
raise ParseError("Expected decoded VNADatapointPacket payload")
callback(datapoint_to_raw_point(payload, settings, num_ports=num_ports))
logger.info("VNA stream subscription started")
return self._session.subscribe(PacketType.VNA_DATAPOINT, _on_packet)
@@ -0,0 +1,115 @@
"""Main user-facing LibreVNA direct-USB device class."""
from __future__ import annotations
import logging
from types import TracebackType
from .api.config import ConfigController
from .api.vna import VNAController
from .enums import PacketType
from .models import DeviceInfo, DeviceStatus, Packet, USBDeviceDescriptor
from .session import LibreVNASession
logger = logging.getLogger(__name__)
class LibreVNADevice:
"""Main entry point for direct USB control of LibreVNA."""
def __init__(self) -> None:
"""Create device facade with session-backed VNA/config controllers."""
self._session = LibreVNASession()
self.vna = VNAController(self._session)
self.config = ConfigController(self._session)
def __enter__(self) -> LibreVNADevice:
"""Return self to support context-managed lifetime in host applications."""
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
"""Always close USB session when leaving context manager block."""
self.disconnect()
@property
def is_connected(self) -> bool:
"""Return connection state of underlying USB session."""
return self._session.is_connected
@property
def connected_serial(self) -> str | None:
"""Return currently connected serial number when available."""
return self._session.connected_serial
@staticmethod
def list_devices() -> list[USBDeviceDescriptor]:
"""Return all currently discoverable LibreVNA USB devices."""
devices = LibreVNASession.list_devices()
logger.debug("Discovered %d LibreVNA USB device(s)", len(devices))
return devices
def connect(
self,
serial: str | None = None,
strict_protocol_version: int = 14,
timeout_s: float = 1.0,
) -> None:
"""Connect to a LibreVNA device by optional serial number."""
logger.info(
"Connecting to LibreVNA (serial=%s, strict_protocol=%d, timeout=%.2fs)",
serial,
strict_protocol_version,
timeout_s,
)
self._session.connect(
serial=serial,
strict_protocol_version=strict_protocol_version,
timeout_s=timeout_s,
)
logger.info("Connected to LibreVNA (serial=%s)", self.connected_serial)
def disconnect(self) -> None:
"""Disconnect USB transport and clear runtime state."""
logger.info("Disconnecting LibreVNA session")
self._session.disconnect()
logger.info("LibreVNA session disconnected")
def send(self, packet: Packet, *, require_ack: bool = True, timeout_s: float = 0.5) -> None:
"""Send low-level protocol packet."""
logger.debug(
"Sending packet %s (require_ack=%s, timeout=%.2fs)",
packet.type.name,
require_ack,
timeout_s,
)
self._session.send(packet, require_ack=require_ack, timeout_s=timeout_s)
def request(
self,
packet_type: PacketType,
response_type: PacketType,
*,
timeout_s: float = 1.0,
) -> Packet:
"""Send no-payload request packet and wait for a response packet."""
logger.debug(
"Request packet=%s response=%s timeout=%.2fs",
packet_type.name,
response_type.name,
timeout_s,
)
return self._session.request(packet_type, response_type, timeout_s=timeout_s)
def get_device_info(self) -> DeviceInfo:
"""Return cached device info from handshake."""
return self._session.get_device_info()
def get_device_status(self, *, timeout_s: float = 1.0) -> DeviceStatus:
"""Query and return current device status."""
return self._session.get_device_status(timeout_s=timeout_s)
@@ -0,0 +1,87 @@
"""Enumerations used by the LibreVNA direct-USB driver."""
from __future__ import annotations
from enum import Enum, IntEnum
class PacketType(IntEnum):
"""Packet identifiers defined by LibreVNA protocol v14."""
NONE = 0
SWEEP_SETTINGS = 2
MANUAL_STATUS = 3
MANUAL_CONTROL = 4
DEVICE_INFO = 5
FIRMWARE_PACKET = 6
ACK = 7
CLEAR_FLASH = 8
PERFORM_FIRMWARE_UPDATE = 9
NACK = 10
REFERENCE = 11
GENERATOR = 12
SPECTRUM_ANALYZER_SETTINGS = 13
SPECTRUM_ANALYZER_RESULT = 14
REQUEST_DEVICE_INFO = 15
REQUEST_SOURCE_CAL = 16
REQUEST_RECEIVER_CAL = 17
SOURCE_CAL_POINT = 18
RECEIVER_CAL_POINT = 19
SET_IDLE = 20
REQUEST_FREQUENCY_CORRECTION = 21
FREQUENCY_CORRECTION = 22
REQUEST_DEVICE_CONFIGURATION = 23
DEVICE_CONFIGURATION = 24
DEVICE_STATUS = 25
REQUEST_DEVICE_STATUS = 26
VNA_DATAPOINT = 27
SET_TRIGGER = 28
CLEAR_TRIGGER = 29
STOP_STATUS_UPDATES = 30
START_STATUS_UPDATES = 31
INITIATE_SWEEP = 32
PERFORM_ACTION = 33
RESET_DEVICE_CONFIGURATION = 34
class SyncMode(IntEnum):
"""Synchronization mode encoded in sweep settings."""
DISABLED = 0
USB = 1
EXTERNAL_REFERENCE = 2
EXTERNAL_TRIGGER = 3
class HardwareFamily(IntEnum):
"""Known LibreVNA hardware families."""
V1 = 0x01
VD0 = 0xD0
VE0 = 0xE0
VFE = 0xFE
VFF = 0xFF
UNKNOWN = 0x00
class SweepKind(str, Enum):
"""Logical VNA sweep mode."""
FREQUENCY = "frequency"
POWER = "power"
class SweepScale(str, Enum):
"""Frequency axis spacing."""
LIN = "lin"
LOG = "log"
class SParameter(str, Enum):
"""Canonical 2-port S-parameters."""
S11 = "S11"
S12 = "S12"
S21 = "S21"
S22 = "S22"
@@ -0,0 +1,39 @@
"""Project exceptions for direct USB and protocol operations."""
from __future__ import annotations
class LibreVNAError(Exception):
"""Base error for all library exceptions."""
class ProtocolVersionMismatch(LibreVNAError):
"""Raised when device protocol version differs from required version."""
class CRCError(LibreVNAError):
"""Raised when packet CRC validation fails."""
class NackError(LibreVNAError):
"""Raised when the device explicitly responds with NACK."""
class TimeoutError(LibreVNAError):
"""Raised when waiting for packet/ack times out."""
class DeviceDisconnectedError(LibreVNAError):
"""Raised when USB transport is disconnected or unavailable."""
class IncompleteSweepError(LibreVNAError):
"""Raised when a sweep does not contain the required points."""
class ParseError(LibreVNAError):
"""Raised when packet decoding or payload parsing fails."""
class UnsupportedHardwareError(LibreVNAError):
"""Raised when a requested operation is unsupported for hardware family."""
@@ -0,0 +1,52 @@
"""Logging helpers for applications embedding ``librevna_driver``.
The library uses standard ``logging`` module loggers under the
``librevna_driver`` namespace and never configures global logging implicitly.
Use :func:`configure_logging` in scripts/services when you want a convenient
default console setup.
"""
from __future__ import annotations
import logging
_LOGGER_NAMESPACE = "librevna_driver"
_DEFAULT_FORMAT = (
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
)
DEFAULT_LOG_LEVEL = "INFO"
def configure_logging(
level: int | str | None = None,
*,
fmt: str = _DEFAULT_FORMAT,
datefmt: str | None = "%Y-%m-%d %H:%M:%S",
) -> None:
"""Configure package logger with one stream handler.
This helper affects only the ``librevna_driver`` logger tree and is safe to
call repeatedly (previous handlers attached by this function are replaced).
When ``level`` is ``None``, :data:`DEFAULT_LOG_LEVEL` is used.
"""
effective_level = level if level is not None else DEFAULT_LOG_LEVEL
logger = logging.getLogger(_LOGGER_NAMESPACE)
logger.handlers.clear()
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
logger.addHandler(handler)
logger.setLevel(_parse_level(effective_level))
logger.propagate = False
def _parse_level(level: int | str) -> int:
"""Parse numeric or textual log level into logging constant."""
if isinstance(level, int):
return level
normalized = level.strip().upper()
if normalized in logging.getLevelNamesMapping():
return logging.getLevelNamesMapping()[normalized]
raise ValueError(f"Unknown logging level: {level!r}")
@@ -0,0 +1,255 @@
"""Public datamodels and low-level packet payload representations."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from threading import Event, Thread
from typing import Any, Callable
import numpy as np
from .enums import HardwareFamily, PacketType, SweepKind, SweepScale, SyncMode
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class Packet:
"""Generic protocol packet container."""
type: PacketType
payload: Any = b""
@dataclass(slots=True)
class USBDeviceDescriptor:
"""USB device descriptor exposed by transport discovery."""
serial: str
vendor_id: int
product_id: int
@dataclass(slots=True)
class DeviceLimits:
"""Hardware capability limits reported by the instrument."""
min_frequency_hz: float
max_frequency_hz: float
max_frequency_harmonic_hz: float
min_ifbw_hz: float
max_ifbw_hz: float
max_points: int
min_power_dbm: float
max_power_dbm: float
min_rbw_hz: float
max_rbw_hz: float
max_amplitude_points: int
max_dwell_time_s: float
@dataclass(slots=True)
class DeviceInfo:
"""Device identity and capabilities."""
protocol_version: int
firmware_major: int
firmware_minor: int
firmware_patch: int
firmware_version: str
hardware_version: int
hardware_revision: str
hardware_family: HardwareFamily
limits: DeviceLimits
num_ports: int
@dataclass(slots=True)
class DeviceStatus:
"""Current runtime status and telemetry."""
family: HardwareFamily
source_locked: bool | None
lo_locked: bool | None
adc_overload: bool | None
unlevel: bool | None
temperatures_c: list[float] = field(default_factory=list)
raw: dict[str, int | float | bool] = field(default_factory=dict)
@dataclass(slots=True)
class VNASweepSettings:
"""Configuration for one VNA sweep setup packet."""
kind: SweepKind = SweepKind.FREQUENCY
f_start_hz: float = 1_000_000.0
f_stop_hz: float = 6_000_000_000.0
points: int = 501
if_bandwidth_hz: float = 1_000.0
power_start_dbm: float = -10.0
power_stop_dbm: float = -10.0
excited_ports: tuple[int, ...] = (1, 2)
sweep_scale: SweepScale = SweepScale.LIN
dwell_s: float = 0.0
suppress_invalid_peaks: bool = True
fixed_power_setting: bool = False
standby: bool = True
sync_mode: SyncMode | None = None
sync_master: bool = False
def __post_init__(self) -> None:
"""Validate configuration before transmitting to the device."""
if self.points <= 0:
raise ValueError("points must be > 0")
if self.if_bandwidth_hz <= 0:
raise ValueError("if_bandwidth_hz must be > 0")
if self.dwell_s < 0:
raise ValueError("dwell_s must be >= 0")
if not self.excited_ports:
raise ValueError("excited_ports must not be empty")
if len(set(self.excited_ports)) != len(self.excited_ports):
raise ValueError("excited_ports must not contain duplicates")
if any(port <= 0 for port in self.excited_ports):
raise ValueError("excited_ports must use 1-based positive port numbers")
if self.f_stop_hz < self.f_start_hz:
raise ValueError("f_stop_hz must be >= f_start_hz")
if self.kind == SweepKind.POWER and self.power_stop_dbm < self.power_start_dbm:
raise ValueError("power_stop_dbm must be >= power_start_dbm for power sweep")
if self.kind == SweepKind.POWER and self.f_start_hz != self.f_stop_hz:
raise ValueError("power sweep requires f_start_hz == f_stop_hz")
@dataclass(slots=True)
class DeviceConfigVariant:
"""Family-specific device configuration fields."""
family: HardwareFamily
values: dict[str, int | float | bool] = field(default_factory=dict)
@dataclass(slots=True)
class VNADatapointPacket:
"""Decoded low-level VNADatapoint payload."""
frequency_or_time: int
cdbm: int
point_number: int
real: np.ndarray
imag: np.ndarray
flags: np.ndarray
@dataclass(slots=True)
class VNARawPoint:
"""Normalized VNA datapoint used by streaming callback API."""
point_number: int
frequency_hz: float | None
time_s: float | None
power_dbm: float | None
measurements: dict[str, complex] = field(default_factory=dict)
z0: float = 50.0
class StreamHandle:
"""Handle returned from streaming subscriptions."""
def __init__(
self,
stop_event: Event,
close_callback: Callable[[], None],
reader_thread: Thread | None = None,
) -> None:
"""Create stream handle with stop event and unsubscribe callback."""
self._stop_event = stop_event
self._close_callback = close_callback
self._reader_thread = reader_thread
def close(self, *, join_timeout_s: float | None = 1.0) -> None:
"""Stop streaming and release internal resources."""
logger.debug("Closing stream handle (join_timeout_s=%s)", join_timeout_s)
if not self._stop_event.is_set():
self._stop_event.set()
self._close_callback()
if self._reader_thread is not None and self._reader_thread.is_alive():
self._reader_thread.join(timeout=join_timeout_s)
logger.debug("Stream handle closed")
@dataclass(slots=True)
class SweepResult:
"""Container for complex VNA traces sharing one X-axis."""
x: np.ndarray
traces: dict[str, np.ndarray] = field(default_factory=dict)
x_label: str = "frequency_hz"
def __post_init__(self) -> None:
"""Normalize dtypes and validate shape compatibility."""
self.x = np.asarray(self.x, dtype=np.float64)
normalized: dict[str, np.ndarray] = {}
for key, values in self.traces.items():
arr = np.asarray(values, dtype=np.complex128)
if arr.shape != self.x.shape:
raise ValueError(
f"Trace '{key}' shape {arr.shape} does not match axis shape {self.x.shape}"
)
normalized[key.strip().lower()] = arr
self.traces = normalized
@property
def s11(self) -> np.ndarray | None:
"""Return `S11` trace when available."""
return self.traces.get("s11")
@property
def s21(self) -> np.ndarray | None:
"""Return `S21` trace when available."""
return self.traces.get("s21")
@property
def s12(self) -> np.ndarray | None:
"""Return `S12` trace when available."""
return self.traces.get("s12")
@property
def s22(self) -> np.ndarray | None:
"""Return `S22` trace when available."""
return self.traces.get("s22")
def trace(self, parameter: str) -> np.ndarray:
"""Return complex trace by parameter name."""
key = parameter.strip().lower()
if key not in self.traces:
raise KeyError(f"Trace '{parameter}' is not available")
return self.traces[key]
def real(self, parameter: str) -> np.ndarray:
"""Return real part of selected trace."""
return self.trace(parameter).real
def imag(self, parameter: str) -> np.ndarray:
"""Return imaginary part of selected trace."""
return self.trace(parameter).imag
def to_npz(self, path: str) -> None:
"""Save result as NumPy `.npz` archive."""
data: dict[str, np.ndarray] = {self.x_label: self.x}
data.update(self.traces)
np.savez(path, **data)
def to_csv(self, path: str) -> None:
"""Save result as CSV with `<trace>_real`/`<trace>_imag` columns."""
columns: list[np.ndarray] = [self.x]
headers: list[str] = [self.x_label]
for name, values in sorted(self.traces.items()):
columns.append(values.real)
columns.append(values.imag)
headers.append(f"{name}_real")
headers.append(f"{name}_imag")
matrix = np.column_stack(columns)
np.savetxt(path, matrix, delimiter=",", header=",".join(headers), comments="")
PacketPayload = Any
@@ -0,0 +1,31 @@
"""Protocol framing and payload codecs for LibreVNA packet protocol v14."""
from .codec import (
decode_packet_payload,
decode_vna_datapoint_payload,
encode_device_config_payload,
encode_packet_payload,
encode_sweep_settings_payload,
ensure_no_payload_types,
NO_PAYLOAD_PACKET_TYPES,
parse_device_config,
parse_device_info,
parse_device_status,
)
from .frame import FrameScanner, decode_frame, encode_frame
__all__ = [
"FrameScanner",
"NO_PAYLOAD_PACKET_TYPES",
"decode_frame",
"decode_packet_payload",
"decode_vna_datapoint_payload",
"encode_device_config_payload",
"encode_frame",
"encode_packet_payload",
"encode_sweep_settings_payload",
"ensure_no_payload_types",
"parse_device_config",
"parse_device_info",
"parse_device_status",
]
@@ -0,0 +1,407 @@
"""Payload encoders/decoders for LibreVNA protocol packets."""
from __future__ import annotations
import ctypes
import logging
import struct
import numpy as np
from ..enums import HardwareFamily, PacketType
from ..exceptions import ParseError, UnsupportedHardwareError
from ..models import (
DeviceConfigVariant,
DeviceInfo,
DeviceLimits,
DeviceStatus,
Packet,
VNADatapointPacket,
VNASweepSettings,
)
from .structs import DeviceStatusUnion
logger = logging.getLogger(__name__)
_DEVICE_INFO_STRUCT = struct.Struct("<HBBBBcQQIIHhhIIBQBH")
_SWEEP_SETTINGS_STRUCT = struct.Struct("<QQHIhBHhH")
_DEVICE_CONFIG_V1_STRUCT = struct.Struct("<IBHB")
_DEVICE_CONFIG_VFF_STRUCT = struct.Struct("<IIIBH")
_DEVICE_CONFIG_VFE_STRUCT = struct.Struct("<H")
_DEVICE_CONFIG_VD0_STRUCT = struct.Struct("<HIB")
_DEVICE_STATUS_VARIANT_BY_FAMILY: dict[HardwareFamily, str] = {
HardwareFamily.V1: "V1",
HardwareFamily.VFF: "VFF",
HardwareFamily.VFE: "VFE",
HardwareFamily.VD0: "VD0",
HardwareFamily.VE0: "VD0",
}
def _family_from_hardware_version(hardware_version: int) -> HardwareFamily:
"""Map hardware version byte to typed hardware family enum."""
try:
return HardwareFamily(hardware_version)
except ValueError as exc:
raise UnsupportedHardwareError(
f"Unsupported hardware_version in DeviceInfo: 0x{hardware_version:02X}"
) from exc
def _ensure_payload_length(payload: bytes, expected: int, *, packet_name: str) -> None:
"""Validate exact payload length for fixed-size packet structures."""
if len(payload) != expected:
raise ParseError(
f"{packet_name} payload length mismatch: expected {expected}, got {len(payload)}"
)
def parse_device_info(payload: bytes) -> DeviceInfo:
"""Parse `DeviceInfo` packet payload into typed model."""
_ensure_payload_length(payload, _DEVICE_INFO_STRUCT.size, packet_name="DeviceInfo")
(
protocol_version,
fw_major,
fw_minor,
fw_patch,
hardware_version,
hw_revision_raw,
min_freq,
max_freq,
min_ifbw,
max_ifbw,
max_points,
min_cdbm,
max_cdbm,
min_rbw,
max_rbw,
max_amplitude_points,
max_harmonic,
num_ports,
max_dwell_time_us,
) = _DEVICE_INFO_STRUCT.unpack(payload)
family = _family_from_hardware_version(hardware_version)
hw_revision = hw_revision_raw.decode("ascii", errors="replace")
limits = DeviceLimits(
min_frequency_hz=float(min_freq),
max_frequency_hz=float(max_freq),
max_frequency_harmonic_hz=float(max_harmonic),
min_ifbw_hz=float(min_ifbw),
max_ifbw_hz=float(max_ifbw),
max_points=int(max_points),
min_power_dbm=float(min_cdbm) / 100.0,
max_power_dbm=float(max_cdbm) / 100.0,
min_rbw_hz=float(min_rbw),
max_rbw_hz=float(max_rbw),
max_amplitude_points=int(max_amplitude_points),
max_dwell_time_s=float(max_dwell_time_us) * 1e-6,
)
device_info = DeviceInfo(
protocol_version=int(protocol_version),
firmware_major=int(fw_major),
firmware_minor=int(fw_minor),
firmware_patch=int(fw_patch),
firmware_version=f"{fw_major}.{fw_minor}.{fw_patch}",
hardware_version=int(hardware_version),
hardware_revision=hw_revision,
hardware_family=family,
limits=limits,
num_ports=int(num_ports),
)
logger.debug(
"Decoded DeviceInfo: protocol=%d fw=%s family=%s ports=%d",
device_info.protocol_version,
device_info.firmware_version,
device_info.hardware_family.name,
device_info.num_ports,
)
return device_info
def _device_status_variant_name_for_family(family: HardwareFamily) -> str:
"""Resolve `DeviceStatusUnion` variant name for specific hardware family."""
try:
return _DEVICE_STATUS_VARIANT_BY_FAMILY[family]
except KeyError as exc:
raise UnsupportedHardwareError(
f"Unsupported hardware family for DeviceStatus: {family!r}"
) from exc
def _structure_fields_dict(struct_obj: ctypes.Structure) -> dict[str, int | float | bool]:
"""Convert ctypes structure fields into plain Python dictionary."""
values: dict[str, int | float | bool] = {}
for entry in struct_obj._fields_:
field_name = entry[0]
if field_name.startswith("_"):
continue
raw_value = getattr(struct_obj, field_name)
if len(entry) == 3:
values[field_name] = bool(raw_value)
continue
field_type = entry[1]
if field_type in {ctypes.c_float, ctypes.c_double}:
values[field_name] = float(raw_value)
else:
values[field_name] = int(raw_value)
return values
def parse_device_status(payload: bytes, family: HardwareFamily) -> DeviceStatus:
"""Parse `DeviceStatus` payload according to active hardware family."""
_ensure_payload_length(payload, ctypes.sizeof(DeviceStatusUnion), packet_name="DeviceStatus")
union = DeviceStatusUnion()
ctypes.memmove(ctypes.addressof(union), payload, len(payload))
variant_name = _device_status_variant_name_for_family(family)
variant = getattr(union, variant_name)
raw = _structure_fields_dict(variant)
source_locked = bool(raw.get("source_locked")) if "source_locked" in raw else None
lo_locked = None
if "LO_locked" in raw:
lo_locked = bool(raw["LO_locked"])
elif "LO1_locked" in raw:
lo_locked = bool(raw["LO1_locked"])
adc_overload = bool(raw.get("ADC_overload")) if "ADC_overload" in raw else None
unlevel = bool(raw.get("unlevel")) if "unlevel" in raw else None
temperatures_c: list[float] = []
for key in ("temp_source", "temp_LO1", "temp_MCU"):
if key in raw:
temperatures_c.append(float(raw[key]))
if "temp_eCal" in raw:
temperatures_c.append(float(raw["temp_eCal"]) / 100.0)
return DeviceStatus(
family=family,
source_locked=source_locked,
lo_locked=lo_locked,
adc_overload=adc_overload,
unlevel=unlevel,
temperatures_c=temperatures_c,
raw=raw,
)
def decode_vna_datapoint_payload(payload: bytes) -> VNADatapointPacket:
"""Decode variable-size VNADatapoint payload."""
if len(payload) < 12:
raise ParseError("VNADatapoint payload is too short")
values_block = len(payload) - 12
if values_block % 9 != 0:
raise ParseError("VNADatapoint payload length is not aligned to value tuple size")
num_values = values_block // 9
(frequency_or_time,) = struct.unpack_from("<Q", payload, 0)
(cdbm,) = struct.unpack_from("<h", payload, 8)
(point_number,) = struct.unpack_from("<H", payload, 10)
real = np.frombuffer(payload, dtype="<f4", count=num_values, offset=12).astype(np.float64, copy=False)
imag = np.frombuffer(payload, dtype="<f4", count=num_values, offset=12 + 4 * num_values).astype(
np.float64,
copy=False,
)
flags = np.frombuffer(payload, dtype=np.uint8, count=num_values, offset=12 + 8 * num_values)
return VNADatapointPacket(
frequency_or_time=int(frequency_or_time),
cdbm=int(cdbm),
point_number=int(point_number),
real=real,
imag=imag,
flags=flags,
)
def decode_packet_payload(packet: Packet) -> object:
"""Decode packet payload for known response packet types."""
if packet.type == PacketType.DEVICE_INFO:
return parse_device_info(packet.payload)
if packet.type == PacketType.VNA_DATAPOINT:
return decode_vna_datapoint_payload(packet.payload)
return packet.payload
def encode_sweep_settings_payload(settings: VNASweepSettings) -> bytes:
"""Encode `SweepSettings` payload."""
if len(settings.excited_ports) > 4:
raise ValueError("Protocol supports at most four excited ports")
sync_mode = settings.sync_mode if settings.sync_mode is not None else 0
stage_by_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
flags1 = 0
flags1 |= int(bool(settings.standby)) << 0
flags1 |= int(bool(settings.sync_master)) << 1
flags1 |= int(bool(settings.suppress_invalid_peaks)) << 2
flags1 |= int(bool(settings.fixed_power_setting)) << 3
flags1 |= int(settings.sweep_scale.value == "log") << 4
flags1 |= (int(sync_mode) & 0x03) << 5
flags2 = 0
stages = len(settings.excited_ports) - 1
flags2 |= stages & 0x07
flags2 |= (stage_by_port.get(1, 0) & 0x07) << 3
flags2 |= (stage_by_port.get(2, 0) & 0x07) << 6
flags2 |= (stage_by_port.get(3, 0) & 0x07) << 9
flags2 |= (stage_by_port.get(4, 0) & 0x07) << 12
dwell_us = int(round(settings.dwell_s * 1_000_000.0))
dwell_us = max(0, min(0xFFFF, dwell_us))
return _SWEEP_SETTINGS_STRUCT.pack(
int(round(settings.f_start_hz)),
int(round(settings.f_stop_hz)),
int(settings.points),
int(round(settings.if_bandwidth_hz)),
int(round(settings.power_start_dbm * 100.0)),
flags1,
flags2,
int(round(settings.power_stop_dbm * 100.0)),
dwell_us,
)
def parse_device_config(payload: bytes, family: HardwareFamily) -> DeviceConfigVariant:
"""Decode family-specific `DeviceConfiguration` payload."""
if len(payload) != 15:
raise ParseError(f"DeviceConfiguration payload length mismatch: expected 15, got {len(payload)}")
values: dict[str, int | float | bool]
if family == HardwareFamily.V1:
if1, adc_prescaler, dft_phase_inc, pll_delay = _DEVICE_CONFIG_V1_STRUCT.unpack(payload[:8])
values = {
"IF1": int(if1),
"ADCprescaler": int(adc_prescaler),
"DFTphaseInc": int(dft_phase_inc),
"PLLSettlingDelay": int(pll_delay),
}
elif family == HardwareFamily.VFF:
ip, mask, gw, flags1, flags2 = _DEVICE_CONFIG_VFF_STRUCT.unpack(payload)
values = {
"ip": int(ip),
"mask": int(mask),
"gw": int(gw),
"dhcp": bool(flags1 & 0x01),
"autogain": bool(flags2 & 0x01),
"portGain": int((flags2 >> 1) & 0x0F),
"refGain": int((flags2 >> 5) & 0x0F),
}
elif family == HardwareFamily.VFE:
(flags,) = _DEVICE_CONFIG_VFE_STRUCT.unpack(payload[:2])
values = {
"autogain": bool(flags & 0x01),
"portGain": int((flags >> 1) & 0x0F),
"refGain": int((flags >> 5) & 0x0F),
}
elif family in {HardwareFamily.VD0, HardwareFamily.VE0}:
dft_phase_inc, adc_rate, pll_delay = _DEVICE_CONFIG_VD0_STRUCT.unpack(payload[:7])
values = {
"DFTphaseInc": int(dft_phase_inc),
"ADCrate": int(adc_rate),
"PLLSettlingDelay": int(pll_delay),
}
else:
raise UnsupportedHardwareError(f"Unsupported hardware family for DeviceConfiguration: {family!r}")
return DeviceConfigVariant(family=family, values=values)
def encode_device_config_payload(config: DeviceConfigVariant) -> bytes:
"""Encode family-specific `DeviceConfiguration` payload."""
family = config.family
values = config.values
def _require_value(key: str) -> int | float | bool:
"""Fetch required device-config value by key or raise parse error."""
if key not in values:
raise ParseError(f"Missing required device configuration field: '{key}'")
return values[key]
if family == HardwareFamily.V1:
payload = _DEVICE_CONFIG_V1_STRUCT.pack(
int(_require_value("IF1")),
int(_require_value("ADCprescaler")),
int(_require_value("DFTphaseInc")),
int(_require_value("PLLSettlingDelay")),
)
elif family == HardwareFamily.VFF:
flags1 = int(bool(_require_value("dhcp")))
flags2 = 0
flags2 |= int(bool(_require_value("autogain"))) << 0
flags2 |= (int(_require_value("portGain")) & 0x0F) << 1
flags2 |= (int(_require_value("refGain")) & 0x0F) << 5
payload = _DEVICE_CONFIG_VFF_STRUCT.pack(
int(_require_value("ip")),
int(_require_value("mask")),
int(_require_value("gw")),
flags1,
flags2,
)
elif family == HardwareFamily.VFE:
flags = 0
flags |= int(bool(_require_value("autogain"))) << 0
flags |= (int(_require_value("portGain")) & 0x0F) << 1
flags |= (int(_require_value("refGain")) & 0x0F) << 5
payload = _DEVICE_CONFIG_VFE_STRUCT.pack(flags)
elif family in {HardwareFamily.VD0, HardwareFamily.VE0}:
payload = _DEVICE_CONFIG_VD0_STRUCT.pack(
int(_require_value("DFTphaseInc")),
int(_require_value("ADCrate")),
int(_require_value("PLLSettlingDelay")),
)
else:
raise UnsupportedHardwareError(f"Unsupported hardware family for DeviceConfiguration: {family!r}")
return payload.ljust(15, b"\x00")
def encode_packet_payload(packet_type: PacketType, payload: object) -> bytes:
"""Generic payload encoder for `LibreVNASession.send()` raw API."""
if payload is None:
return b""
if isinstance(payload, (bytes, bytearray, memoryview)):
return bytes(payload)
if packet_type == PacketType.SWEEP_SETTINGS and isinstance(payload, VNASweepSettings):
return encode_sweep_settings_payload(payload)
if packet_type == PacketType.DEVICE_CONFIGURATION and isinstance(payload, DeviceConfigVariant):
return encode_device_config_payload(payload)
logger.error(
"Unsupported payload object for packet %s: %s",
packet_type.name,
type(payload).__name__,
)
raise TypeError(f"Unsupported payload object for packet {packet_type.name}: {type(payload).__name__}")
NO_PAYLOAD_PACKET_TYPES = {
PacketType.ACK,
PacketType.NACK,
PacketType.REQUEST_DEVICE_INFO,
PacketType.REQUEST_DEVICE_CONFIGURATION,
PacketType.REQUEST_DEVICE_STATUS,
PacketType.INITIATE_SWEEP,
PacketType.RESET_DEVICE_CONFIGURATION,
}
def ensure_no_payload_types(packet_type: PacketType, payload: bytes) -> None:
"""Validate empty payload requirement for no-payload packet types."""
if packet_type in NO_PAYLOAD_PACKET_TYPES and payload:
logger.error("No-payload packet %s received payload length %d", packet_type.name, len(payload))
raise ParseError(f"Packet {packet_type.name} does not support payload")
@@ -0,0 +1,10 @@
"""CRC32 helpers for LibreVNA packet framing."""
from __future__ import annotations
import zlib
def crc32(data: bytes | bytearray | memoryview) -> int:
"""Compute protocol CRC32 over packet bytes excluding trailing CRC field."""
return zlib.crc32(data) & 0xFFFFFFFF
@@ -0,0 +1,123 @@
"""Packet frame encoding/decoding for LibreVNA protocol stream."""
from __future__ import annotations
import logging
import struct
from ..enums import PacketType
from ..exceptions import CRCError, ParseError
from ..models import Packet
from .crc32 import crc32
logger = logging.getLogger(__name__)
_HEADER = 0x5A
_FRAME_OVERHEAD = 8 # header + length + type + crc
_MAX_FRAME_LENGTH = 4096
_NO_CRC_PACKET_TYPES = {
PacketType.VNA_DATAPOINT,
}
def encode_frame(packet: Packet) -> bytes:
"""Encode one packet into framed wire format."""
payload = packet.payload
if not isinstance(payload, (bytes, bytearray, memoryview)):
raise TypeError("Packet payload must be bytes-like")
payload_bytes = bytes(payload)
length = _FRAME_OVERHEAD + len(payload_bytes)
if length > 0xFFFF:
raise ValueError("Packet is too large for protocol frame length field")
frame = bytearray(length)
frame[0] = _HEADER
struct.pack_into("<H", frame, 1, length)
frame[3] = int(packet.type)
frame[4 : 4 + len(payload_bytes)] = payload_bytes
crc_value = 0
if packet.type not in _NO_CRC_PACKET_TYPES:
crc_value = crc32(frame[:-4])
struct.pack_into("<I", frame, length - 4, crc_value)
return bytes(frame)
def decode_frame(frame: bytes) -> Packet:
"""Decode and validate one complete frame."""
if len(frame) < _FRAME_OVERHEAD:
raise ParseError("Frame is too short")
if frame[0] != _HEADER:
raise ParseError("Invalid frame header")
(length,) = struct.unpack_from("<H", frame, 1)
if length != len(frame):
raise ParseError(f"Frame length mismatch: declared {length}, got {len(frame)}")
packet_type_raw = frame[3]
try:
packet_type = PacketType(packet_type_raw)
except ValueError as exc:
raise ParseError(f"Unknown packet type id {packet_type_raw}") from exc
(received_crc,) = struct.unpack_from("<I", frame, length - 4)
if packet_type in _NO_CRC_PACKET_TYPES:
if received_crc != 0:
raise CRCError("VNADatapoint packet must carry zero CRC")
else:
computed_crc = crc32(frame[:-4])
if received_crc != computed_crc:
raise CRCError(
f"CRC mismatch for packet {packet_type.name}: "
f"received 0x{received_crc:08X}, computed 0x{computed_crc:08X}"
)
return Packet(type=packet_type, payload=frame[4:-4])
class FrameScanner:
"""Incremental frame scanner for raw USB byte streams."""
def __init__(self) -> None:
"""Initialize internal undecoded byte buffer."""
self._buffer = bytearray()
def clear(self) -> None:
"""Drop all buffered undecoded bytes."""
self._buffer.clear()
def feed(self, chunk: bytes) -> list[Packet]:
"""Feed raw bytes and return every fully decoded packet."""
if not chunk:
return []
self._buffer.extend(chunk)
decoded: list[Packet] = []
while True:
header_index = self._buffer.find(_HEADER)
if header_index < 0:
self._buffer.clear()
break
if header_index > 0:
del self._buffer[:header_index]
if len(self._buffer) < 4:
break
(length,) = struct.unpack_from("<H", self._buffer, 1)
if length < _FRAME_OVERHEAD or length > _MAX_FRAME_LENGTH:
logger.debug("Discarding byte due to invalid frame length=%d", length)
del self._buffer[0]
continue
if len(self._buffer) < length:
break
frame = bytes(self._buffer[:length])
del self._buffer[:length]
decoded.append(decode_frame(frame))
return decoded
@@ -0,0 +1,89 @@
"""ctypes layouts for protocol status unions used by the driver."""
from __future__ import annotations
import ctypes
class DeviceStatusV1(ctypes.LittleEndianStructure):
"""Device status bit layout for V1 family."""
_pack_ = 1
_fields_ = [
("extRefAvailable", ctypes.c_uint8, 1),
("extRefInUse", ctypes.c_uint8, 1),
("FPGA_configured", ctypes.c_uint8, 1),
("source_locked", ctypes.c_uint8, 1),
("LO1_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 1),
("temp_source", ctypes.c_uint8),
("temp_LO1", ctypes.c_uint8),
("temp_MCU", ctypes.c_uint8),
]
class DeviceStatusVFF(ctypes.LittleEndianStructure):
"""Device status bit layout for VFF family."""
_pack_ = 1
_fields_ = [
("source_locked", ctypes.c_uint8, 1),
("LO_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 4),
("temp_MCU", ctypes.c_uint8),
]
class DeviceStatusVFE(ctypes.LittleEndianStructure):
"""Device status bit layout for VFE family."""
_pack_ = 1
_fields_ = [
("source_locked", ctypes.c_uint8, 1),
("LO_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 4),
("temp_MCU", ctypes.c_uint8),
("temp_eCal", ctypes.c_uint16),
("power_heater", ctypes.c_uint16),
]
class DeviceStatusVD0(ctypes.LittleEndianStructure):
"""Device status bit layout for VD0/VE0 families."""
_pack_ = 1
_fields_ = [
("extRefAvailable", ctypes.c_uint8, 1),
("extRefInUse", ctypes.c_uint8, 1),
("FPGA_configured", ctypes.c_uint8, 1),
("source_locked", ctypes.c_uint8, 1),
("LO_locked", ctypes.c_uint8, 1),
("ADC_overload", ctypes.c_uint8, 1),
("unlevel", ctypes.c_uint8, 1),
("_unused", ctypes.c_uint8, 1),
("temp_MCU", ctypes.c_uint8),
("supply_voltage", ctypes.c_uint16),
("supply_current", ctypes.c_uint16),
]
class DeviceStatusUnion(ctypes.Union):
"""6-byte device-status union shared across hardware families."""
_pack_ = 1
_fields_ = [
("V1", DeviceStatusV1),
("VFF", DeviceStatusVFF),
("VFE", DeviceStatusVFE),
("VD0", DeviceStatusVD0),
("raw", ctypes.c_uint8 * 6),
]
assert ctypes.sizeof(DeviceStatusUnion) == 6
@@ -0,0 +1,424 @@
"""Session orchestration for direct-USB LibreVNA protocol communication."""
from __future__ import annotations
from collections import defaultdict, deque
import logging
import threading
import time
from typing import Callable, Protocol, TypeVar
from .enums import HardwareFamily, PacketType, SyncMode
from .exceptions import (
DeviceDisconnectedError,
NackError,
ParseError,
ProtocolVersionMismatch,
TimeoutError,
)
from .models import DeviceInfo, DeviceStatus, Packet, StreamHandle, USBDeviceDescriptor
from .protocol import (
FrameScanner,
decode_packet_payload,
encode_frame,
encode_packet_payload,
ensure_no_payload_types,
parse_device_status,
)
from .transport import USBTransport
logger = logging.getLogger(__name__)
class _IndexedPayload(Protocol):
"""Protocol for payload types carrying an integer `point_number` field."""
point_number: int
TPacketPayload = TypeVar("TPacketPayload", bound=_IndexedPayload)
class LibreVNASession:
"""Owns USB transport, packet queues, and request/response synchronization."""
def __init__(self) -> None:
"""Initialize transport, queues, synchronization primitives, and defaults."""
self._scanner = FrameScanner()
self._transport = USBTransport(
on_data=self._on_transport_data,
on_disconnect=self._on_transport_disconnect,
)
self._incoming: dict[PacketType, deque[Packet]] = defaultdict(deque)
self._subscribers: dict[PacketType, set[Callable[[Packet], None]]] = defaultdict(set)
self._incoming_cv = threading.Condition()
self._ack_cv = threading.Condition()
self._send_lock = threading.Lock()
self._awaiting_ack = False
self._ack_result: PacketType | None = None
self._fatal_error: Exception | None = None
self._device_info: DeviceInfo | None = None
self._device_status: DeviceStatus | None = None
self._hardware_family: HardwareFamily = HardwareFamily.UNKNOWN
self._default_sync_mode = SyncMode.DISABLED
self._default_sync_master = False
@property
def is_connected(self) -> bool:
"""Return `True` when USB connection is active."""
return self._transport.is_connected
@property
def connected_serial(self) -> str | None:
"""Serial number of currently connected device, when available."""
return self._transport.connected_serial
@property
def hardware_family(self) -> HardwareFamily:
"""Hardware family inferred from `DeviceInfo` packet."""
return self._hardware_family
@property
def default_sync_mode(self) -> SyncMode:
"""Default sync mode used by controllers when settings do not override."""
return self._default_sync_mode
@property
def default_sync_master(self) -> bool:
"""Default sync master flag used by controllers."""
return self._default_sync_master
def set_default_sync(self, mode: SyncMode, master: bool) -> None:
"""Set session-level default synchronization settings."""
self._default_sync_mode = mode
self._default_sync_master = master
@staticmethod
def list_devices() -> list[USBDeviceDescriptor]:
"""Enumerate available direct-USB LibreVNA devices."""
return USBTransport.list_devices()
def connect(
self,
serial: str | None = None,
*,
strict_protocol_version: int = 14,
timeout_s: float = 1.0,
) -> None:
"""Connect transport and perform startup handshake."""
logger.info(
"Opening USB transport (serial=%s, strict_protocol=%d, timeout=%.2fs)",
serial,
strict_protocol_version,
timeout_s,
)
self._fatal_error = None
self._scanner.clear()
self._incoming.clear()
self._transport.connect(serial=serial, timeout_s=timeout_s)
try:
info_packet = self.request(
PacketType.REQUEST_DEVICE_INFO,
PacketType.DEVICE_INFO,
timeout_s=timeout_s,
)
if not isinstance(info_packet.payload, DeviceInfo):
raise ParseError("Decoded DeviceInfo payload has unexpected type")
self._device_info = info_packet.payload
self._hardware_family = self._device_info.hardware_family
logger.info(
"Handshake OK: fw=%s protocol=%d family=%s ports=%d",
self._device_info.firmware_version,
self._device_info.protocol_version,
self._device_info.hardware_family.name,
self._device_info.num_ports,
)
if self._device_info.protocol_version != strict_protocol_version:
raise ProtocolVersionMismatch(
"Protocol version mismatch: "
f"device={self._device_info.protocol_version}, "
f"required={strict_protocol_version}"
)
self.get_device_status(timeout_s=timeout_s)
logger.debug("Initial device status request completed")
except Exception:
logger.exception("Connect handshake failed, closing session")
self.disconnect()
raise
def disconnect(self) -> None:
"""Disconnect USB transport and clear session state."""
logger.debug("Closing USB transport and clearing session queues")
self._transport.disconnect()
with self._incoming_cv:
self._incoming.clear()
self._subscribers.clear()
self._incoming_cv.notify_all()
with self._ack_cv:
self._awaiting_ack = False
self._ack_result = None
self._ack_cv.notify_all()
def send(self, packet: Packet, *, require_ack: bool = True, timeout_s: float = 0.5) -> None:
"""Send one protocol packet and optionally wait for ACK/NACK."""
payload = encode_packet_payload(packet.type, packet.payload)
ensure_no_payload_types(packet.type, payload)
frame = encode_frame(Packet(type=packet.type, payload=payload))
logger.debug(
"TX packet=%s payload=%dB require_ack=%s timeout=%.2fs",
packet.type.name,
len(payload),
require_ack,
timeout_s,
)
with self._send_lock:
if require_ack:
with self._ack_cv:
self._awaiting_ack = True
self._ack_result = None
self._transport.write(frame, timeout_s=timeout_s)
if require_ack:
deadline = time.monotonic() + timeout_s
with self._ack_cv:
while self._ack_result is None:
self._raise_if_fatal_locked()
remaining = deadline - time.monotonic()
if remaining <= 0:
self._awaiting_ack = False
raise TimeoutError(f"Timeout waiting for ACK for packet {packet.type.name}")
self._ack_cv.wait(timeout=remaining)
result = self._ack_result
self._awaiting_ack = False
self._ack_result = None
if result == PacketType.NACK:
raise NackError(f"Received NACK for packet {packet.type.name}")
logger.debug("ACK received for packet %s", packet.type.name)
def request(
self,
packet_type: PacketType,
response_type: PacketType,
*,
timeout_s: float = 1.0,
) -> Packet:
"""Send no-payload request packet and wait for one response packet type."""
logger.debug(
"Request start: packet=%s expect=%s timeout=%.2fs",
packet_type.name,
response_type.name,
timeout_s,
)
self.clear_queue(response_type)
self.send(Packet(type=packet_type), require_ack=True, timeout_s=timeout_s)
packet = self.wait_for_packet(response_type, timeout_s=timeout_s)
logger.debug("Request complete: received %s", response_type.name)
return packet
def collect_indexed_payloads(
self,
*,
packet_type: PacketType,
expected_points: int,
timeout_s: float,
payload_type: type[TPacketPayload],
payload_error: str,
) -> list[TPacketPayload]:
"""Collect indexed payloads by `point_number` into ascending order.
Missing points are omitted; callers decide whether this is acceptable.
"""
if expected_points <= 0:
raise ValueError("expected_points must be > 0")
deadline = time.monotonic() + timeout_s
collected: list[TPacketPayload | None] = [None] * expected_points
received = 0
while received < expected_points:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
packet = self.wait_for_packet(packet_type, timeout_s=remaining)
payload = packet.payload
if not isinstance(payload, payload_type):
raise ParseError(payload_error)
point_number = payload.point_number
if point_number < 0 or point_number >= expected_points:
raise ParseError(
f"Received out-of-range point index {point_number}, expected 0..{expected_points - 1}"
)
if collected[point_number] is None:
received += 1
collected[point_number] = payload
return [item for item in collected if item is not None]
def wait_for_packet(self, packet_type: PacketType, *, timeout_s: float = 1.0) -> Packet:
"""Wait for the next packet of requested type."""
deadline = time.monotonic() + timeout_s
with self._incoming_cv:
while True:
self._raise_if_fatal_locked()
queue = self._incoming[packet_type]
if queue:
logger.debug(
"Dequeued packet %s (remaining=%d)",
packet_type.name,
len(queue) - 1,
)
return queue.popleft()
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Timeout waiting for packet {packet_type.name}")
self._incoming_cv.wait(timeout=remaining)
def clear_queue(self, packet_type: PacketType) -> None:
"""Drop queued packets of a given type."""
with self._incoming_cv:
dropped = len(self._incoming[packet_type])
self._incoming[packet_type].clear()
if dropped:
logger.debug("Cleared %d queued packet(s) of type %s", dropped, packet_type.name)
def get_device_info(self) -> DeviceInfo:
"""Return cached `DeviceInfo` from successful `connect()` handshake."""
if self._device_info is None:
raise DeviceDisconnectedError("Device info is not available before connect()")
return self._device_info
def get_device_status(self, *, timeout_s: float = 1.0) -> DeviceStatus:
"""Request and return current `DeviceStatus`."""
packet = self.request(
PacketType.REQUEST_DEVICE_STATUS,
PacketType.DEVICE_STATUS,
timeout_s=timeout_s,
)
if not isinstance(packet.payload, (bytes, bytearray, memoryview)):
raise ParseError("DeviceStatus packet payload has unexpected type")
status = parse_device_status(bytes(packet.payload), self._hardware_family)
self._device_status = status
logger.debug(
"Device status: source_locked=%s lo_locked=%s adc_overload=%s unlevel=%s",
status.source_locked,
status.lo_locked,
status.adc_overload,
status.unlevel,
)
return status
def subscribe(self, packet_type: PacketType, callback: Callable[[Packet], None]) -> StreamHandle:
"""Register packet callback and return handle for unsubscription."""
stop_event = threading.Event()
with self._incoming_cv:
self._subscribers[packet_type].add(callback)
logger.debug(
"Subscriber added for %s (count=%d)",
packet_type.name,
len(self._subscribers[packet_type]),
)
def _close() -> None:
"""Unsubscribe callback from session packet subscribers."""
with self._incoming_cv:
callbacks = self._subscribers.get(packet_type)
if callbacks is not None:
callbacks.discard(callback)
logger.debug(
"Subscriber removed for %s (count=%d)",
packet_type.name,
len(callbacks),
)
return StreamHandle(stop_event=stop_event, close_callback=_close)
def _on_transport_data(self, chunk: bytes) -> None:
"""Decode incoming USB chunk and route packets to queues/subscribers."""
try:
packets = self._scanner.feed(chunk)
if logger.isEnabledFor(logging.DEBUG) and packets:
logger.debug("RX chunk=%dB decoded_packets=%d", len(chunk), len(packets))
except Exception as exc:
self._set_fatal_error(exc)
return
for packet in packets:
try:
decoded_payload = decode_packet_payload(packet)
except Exception as exc:
self._set_fatal_error(exc)
return
decoded_packet = Packet(type=packet.type, payload=decoded_payload)
self._dispatch_packet(decoded_packet)
def _on_transport_disconnect(self, exc: Exception) -> None:
"""Receive asynchronous transport disconnect event."""
self._set_fatal_error(exc)
def _dispatch_packet(self, packet: Packet) -> None:
"""Route packet into ack waiter, queue, and subscriber callbacks."""
if logger.isEnabledFor(logging.DEBUG) and packet.type not in {
PacketType.VNA_DATAPOINT,
}:
logger.debug("Dispatch packet %s", packet.type.name)
if packet.type in {PacketType.ACK, PacketType.NACK}:
with self._ack_cv:
if self._awaiting_ack and self._ack_result is None:
self._ack_result = packet.type
self._ack_cv.notify_all()
return
callbacks: list[Callable[[Packet], None]] = []
with self._incoming_cv:
self._incoming[packet.type].append(packet)
callbacks = list(self._subscribers.get(packet.type, set()))
self._incoming_cv.notify_all()
for callback in callbacks:
try:
callback(packet)
except Exception as exc:
logger.exception("Subscriber callback failed for packet %s", packet.type.name)
self._set_fatal_error(exc)
return
def _set_fatal_error(self, exc: Exception) -> None:
"""Mark session as failed and wake all waiting operations."""
with self._incoming_cv:
with self._ack_cv:
if self._fatal_error is None:
self._fatal_error = exc
logger.error("Session fatal error: %s", exc, exc_info=exc)
self._incoming_cv.notify_all()
self._ack_cv.notify_all()
def _raise_if_fatal_locked(self) -> None:
"""Raise stored fatal error (called while condition lock is held)."""
if self._fatal_error is None:
return
exc = self._fatal_error
if isinstance(exc, DeviceDisconnectedError):
raise exc
raise DeviceDisconnectedError(f"Session stopped due to transport/protocol error: {exc}") from exc
@@ -0,0 +1,10 @@
"""Sweep assembly and result helpers."""
from .assembler import assemble_vna_sweep, datapoint_to_raw_point
from ..models import SweepResult
__all__ = [
"SweepResult",
"assemble_vna_sweep",
"datapoint_to_raw_point",
]
@@ -0,0 +1,181 @@
"""Helpers to assemble high-level sweep results from packet streams."""
from __future__ import annotations
import logging
import math
from typing import Iterable
import numpy as np
from ..exceptions import IncompleteSweepError, ParseError
from ..enums import SweepKind
from ..models import VNADatapointPacket, VNARawPoint, VNASweepSettings, SweepResult
logger = logging.getLogger(__name__)
_REFERENCE_FLAG = 0x10
def _extract_stage(flags: int) -> int:
"""Return stage index encoded inside datapoint flag byte."""
return flags >> 5
def _find_vna_value(
datapoint: VNADatapointPacket,
*,
stage: int,
port_index: int,
reference: bool,
) -> complex:
"""Find one complex receiver value for stage/port/ref tuple."""
source_mask = 1 << port_index
if reference:
source_mask |= _REFERENCE_FLAG
for idx, flags in enumerate(datapoint.flags):
if _extract_stage(int(flags)) != stage:
continue
if (int(flags) & source_mask) != source_mask:
continue
return complex(float(datapoint.real[idx]), float(datapoint.imag[idx]))
kind = "reference" if reference else "receiver"
raise ParseError(
f"Missing {kind} value for stage={stage}, port_index={port_index}, point={datapoint.point_number}"
)
def datapoint_to_raw_point(
datapoint: VNADatapointPacket,
settings: VNASweepSettings,
*,
num_ports: int,
) -> VNARawPoint:
"""Convert low-level VNADatapoint packet into callback-friendly object."""
measurements: dict[str, complex] = {}
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
for excited_port, stage in stage_by_excited_port.items():
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
for receiver_port in range(1, num_ports + 1):
measured = _find_vna_value(
datapoint,
stage=stage,
port_index=receiver_port - 1,
reference=False,
)
measurements[f"S{receiver_port}{excited_port}"] = measured / ref
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
settings.power_start_dbm,
settings.power_stop_dbm,
rel_tol=0.0,
abs_tol=0.0,
)
if zero_span:
return VNARawPoint(
point_number=datapoint.point_number,
frequency_hz=None,
time_s=float(datapoint.frequency_or_time) * 1e-6,
power_dbm=None,
measurements=measurements,
)
return VNARawPoint(
point_number=datapoint.point_number,
frequency_hz=float(datapoint.frequency_or_time),
time_s=None,
power_dbm=float(datapoint.cdbm) / 100.0,
measurements=measurements,
)
def assemble_vna_sweep(
datapoints: Iterable[VNADatapointPacket],
settings: VNASweepSettings,
*,
num_ports: int,
expected_points: int,
) -> SweepResult:
"""Build a complete VNA sweep from raw datapoints."""
if expected_points <= 0:
raise ValueError("expected_points must be > 0")
x = np.empty(expected_points, dtype=np.float64)
seen = np.zeros(expected_points, dtype=bool)
stage_by_excited_port = {port: stage for stage, port in enumerate(settings.excited_ports)}
receiver_limit = min(num_ports, 2)
trace_names = [
f"s{receiver_port}{excited_port}"
for excited_port in settings.excited_ports
for receiver_port in range(1, receiver_limit + 1)
if receiver_port <= 2 and excited_port <= 2
]
traces: dict[str, np.ndarray] = {
name: np.empty(expected_points, dtype=np.complex128) for name in trace_names
}
zero_span = settings.f_start_hz == settings.f_stop_hz and math.isclose(
settings.power_start_dbm,
settings.power_stop_dbm,
rel_tol=0.0,
abs_tol=0.0,
)
power_sweep = settings.kind == SweepKind.POWER
for datapoint in datapoints:
idx = datapoint.point_number
if idx >= expected_points:
raise ParseError(f"Received out-of-range point index {idx}, expected < {expected_points}")
if zero_span:
x[idx] = float(datapoint.frequency_or_time) * 1e-6
elif power_sweep:
x[idx] = float(datapoint.cdbm) / 100.0
else:
x[idx] = float(datapoint.frequency_or_time)
for excited_port, stage in stage_by_excited_port.items():
ref = _find_vna_value(datapoint, stage=stage, port_index=excited_port - 1, reference=True)
for receiver_port in range(1, num_ports + 1):
if receiver_port > 2 or excited_port > 2:
# Public SweepResult intentionally exposes 2-port canonical traces.
continue
measured = _find_vna_value(
datapoint,
stage=stage,
port_index=receiver_port - 1,
reference=False,
)
trace_name = f"s{receiver_port}{excited_port}"
trace = traces.get(trace_name)
if trace is not None:
trace[idx] = measured / ref
seen[idx] = True
missing = np.flatnonzero(~seen)
if missing.size > 0:
raise IncompleteSweepError(
f"Sweep is incomplete: received {int(seen.sum())}/{expected_points} points"
)
x_label = "frequency_hz"
if zero_span:
x_label = "time_s"
elif power_sweep:
x_label = "power_dbm"
result = SweepResult(x=x, traces=traces, x_label=x_label)
logger.debug(
"Assembled VNA sweep: points=%d x_label=%s traces=%s",
len(result.x),
result.x_label,
sorted(result.traces.keys()),
)
return result
@@ -0,0 +1,5 @@
"""Transport layer primitives."""
from .usb import USBTransport
__all__ = ["USBTransport"]
@@ -0,0 +1,244 @@
"""Direct USB transport using libusb1 for LibreVNA devices."""
from __future__ import annotations
from contextlib import suppress
import logging
import threading
from typing import Callable
from ..exceptions import DeviceDisconnectedError, TimeoutError
from ..models import USBDeviceDescriptor
import usb1
logger = logging.getLogger(__name__)
class USBTransport:
"""USB bulk transport for LibreVNA protocol endpoints."""
DATA_EP_OUT = 0x01
DATA_EP_IN = 0x81
INTERFACE = 0
VALID_USB_IDS = (
(0x0483, 0x564E),
(0x0483, 0x4121),
(0x1209, 0x4121),
)
def __init__(
self,
*,
on_data: Callable[[bytes], None],
on_disconnect: Callable[[Exception], None] | None = None,
read_chunk_size: int = 65536,
) -> None:
"""Create transport with RX callback and optional disconnect callback."""
self._on_data = on_data
self._on_disconnect = on_disconnect
self._read_chunk_size = read_chunk_size
self._ctx: usb1.USBContext | None = None # type: ignore[valid-type]
self._handle: usb1.USBDeviceHandle | None = None # type: ignore[valid-type]
self._rx_thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._tx_lock = threading.Lock()
self.connected_serial: str | None = None
@property
def is_connected(self) -> bool:
"""Return `True` when USB handle is open."""
return self._handle is not None
@staticmethod
def list_devices() -> list[USBDeviceDescriptor]:
"""Enumerate attached LibreVNA USB devices."""
devices: list[USBDeviceDescriptor] = []
with usb1.USBContext() as ctx:
for device, vid, pid in USBTransport._iter_matching_devices(ctx):
handle: usb1.USBDeviceHandle | None = None
try:
handle = device.open()
serial = handle.getSerialNumber() or ""
except usb1.USBError as exc:
logger.debug(
"Skipping USB device during discovery vid=0x%04x pid=0x%04x: %s",
vid,
pid,
exc,
)
continue
finally:
if handle is not None:
with suppress(usb1.USBError):
handle.close()
devices.append(USBDeviceDescriptor(serial=serial, vendor_id=vid, product_id=pid))
devices.sort(key=lambda item: (item.serial, item.vendor_id, item.product_id))
logger.debug("USB discovery completed, devices=%d", len(devices))
return devices
def connect(self, *, serial: str | None = None, timeout_s: float = 1.0) -> None:
"""Open USB device, claim interface, and start RX thread."""
if self._handle is not None:
logger.debug("USB connect skipped: already connected")
return
logger.debug("Opening libusb context for connect(serial=%s)", serial)
self._ctx = usb1.USBContext()
selected_handle: usb1.USBDeviceHandle | None = None
selected_serial = ""
for device, _, _ in self._iter_matching_devices(self._ctx):
handle: usb1.USBDeviceHandle | None = None
try:
handle = device.open()
found_serial = handle.getSerialNumber() or ""
if serial is not None and found_serial != serial:
handle.close()
continue
selected_handle = handle
selected_serial = found_serial
break
except usb1.USBError as exc:
if handle is not None:
with suppress(usb1.USBError):
handle.close()
logger.debug("Skipping USB candidate during connect due to error: %s", exc)
continue
if selected_handle is None:
if self._ctx is not None:
self._ctx.close()
self._ctx = None
serial_msg = f" with serial '{serial}'" if serial else ""
raise DeviceDisconnectedError(f"No compatible LibreVNA USB device found{serial_msg}")
try:
selected_handle.setAutoDetachKernelDriver(True)
if selected_handle.kernelDriverActive(self.INTERFACE):
selected_handle.detachKernelDriver(self.INTERFACE)
except usb1.USBError as exc:
selected_handle.close()
if self._ctx is not None:
self._ctx.close()
self._ctx = None
raise DeviceDisconnectedError(f"Failed to prepare USB kernel driver state: {exc}") from exc
try:
selected_handle.claimInterface(self.INTERFACE)
except usb1.USBError as exc:
selected_handle.close()
if self._ctx is not None:
self._ctx.close()
self._ctx = None
raise DeviceDisconnectedError(f"Failed to claim USB interface {self.INTERFACE}: {exc}") from exc
self._handle = selected_handle
self.connected_serial = selected_serial
self._stop_event.clear()
logger.info(
"USB connected (serial=%s, timeout=%.2fs, endpoint_out=0x%02x, endpoint_in=0x%02x)",
self.connected_serial,
timeout_s,
self.DATA_EP_OUT,
self.DATA_EP_IN,
)
self._rx_thread = threading.Thread(target=self._rx_loop, name="librevna-usb-rx", daemon=True)
self._rx_thread.start()
def disconnect(self) -> None:
"""Stop RX thread and close USB resources."""
logger.debug("USB disconnect requested")
self._stop_event.set()
if self._rx_thread is not None and self._rx_thread.is_alive():
self._rx_thread.join(timeout=1.0)
self._rx_thread = None
if self._handle is not None:
self._handle.releaseInterface(self.INTERFACE)
self._handle.close()
self._handle = None
if self._ctx is not None:
self._ctx.close()
self._ctx = None
logger.info("USB disconnected (serial=%s)", self.connected_serial)
self.connected_serial = None
def write(self, data: bytes, *, timeout_s: float = 0.5) -> None:
"""Write one framed packet to device bulk-out endpoint."""
handle = self._handle
if handle is None:
raise DeviceDisconnectedError("USB device is not connected")
timeout_ms = max(1, int(timeout_s * 1000.0))
with self._tx_lock:
try:
written = handle.bulkWrite(self.DATA_EP_OUT, data, timeout=timeout_ms)
except usb1.USBErrorTimeout as exc:
raise TimeoutError("Timed out writing USB bulk packet") from exc
except usb1.USBErrorNoDevice as exc:
raise DeviceDisconnectedError("USB device disconnected during write") from exc
except usb1.USBError as exc:
raise DeviceDisconnectedError(f"USB write failed: {exc}") from exc
if written != len(data):
raise DeviceDisconnectedError(
f"USB bulk write incomplete: wrote {written}/{len(data)} bytes"
)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("USB TX %d bytes", len(data))
def _rx_loop(self) -> None:
"""Continuously read bulk-in data and forward to frame scanner callback."""
handle = self._handle
if handle is None:
return
logger.debug("USB RX thread started")
timeout_ms = 100
while not self._stop_event.is_set():
try:
data = handle.bulkRead(self.DATA_EP_IN, self._read_chunk_size, timeout=timeout_ms)
except usb1.USBErrorTimeout:
continue
except usb1.USBErrorInterrupted:
continue
except usb1.USBErrorNoDevice as exc:
if self._on_disconnect is not None:
self._on_disconnect(DeviceDisconnectedError("USB device disconnected"))
logger.warning("USB RX stopped: device disconnected")
return
except usb1.USBError as exc:
if self._stop_event.is_set():
return
if self._on_disconnect is not None:
self._on_disconnect(DeviceDisconnectedError(f"USB read failed: {exc}"))
logger.error("USB RX failed: %s", exc)
return
if data:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("USB RX %d bytes", len(data))
self._on_data(bytes(data))
logger.debug("USB RX thread stopped")
@classmethod
def _iter_matching_devices(
cls,
ctx: "usb1.USBContext", # type: ignore[name-defined]
):
"""Yield USB devices matching supported LibreVNA VID/PID pairs."""
for device in ctx.getDeviceList(skip_on_error=True):
vid = int(device.getVendorID())
pid = int(device.getProductID())
if (vid, pid) in cls.VALID_USB_IDS:
yield device, vid, pid
@@ -0,0 +1,98 @@
"""High-level orchestration service for configuring and querying LibreVNA."""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from python_app.hardware_full.librevna_backends import LibreVnaBackend, MockLibreVnaBackend, NativeLibreVnaBackend
from python_app.models.run_config_model import RadarSweepModel
@dataclass(slots=True)
class LibreVnaService:
"""Provide stable API for GUI/workflows while hiding backend details."""
serial: str | None = None
strict_protocol_version: int = 14
backend_mode: str = "auto"
_driver_available: bool = field(init=False, default=False, repr=False)
_backend: LibreVnaBackend | None = field(init=False, default=None, repr=False)
_using_mock_backend: bool = field(init=False, default=False, repr=False)
def __post_init__(self) -> None:
"""Initialize selected backend and detect driver availability."""
self._driver_available = False
self._backend = None
self._using_mock_backend = False
mode = self.backend_mode.strip().lower()
if mode not in {"auto", "native", "mock"}:
raise ValueError(f"Unsupported LibreVnaService backend mode: {self.backend_mode}")
if mode == "mock":
self._backend = MockLibreVnaBackend()
self._using_mock_backend = True
return
try:
self._backend = NativeLibreVnaBackend(
serial=self.serial,
strict_protocol_version=self.strict_protocol_version,
)
self._driver_available = True
except Exception:
if mode == "native":
raise
self._backend = MockLibreVnaBackend()
self._using_mock_backend = True
@property
def driver_available(self) -> bool:
"""Return `True` when native Python LibreVNA driver is available."""
return self._driver_available
def open(self) -> None:
"""Open backend resources."""
if not self._driver_available and not self._using_mock_backend:
return
if self._backend is None:
return
self._backend.open()
def close(self) -> None:
"""Close backend resources."""
if self._backend is None:
return
self._backend.close()
def configure(self, sweep: RadarSweepModel) -> None:
"""Apply sweep settings to active backend."""
if self._backend is None:
raise RuntimeError("LibreVNA backend is not initialized")
self._backend.configure(sweep)
def read_device_limits(self) -> dict[str, float | int]:
"""Read native device limits from connected LibreVNA."""
if not self._driver_available:
raise RuntimeError("LibreVNA Python driver is not available")
if self._backend is None:
raise RuntimeError("LibreVNA backend is not initialized")
opened_here = not self._backend.is_open
try:
if opened_here:
self.open()
return self._backend.read_device_limits()
finally:
if opened_here:
self.close()
def acquire_s21(self) -> tuple[np.ndarray, np.ndarray]:
"""Acquire one S21 trace from currently selected backend."""
if self._backend is None:
raise RuntimeError("LibreVNA backend is not initialized")
if self._using_mock_backend and not self._driver_available and self.backend_mode != "mock":
raise RuntimeError("Device not found")
return self._backend.acquire_s21()
@@ -0,0 +1,13 @@
"""Switch driver implementations for native GPIO and mock operation."""
from python_app.hardware_full.switch_drivers.h7992_driver import H7992Driver
from python_app.hardware_full.switch_drivers.hmc349a_driver import HMC349ADriver
from python_app.hardware_full.switch_drivers.interface import SwitchDriverProtocol
from python_app.hardware_full.switch_drivers.mock_driver import MockSwitchDriver
__all__ = [
"H7992Driver",
"HMC349ADriver",
"MockSwitchDriver",
"SwitchDriverProtocol",
]
@@ -0,0 +1,200 @@
"""Minimal Linux GPIO v2 UAPI wrapper for output-only line control."""
from __future__ import annotations
import ctypes
import errno
import fcntl
import os
from typing import Sequence
GPIO_MAX_NAME_SIZE = 32
GPIO_V2_LINES_MAX = 64
GPIO_V2_LINE_NUM_ATTRS_MAX = 10
GPIO_V2_LINE_FLAG_OUTPUT = 1 << 3
_IOC_NRBITS = 8
_IOC_TYPEBITS = 8
_IOC_SIZEBITS = 14
_IOC_DIRBITS = 2
_IOC_NRSHIFT = 0
_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS
_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS
_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS
_IOC_WRITE = 1
_IOC_READ = 2
def _ioc(direction: int, ioc_type: int, number: int, size: int) -> int:
"""Build raw ioctl command number."""
return (
(direction << _IOC_DIRSHIFT)
| (ioc_type << _IOC_TYPESHIFT)
| (number << _IOC_NRSHIFT)
| (size << _IOC_SIZESHIFT)
)
def _iowr(ioc_type: int, number: int, struct_type: type[ctypes.Structure]) -> int:
"""Build read-write ioctl number for provided structure."""
return _ioc(_IOC_READ | _IOC_WRITE, ioc_type, number, ctypes.sizeof(struct_type))
class GpioV2LineAttribute(ctypes.Structure):
"""ctypes mapping of `gpio_v2_line_attribute`."""
_fields_ = [
("id", ctypes.c_uint32),
("padding", ctypes.c_uint32),
("value", ctypes.c_uint64),
]
class GpioV2LineConfigAttribute(ctypes.Structure):
"""ctypes mapping of `gpio_v2_line_config_attribute`."""
_fields_ = [
("attr", GpioV2LineAttribute),
("mask", ctypes.c_uint64),
]
class GpioV2LineConfig(ctypes.Structure):
"""ctypes mapping of `gpio_v2_line_config`."""
_fields_ = [
("flags", ctypes.c_uint64),
("num_attrs", ctypes.c_uint32),
("padding", ctypes.c_uint32 * 5),
("attrs", GpioV2LineConfigAttribute * GPIO_V2_LINE_NUM_ATTRS_MAX),
]
class GpioV2LineRequest(ctypes.Structure):
"""ctypes mapping of `gpio_v2_line_request`."""
_fields_ = [
("offsets", ctypes.c_uint32 * GPIO_V2_LINES_MAX),
("consumer", ctypes.c_char * GPIO_MAX_NAME_SIZE),
("config", GpioV2LineConfig),
("num_lines", ctypes.c_uint32),
("event_buffer_size", ctypes.c_uint32),
("padding", ctypes.c_uint32 * 5),
("fd", ctypes.c_int32),
]
class GpioV2LineValues(ctypes.Structure):
"""ctypes mapping of `gpio_v2_line_values`."""
_fields_ = [
("bits", ctypes.c_uint64),
("mask", ctypes.c_uint64),
]
GPIO_V2_GET_LINE_IOCTL = _iowr(0xB4, 0x07, GpioV2LineRequest)
GPIO_V2_LINE_SET_VALUES_IOCTL = _iowr(0xB4, 0x0F, GpioV2LineValues)
class GpioOutputLines:
"""Open and control one or more GPIO output lines as a single request."""
def __init__(self, chip: str, offsets: Sequence[int], consumer: str) -> None:
"""Build GPIO line request descriptor."""
if not chip:
raise ValueError("gpio chip path must not be empty")
if not offsets:
raise ValueError("at least one GPIO line offset is required")
if len(offsets) > GPIO_V2_LINES_MAX:
raise ValueError(f"too many GPIO offsets requested: {len(offsets)}")
normalized_offsets = [int(offset) for offset in offsets]
if any(offset < 0 for offset in normalized_offsets):
raise ValueError("GPIO offsets must be non-negative")
if len(set(normalized_offsets)) != len(normalized_offsets):
raise ValueError("GPIO offsets must be unique")
self._chip = chip
self._offsets = normalized_offsets
self._consumer = (consumer or "radar_switch").encode("ascii", errors="ignore")[: GPIO_MAX_NAME_SIZE - 1]
self._chip_fd = -1
self._line_fd = -1
def open(self) -> None:
"""Open GPIO chip and request configured output lines."""
if self._line_fd >= 0:
return
try:
self._chip_fd = os.open(self._chip, os.O_RDONLY | os.O_CLOEXEC)
except OSError as exc:
raise RuntimeError(f"Failed to open GPIO chip '{self._chip}': {exc}") from exc
request = GpioV2LineRequest()
for index, offset in enumerate(self._offsets):
request.offsets[index] = ctypes.c_uint32(offset).value
request.num_lines = ctypes.c_uint32(len(self._offsets)).value
request.config.flags = ctypes.c_uint64(GPIO_V2_LINE_FLAG_OUTPUT).value
request.consumer = self._consumer
try:
fcntl.ioctl(self._chip_fd, GPIO_V2_GET_LINE_IOCTL, request)
except OSError as exc:
self._close_chip_fd()
raise RuntimeError(f"Failed to request GPIO lines on '{self._chip}': {exc}") from exc
if request.fd < 0:
self._close_chip_fd()
raise RuntimeError(f"GPIO line request returned invalid fd for '{self._chip}'")
self._line_fd = int(request.fd)
def close(self) -> None:
"""Close line request and chip file descriptors."""
self._close_line_fd()
self._close_chip_fd()
def set_values(self, values: Sequence[int]) -> None:
"""Apply output values for all requested lines."""
if self._line_fd < 0:
raise RuntimeError("GPIO line request is not open")
if len(values) != len(self._offsets):
raise ValueError(
f"GPIO values length mismatch: expected {len(self._offsets)}, got {len(values)}"
)
bits = 0
for index, value in enumerate(values):
normalized = int(value)
if normalized not in (0, 1):
raise ValueError(f"GPIO output value must be 0 or 1, got {value}")
if normalized == 1:
bits |= (1 << index)
mask = (1 << len(self._offsets)) - 1
line_values = GpioV2LineValues(bits=ctypes.c_uint64(bits).value, mask=ctypes.c_uint64(mask).value)
try:
fcntl.ioctl(self._line_fd, GPIO_V2_LINE_SET_VALUES_IOCTL, line_values)
except OSError as exc:
if exc.errno == errno.ENODEV:
raise RuntimeError("GPIO device disconnected") from exc
raise RuntimeError(f"Failed to set GPIO output values: {exc}") from exc
def _close_line_fd(self) -> None:
"""Close line file descriptor if currently open."""
if self._line_fd >= 0:
os.close(self._line_fd)
self._line_fd = -1
def _close_chip_fd(self) -> None:
"""Close chip file descriptor if currently open."""
if self._chip_fd >= 0:
os.close(self._chip_fd)
self._chip_fd = -1
@@ -0,0 +1,86 @@
"""Native GPIO driver for H7992 switch."""
from __future__ import annotations
from dataclasses import dataclass, field
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
_POSITION_TO_AB = (
(0, 0),
(0, 1),
(1, 0),
(1, 1),
)
@dataclass(slots=True)
class H7992Driver:
"""Drive H7992 using two GPIO lines (A/B)."""
name: str
positions: int = 4
default_position: int = 0
gpio_chip: str = "/dev/gpiochip0"
pin_a: int = 17
pin_b: int = 27
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
_is_open: bool = field(init=False, default=False, repr=False)
_current_position: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
"""Initialize runtime state."""
self._lines: GpioOutputLines | None = None
self._is_open = False
self._current_position = 0
def open(self) -> None:
"""Open GPIO lines and switch to default position."""
if self._is_open:
return
self._validate()
self._lines = GpioOutputLines(
chip=self.gpio_chip,
offsets=(self.pin_a, self.pin_b),
consumer=f"radar_{self.name}",
)
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
def close(self) -> None:
"""Close GPIO lines."""
if self._lines is not None:
self._lines.close()
self._lines = None
self._is_open = False
def position_count(self) -> int:
"""Return number of supported positions."""
return self.positions
def switch_to(self, position: int) -> None:
"""Switch hardware to selected position."""
if not self._is_open or self._lines is None:
raise RuntimeError(f"Switch driver is not open for {self.name}")
if position < 0 or position >= self.positions:
raise ValueError(f"Position out of range for {self.name}: {position}")
self._lines.set_values(_POSITION_TO_AB[position])
self._current_position = int(position)
@property
def current_position(self) -> int:
"""Return current position."""
return self._current_position
def _validate(self) -> None:
"""Validate H7992 driver parameters."""
if self.positions <= 0 or self.positions > 4:
raise ValueError(f"H7992 positions must be in range [1,4] for {self.name}")
if self.default_position < 0 or self.default_position >= self.positions:
raise ValueError(f"default_position out of range for {self.name}")
if self.pin_a < 0 or self.pin_b < 0 or self.pin_a == self.pin_b:
raise ValueError(f"pin_a/pin_b are invalid for {self.name}")
@@ -0,0 +1,84 @@
"""Native GPIO driver for HMC349A switch."""
from __future__ import annotations
from dataclasses import dataclass, field
from python_app.hardware_full.switch_drivers.gpio_uapi import GpioOutputLines
@dataclass(slots=True)
class HMC349ADriver:
"""Drive HMC349A using one control GPIO line."""
name: str
positions: int = 2
default_position: int = 0
gpio_chip: str = "/dev/gpiochip0"
pin_a: int = 17
invert_logic: bool = False
_lines: GpioOutputLines | None = field(init=False, default=None, repr=False)
_is_open: bool = field(init=False, default=False, repr=False)
_current_position: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
"""Initialize runtime state."""
self._lines: GpioOutputLines | None = None
self._is_open = False
self._current_position = 0
def open(self) -> None:
"""Open GPIO lines and switch to default position."""
if self._is_open:
return
self._validate()
offsets = [self.pin_a]
self._lines = GpioOutputLines(
chip=self.gpio_chip,
offsets=offsets,
consumer=f"radar_{self.name}",
)
self._lines.open()
self._is_open = True
self.switch_to(self.default_position)
def close(self) -> None:
"""Close GPIO lines."""
if self._lines is not None:
self._lines.close()
self._lines = None
self._is_open = False
def position_count(self) -> int:
"""Return number of supported positions."""
return self.positions
def switch_to(self, position: int) -> None:
"""Switch hardware to selected position."""
if not self._is_open or self._lines is None:
raise RuntimeError(f"Switch driver is not open for {self.name}")
if position < 0 or position >= self.positions:
raise ValueError(f"Position out of range for {self.name}: {position}")
control = int(position & 0x01)
if self.invert_logic:
control ^= 0x01
self._lines.set_values([control])
self._current_position = int(position)
@property
def current_position(self) -> int:
"""Return current position."""
return self._current_position
def _validate(self) -> None:
"""Validate HMC349A driver parameters."""
if self.positions <= 0 or self.positions > 2:
raise ValueError(f"HMC349A positions must be in range [1,2] for {self.name}")
if self.default_position < 0 or self.default_position >= self.positions:
raise ValueError(f"default_position out of range for {self.name}")
if self.pin_a < 0:
raise ValueError(f"pin_a is invalid for {self.name}")
@@ -0,0 +1,25 @@
"""Protocols for switch backend implementations."""
from __future__ import annotations
from typing import Protocol
class SwitchDriverProtocol(Protocol):
"""Required contract for all switch backends."""
def open(self) -> None:
"""Open hardware or mock resources."""
def close(self) -> None:
"""Close hardware or mock resources."""
def position_count(self) -> int:
"""Return total number of supported positions."""
def switch_to(self, position: int) -> None:
"""Switch to requested zero-based position."""
@property
def current_position(self) -> int:
"""Return currently active position."""
@@ -0,0 +1,55 @@
"""Mock switch driver used in development and simulation modes."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class MockSwitchDriver:
"""In-memory switch driver that validates and tracks current position."""
name: str
positions: int
default_position: int = 0
_is_open: bool = field(init=False, default=False, repr=False)
_current_position: int = field(init=False, default=0, repr=False)
def __post_init__(self) -> None:
"""Initialize closed driver state."""
self._is_open = False
self._current_position = 0
def open(self) -> None:
"""Validate config and open driver."""
self._validate()
self._is_open = True
self.switch_to(self.default_position)
def close(self) -> None:
"""Close mock driver."""
self._is_open = False
def position_count(self) -> int:
"""Return number of supported positions."""
return self.positions
def switch_to(self, position: int) -> None:
"""Switch to requested position."""
if not self._is_open:
raise RuntimeError(f"Switch driver is not open for {self.name}")
if position < 0 or position >= self.positions:
raise ValueError(f"Position out of range for {self.name}: {position}")
self._current_position = int(position)
@property
def current_position(self) -> int:
"""Return current position."""
return self._current_position
def _validate(self) -> None:
"""Validate mock driver parameters."""
if self.positions <= 0:
raise ValueError(f"Switch positions must be > 0 for {self.name}")
if self.default_position < 0 or self.default_position >= self.positions:
raise ValueError(f"Switch default_position out of range for {self.name}")
@@ -0,0 +1,85 @@
"""High-level switch service selecting native/mock backend driver."""
from __future__ import annotations
from dataclasses import dataclass, field
from python_app.hardware_full.switch_drivers import (
H7992Driver,
HMC349ADriver,
MockSwitchDriver,
SwitchDriverProtocol,
)
@dataclass(slots=True)
class SwitchService:
"""Facade around concrete switch drivers."""
name: str
positions: int
mode: str = "mock"
driver: str = "h7992"
gpio_chip: str = "/dev/gpiochip0"
pin_a: int = 17
pin_b: int = 27
invert_logic: bool = False
default_position: int = 0
_driver: SwitchDriverProtocol = field(init=False, repr=False)
def __post_init__(self) -> None:
"""Create underlying driver based on configured mode and type."""
self._driver = self._build_driver()
def open(self) -> None:
"""Open underlying switch driver resources."""
self._driver.open()
def close(self) -> None:
"""Close underlying switch driver resources."""
self._driver.close()
def switch_to(self, position: int) -> None:
"""Switch to requested position."""
self._driver.switch_to(position)
@property
def current_position(self) -> int:
"""Return current switch position reported by backend driver."""
return self._driver.current_position
def _build_driver(self) -> SwitchDriverProtocol:
"""Instantiate concrete driver according to configured mode and driver kind."""
mode = self.mode.strip().lower()
driver_kind = self.driver.strip().lower()
if mode == "mock":
return MockSwitchDriver(
name=self.name,
positions=self.positions,
default_position=self.default_position,
)
if mode != "native":
raise RuntimeError(f"Unsupported switch mode: {self.mode}")
if driver_kind == "h7992":
return H7992Driver(
name=self.name,
positions=self.positions,
default_position=self.default_position,
gpio_chip=self.gpio_chip,
pin_a=self.pin_a,
pin_b=self.pin_b,
)
if driver_kind == "hmc349a":
return HMC349ADriver(
name=self.name,
positions=self.positions,
default_position=self.default_position,
gpio_chip=self.gpio_chip,
pin_a=self.pin_a,
invert_logic=self.invert_logic,
)
raise RuntimeError(f"Unsupported switch driver: {self.driver}")