Refactor radar system
This commit is contained in:
@@ -201,6 +201,8 @@ class SweepResult:
|
||||
x: np.ndarray
|
||||
traces: dict[str, np.ndarray] = field(default_factory=dict)
|
||||
x_label: str = "frequency_hz"
|
||||
capture_start_ns: int = 0
|
||||
capture_end_ns: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Normalize dtypes and validate shape compatibility."""
|
||||
|
||||
@@ -28,6 +28,7 @@ from .protocol import (
|
||||
from .transport import USBTransport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_PACKET_QUEUE_MAX = 32768
|
||||
|
||||
|
||||
class _IndexedPayload(Protocol):
|
||||
@@ -50,7 +51,9 @@ class LibreVNASession:
|
||||
on_disconnect=self._on_transport_disconnect,
|
||||
)
|
||||
|
||||
self._incoming: dict[PacketType, deque[Packet]] = defaultdict(deque)
|
||||
# The sweep free-runs between acquisitions, and subscriptions do not
|
||||
# consume request queues. Retain a bounded tail of stale datapoints.
|
||||
self._incoming: dict[PacketType, deque[Packet]] = defaultdict(lambda: deque(maxlen=_PACKET_QUEUE_MAX))
|
||||
self._subscribers: dict[PacketType, set[Callable[[Packet], None]]] = defaultdict(set)
|
||||
|
||||
self._incoming_cv = threading.Condition()
|
||||
@@ -159,8 +162,8 @@ class LibreVNASession:
|
||||
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._fatal_error = DeviceDisconnectedError("LibreVNA session is disconnected")
|
||||
self._incoming.clear()
|
||||
self._subscribers.clear()
|
||||
self._incoming_cv.notify_all()
|
||||
@@ -168,6 +171,7 @@ class LibreVNASession:
|
||||
self._awaiting_ack = False
|
||||
self._ack_result = None
|
||||
self._ack_cv.notify_all()
|
||||
self._transport.disconnect()
|
||||
|
||||
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."""
|
||||
@@ -188,26 +192,26 @@ class LibreVNASession:
|
||||
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)
|
||||
try:
|
||||
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:
|
||||
raise TimeoutError(f"Timeout waiting for ACK for packet {packet.type.name}")
|
||||
self._ack_cv.wait(timeout=remaining)
|
||||
result = self._ack_result
|
||||
if result == PacketType.NACK:
|
||||
raise NackError(f"Received NACK for packet {packet.type.name}")
|
||||
logger.debug("ACK received for packet %s", packet.type.name)
|
||||
finally:
|
||||
if require_ack:
|
||||
with self._ack_cv:
|
||||
self._awaiting_ack = False
|
||||
self._ack_result = None
|
||||
|
||||
def request(
|
||||
self,
|
||||
@@ -238,9 +242,10 @@ class LibreVNASession:
|
||||
payload_type: type[TPacketPayload],
|
||||
payload_error: str,
|
||||
) -> list[TPacketPayload]:
|
||||
"""Collect indexed payloads by `point_number` into ascending order.
|
||||
"""Collect one sweep from point zero, returning points in ascending order.
|
||||
|
||||
Missing points are omitted; callers decide whether this is acceptable.
|
||||
Ignore the tail before the first point zero. A new point zero discards
|
||||
an incomplete earlier cycle instead of filling its gaps from a later one.
|
||||
"""
|
||||
if expected_points <= 0:
|
||||
raise ValueError("expected_points must be > 0")
|
||||
@@ -265,6 +270,11 @@ class LibreVNASession:
|
||||
f"Received out-of-range point index {point_number}, expected 0..{expected_points - 1}"
|
||||
)
|
||||
|
||||
if point_number == 0:
|
||||
collected = [None] * expected_points
|
||||
received = 0
|
||||
elif collected[0] is None:
|
||||
continue
|
||||
if collected[point_number] is None:
|
||||
received += 1
|
||||
collected[point_number] = payload
|
||||
@@ -391,8 +401,12 @@ class LibreVNASession:
|
||||
|
||||
callbacks: list[Callable[[Packet], None]] = []
|
||||
with self._incoming_cv:
|
||||
self._incoming[packet.type].append(packet)
|
||||
callbacks = list(self._subscribers.get(packet.type, set()))
|
||||
queue = self._incoming[packet.type]
|
||||
if len(queue) == queue.maxlen and not callbacks and packet.type != PacketType.VNA_DATAPOINT:
|
||||
self._set_fatal_error(ParseError(f"Packet queue overflow for {packet.type.name}"))
|
||||
return
|
||||
queue.append(packet)
|
||||
self._incoming_cv.notify_all()
|
||||
|
||||
for callback in callbacks:
|
||||
|
||||
@@ -94,68 +94,60 @@ class USBTransport:
|
||||
|
||||
logger.debug("Opening libusb context for connect(serial=%s)", serial)
|
||||
self._ctx = usb1.USBContext()
|
||||
selected_handle: usb1.USBDeviceHandle | None = None
|
||||
selected_serial = ""
|
||||
try:
|
||||
selected_handle: usb1.USBDeviceHandle | None = None
|
||||
selected_serial = ""
|
||||
|
||||
for device, _, _ in self._iter_matching_devices(self._ctx):
|
||||
handle: usb1.USBDeviceHandle | None = None
|
||||
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:
|
||||
continue
|
||||
|
||||
selected_handle = handle
|
||||
selected_serial = found_serial
|
||||
break
|
||||
except usb1.USBError as exc:
|
||||
logger.debug("Skipping USB candidate during connect due to error: %s", exc)
|
||||
finally:
|
||||
if handle is not None and handle is not selected_handle:
|
||||
with suppress(usb1.USBError):
|
||||
handle.close()
|
||||
|
||||
if selected_handle is None:
|
||||
serial_msg = f" with serial '{serial}'" if serial else ""
|
||||
raise DeviceDisconnectedError(f"No compatible LibreVNA USB device found{serial_msg}")
|
||||
|
||||
self._handle = selected_handle
|
||||
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
|
||||
selected_handle.setAutoDetachKernelDriver(True)
|
||||
if selected_handle.kernelDriverActive(self.INTERFACE):
|
||||
selected_handle.detachKernelDriver(self.INTERFACE)
|
||||
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
|
||||
raise DeviceDisconnectedError(f"Failed to prepare USB kernel driver state: {exc}") from exc
|
||||
|
||||
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:
|
||||
self._claim_interface_with_busy_recovery(selected_handle)
|
||||
except usb1.USBError as exc:
|
||||
raise DeviceDisconnectedError(f"Failed to claim USB interface {self.INTERFACE}: {exc}") from exc
|
||||
|
||||
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
|
||||
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,
|
||||
)
|
||||
|
||||
try:
|
||||
self._claim_interface_with_busy_recovery(selected_handle)
|
||||
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()
|
||||
self._rx_thread = threading.Thread(target=self._rx_loop, name="librevna-usb-rx", daemon=True)
|
||||
self._rx_thread.start()
|
||||
except Exception:
|
||||
self.disconnect()
|
||||
raise
|
||||
|
||||
def _claim_interface_with_busy_recovery(self, handle: "usb1.USBDeviceHandle") -> None: # type: ignore[name-defined]
|
||||
"""Claim the data interface, clearing a stale BUSY claim once if needed.
|
||||
|
||||
Reference in New Issue
Block a user