init commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Transport layer primitives."""
|
||||
|
||||
from .usb import USBTransport
|
||||
|
||||
__all__ = ["USBTransport"]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Direct USB transport using libusb1 for LibreVNA devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
from ..exceptions import DeviceDisconnectedError, TimeoutError
|
||||
from ..models import USBDeviceDescriptor
|
||||
|
||||
import usb1
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class USBTransport:
|
||||
"""USB bulk transport for LibreVNA protocol endpoints."""
|
||||
|
||||
DATA_EP_OUT = 0x01
|
||||
DATA_EP_IN = 0x81
|
||||
INTERFACE = 0
|
||||
VALID_USB_IDS = (
|
||||
(0x0483, 0x564E),
|
||||
(0x0483, 0x4121),
|
||||
(0x1209, 0x4121),
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_data: Callable[[bytes], None],
|
||||
on_disconnect: Callable[[Exception], None] | None = None,
|
||||
read_chunk_size: int = 65536,
|
||||
) -> None:
|
||||
"""Create transport with RX callback and optional disconnect callback."""
|
||||
self._on_data = on_data
|
||||
self._on_disconnect = on_disconnect
|
||||
self._read_chunk_size = read_chunk_size
|
||||
|
||||
self._ctx: usb1.USBContext | None = None # type: ignore[valid-type]
|
||||
self._handle: usb1.USBDeviceHandle | None = None # type: ignore[valid-type]
|
||||
self._rx_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._tx_lock = threading.Lock()
|
||||
|
||||
self.connected_serial: str | None = None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return `True` when USB handle is open."""
|
||||
return self._handle is not None
|
||||
|
||||
@staticmethod
|
||||
def list_devices() -> list[USBDeviceDescriptor]:
|
||||
"""Enumerate attached LibreVNA USB devices."""
|
||||
devices: list[USBDeviceDescriptor] = []
|
||||
with usb1.USBContext() as ctx:
|
||||
for device, vid, pid in USBTransport._iter_matching_devices(ctx):
|
||||
handle: usb1.USBDeviceHandle | None = None
|
||||
try:
|
||||
handle = device.open()
|
||||
serial = handle.getSerialNumber() or ""
|
||||
except usb1.USBError as exc:
|
||||
logger.debug(
|
||||
"Skipping USB device during discovery vid=0x%04x pid=0x%04x: %s",
|
||||
vid,
|
||||
pid,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
if handle is not None:
|
||||
with suppress(usb1.USBError):
|
||||
handle.close()
|
||||
|
||||
devices.append(USBDeviceDescriptor(serial=serial, vendor_id=vid, product_id=pid))
|
||||
|
||||
devices.sort(key=lambda item: (item.serial, item.vendor_id, item.product_id))
|
||||
logger.debug("USB discovery completed, devices=%d", len(devices))
|
||||
return devices
|
||||
|
||||
def connect(self, *, serial: str | None = None, timeout_s: float = 1.0) -> None:
|
||||
"""Open USB device, claim interface, and start RX thread."""
|
||||
if self._handle is not None:
|
||||
logger.debug("USB connect skipped: already connected")
|
||||
return
|
||||
|
||||
logger.debug("Opening libusb context for connect(serial=%s)", serial)
|
||||
self._ctx = usb1.USBContext()
|
||||
selected_handle: usb1.USBDeviceHandle | None = None
|
||||
selected_serial = ""
|
||||
|
||||
for device, _, _ in self._iter_matching_devices(self._ctx):
|
||||
handle: usb1.USBDeviceHandle | None = None
|
||||
try:
|
||||
handle = device.open()
|
||||
found_serial = handle.getSerialNumber() or ""
|
||||
if serial is not None and found_serial != serial:
|
||||
handle.close()
|
||||
continue
|
||||
|
||||
selected_handle = handle
|
||||
selected_serial = found_serial
|
||||
break
|
||||
except usb1.USBError as exc:
|
||||
if handle is not None:
|
||||
with suppress(usb1.USBError):
|
||||
handle.close()
|
||||
logger.debug("Skipping USB candidate during connect due to error: %s", exc)
|
||||
continue
|
||||
|
||||
if selected_handle is None:
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
serial_msg = f" with serial '{serial}'" if serial else ""
|
||||
raise DeviceDisconnectedError(f"No compatible LibreVNA USB device found{serial_msg}")
|
||||
|
||||
try:
|
||||
selected_handle.setAutoDetachKernelDriver(True)
|
||||
if selected_handle.kernelDriverActive(self.INTERFACE):
|
||||
selected_handle.detachKernelDriver(self.INTERFACE)
|
||||
except usb1.USBError as exc:
|
||||
selected_handle.close()
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
raise DeviceDisconnectedError(f"Failed to prepare USB kernel driver state: {exc}") from exc
|
||||
|
||||
try:
|
||||
selected_handle.claimInterface(self.INTERFACE)
|
||||
except usb1.USBError as exc:
|
||||
selected_handle.close()
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
raise DeviceDisconnectedError(f"Failed to claim USB interface {self.INTERFACE}: {exc}") from exc
|
||||
|
||||
self._handle = selected_handle
|
||||
self.connected_serial = selected_serial
|
||||
self._stop_event.clear()
|
||||
logger.info(
|
||||
"USB connected (serial=%s, timeout=%.2fs, endpoint_out=0x%02x, endpoint_in=0x%02x)",
|
||||
self.connected_serial,
|
||||
timeout_s,
|
||||
self.DATA_EP_OUT,
|
||||
self.DATA_EP_IN,
|
||||
)
|
||||
|
||||
self._rx_thread = threading.Thread(target=self._rx_loop, name="librevna-usb-rx", daemon=True)
|
||||
self._rx_thread.start()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Stop RX thread and close USB resources."""
|
||||
logger.debug("USB disconnect requested")
|
||||
self._stop_event.set()
|
||||
|
||||
if self._rx_thread is not None and self._rx_thread.is_alive():
|
||||
self._rx_thread.join(timeout=1.0)
|
||||
self._rx_thread = None
|
||||
|
||||
if self._handle is not None:
|
||||
self._handle.releaseInterface(self.INTERFACE)
|
||||
self._handle.close()
|
||||
self._handle = None
|
||||
|
||||
if self._ctx is not None:
|
||||
self._ctx.close()
|
||||
self._ctx = None
|
||||
|
||||
logger.info("USB disconnected (serial=%s)", self.connected_serial)
|
||||
self.connected_serial = None
|
||||
|
||||
def write(self, data: bytes, *, timeout_s: float = 0.5) -> None:
|
||||
"""Write one framed packet to device bulk-out endpoint."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
raise DeviceDisconnectedError("USB device is not connected")
|
||||
|
||||
timeout_ms = max(1, int(timeout_s * 1000.0))
|
||||
with self._tx_lock:
|
||||
try:
|
||||
written = handle.bulkWrite(self.DATA_EP_OUT, data, timeout=timeout_ms)
|
||||
except usb1.USBErrorTimeout as exc:
|
||||
raise TimeoutError("Timed out writing USB bulk packet") from exc
|
||||
except usb1.USBErrorNoDevice as exc:
|
||||
raise DeviceDisconnectedError("USB device disconnected during write") from exc
|
||||
except usb1.USBError as exc:
|
||||
raise DeviceDisconnectedError(f"USB write failed: {exc}") from exc
|
||||
|
||||
if written != len(data):
|
||||
raise DeviceDisconnectedError(
|
||||
f"USB bulk write incomplete: wrote {written}/{len(data)} bytes"
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("USB TX %d bytes", len(data))
|
||||
|
||||
def _rx_loop(self) -> None:
|
||||
"""Continuously read bulk-in data and forward to frame scanner callback."""
|
||||
handle = self._handle
|
||||
if handle is None:
|
||||
return
|
||||
|
||||
logger.debug("USB RX thread started")
|
||||
timeout_ms = 100
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
data = handle.bulkRead(self.DATA_EP_IN, self._read_chunk_size, timeout=timeout_ms)
|
||||
except usb1.USBErrorTimeout:
|
||||
continue
|
||||
except usb1.USBErrorInterrupted:
|
||||
continue
|
||||
except usb1.USBErrorNoDevice as exc:
|
||||
if self._on_disconnect is not None:
|
||||
self._on_disconnect(DeviceDisconnectedError("USB device disconnected"))
|
||||
logger.warning("USB RX stopped: device disconnected")
|
||||
return
|
||||
except usb1.USBError as exc:
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
if self._on_disconnect is not None:
|
||||
self._on_disconnect(DeviceDisconnectedError(f"USB read failed: {exc}"))
|
||||
logger.error("USB RX failed: %s", exc)
|
||||
return
|
||||
|
||||
if data:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("USB RX %d bytes", len(data))
|
||||
self._on_data(bytes(data))
|
||||
logger.debug("USB RX thread stopped")
|
||||
|
||||
@classmethod
|
||||
def _iter_matching_devices(
|
||||
cls,
|
||||
ctx: "usb1.USBContext", # type: ignore[name-defined]
|
||||
):
|
||||
"""Yield USB devices matching supported LibreVNA VID/PID pairs."""
|
||||
for device in ctx.getDeviceList(skip_on_error=True):
|
||||
vid = int(device.getVendorID())
|
||||
pid = int(device.getProductID())
|
||||
if (vid, pid) in cls.VALID_USB_IDS:
|
||||
yield device, vid, pid
|
||||
Reference in New Issue
Block a user