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