init commit

This commit is contained in:
Ayzen
2026-03-05 14:42:33 +03:00
commit fd4618b20d
964 changed files with 325114 additions and 0 deletions
@@ -0,0 +1,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