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
@@ -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