init commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user