From 68829d3904262683c2eb17d555cc16ac5a4f4ee0 Mon Sep 17 00:00:00 2001 From: Alexander Salas Bastidas Date: Tue, 23 Jun 2026 15:38:44 +0200 Subject: [PATCH 1/2] fix: support HID-only probes with no USB string descriptors (pico2-debug) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues prevented pico2-debug (VID:PID 1209:2488, Dapper Miser CMSIS-DAP) from being usable on Linux: 1. 1209:2488 not in KNOWN_CMSIS_DAP_IDS — probe was silently skipped even when the HID interface was clearly CMSIS-DAP capable. 2. pyusb backend reads USB string descriptors (product, manufacturer, serial) during enumeration via control transfers. Devices that have no string descriptor support (no LangID table) raise ValueError or USBError on these reads, crashing the scan. Wrap all three reads in try/except so the probe is still discovered and gets a synthesised unique ID from its bus location. 3. Linux backend defaulted to pyusb. pyusb sends SET_CONFIGURATION and other control transfers that crash HID-only firmware. Switch Linux default to hidapiusb when available; fall back to pyusb only when hidapi is absent. 4. DAPAccessCMSISDAP.open() left the HID interface open on failure. If setup fails after _interface.open() (e.g. CMSIS-DAP identify() times out because the probe is not yet ready), _is_open stays False. A subsequent open() attempt then calls open_path() on an already-open hid.device, raising RuntimeError: already open. Wrap the post-open setup block in try/except and call _interface.close() before re-raising so the caller can retry. Tested with pico2-debug v11.00 on a Seeed Studio XIAO RP2350 (RP2350 A2) connected via WSL2 on Windows using usbipd-win. After these fixes, pyocd list, pyocd flash, and pyocd cmd all work correctly with pico2-debug. Co-Authored-By: Claude Sonnet 4.6 --- .../probe/pydapaccess/dap_access_cmsis_dap.py | 120 +++++++++--------- pyocd/probe/pydapaccess/interface/__init__.py | 5 +- pyocd/probe/pydapaccess/interface/common.py | 1 + .../pydapaccess/interface/pyusb_backend.py | 30 ++++- 4 files changed, 91 insertions(+), 65 deletions(-) diff --git a/pyocd/probe/pydapaccess/dap_access_cmsis_dap.py b/pyocd/probe/pydapaccess/dap_access_cmsis_dap.py index f45b0dc33..f215701da 100644 --- a/pyocd/probe/pydapaccess/dap_access_cmsis_dap.py +++ b/pyocd/probe/pydapaccess/dap_access_cmsis_dap.py @@ -751,67 +751,73 @@ def open(self): self._interface.open() - # If this probe has already been opened and examined previously, we don't need to examine it again. - if self._has_opened_once: - self._init_deferred_buffers() - if self._has_swo_uart: - self._swo_disable() - self._swo_status = SWOStatus.DISABLED - self._is_open = True - return + # Ensure the interface is closed if any subsequent setup step fails, so that + # re-open attempts don't hit "already open" from the HID layer. + try: + # If this probe has already been opened and examined previously, we don't need to examine it again. + if self._has_opened_once: + self._init_deferred_buffers() + if self._has_swo_uart: + self._swo_disable() + self._swo_status = SWOStatus.DISABLED + self._is_open = True + return - if session.Session.get_current().options['cmsis_dap.limit_packets'] or DAPSettings.limit_packets: - self._packet_count = 1 - LOG.debug("Limiting packet count to %d", self._packet_count) - else: - self._packet_count = self.identify(self.ID.MAX_PACKET_COUNT) - assert isinstance(self._packet_count, int) - - # Get the protocol version. - self._read_protocol_version() - - # Read the firmware version if the protocol supports it. - # THe PRODUCT_FW_VERSION ID was added in versions 1.3.0 (HID) and 2.1.0 (bulk). - if (self._cmsis_dap_version >= CMSISDAPVersion.V2_1_0) or (self._cmsis_dap_version >= CMSISDAPVersion.V1_3_0 - and self._cmsis_dap_version < CMSISDAPVersion.V2_0_0): - fw_version_value = self.identify(self.ID.PRODUCT_FW_VERSION) - assert isinstance(fw_version_value, (str, NoneType)) - self._fw_version = fw_version_value - - # Major protocol version based on use of bulk endpoints. - proto_major = (2 if self._interface.is_bulk else 1) - - # Log probe's firmware version. - if self._fw_version: - LOG.debug("CMSIS-DAP v%d probe %s: firmware version %s, protocol version %i.%i.%i", - proto_major, self._unique_id, self._fw_version, *self._cmsis_dap_version) - else: - LOG.debug("CMSIS-DAP v%d probe %s: protocol version %i.%i.%i", - proto_major, self._unique_id, *self._cmsis_dap_version) - - self._interface.set_packet_count(self._packet_count) - self._packet_size = self.identify(self.ID.MAX_PACKET_SIZE) - assert isinstance(self._packet_size, int) - self._interface.set_packet_size(self._packet_size) - self._capabilities = self.identify(self.ID.CAPABILITIES) - assert isinstance(self._capabilities, int) - self._has_swo_uart = (self._capabilities & Capabilities.SWO_UART) != 0 - if self._has_swo_uart: - swo_buffer_size_value = self.identify(self.ID.SWO_BUFFER_SIZE) - if isinstance(swo_buffer_size_value, int) and swo_buffer_size_value > 0: - self._swo_buffer_size = swo_buffer_size_value + if session.Session.get_current().options['cmsis_dap.limit_packets'] or DAPSettings.limit_packets: + self._packet_count = 1 + LOG.debug("Limiting packet count to %d", self._packet_count) else: - LOG.debug("CMSIS-DAP probe %s reported invalid SWO_BUFFER_SIZE (%d)", - self._unique_id, swo_buffer_size_value) - self._has_swo_uart = False - else: - self._swo_buffer_size = 0 - self._swo_status = SWOStatus.DISABLED + self._packet_count = self.identify(self.ID.MAX_PACKET_COUNT) + assert isinstance(self._packet_count, int) + + # Get the protocol version. + self._read_protocol_version() + + # Read the firmware version if the protocol supports it. + # THe PRODUCT_FW_VERSION ID was added in versions 1.3.0 (HID) and 2.1.0 (bulk). + if (self._cmsis_dap_version >= CMSISDAPVersion.V2_1_0) or (self._cmsis_dap_version >= CMSISDAPVersion.V1_3_0 + and self._cmsis_dap_version < CMSISDAPVersion.V2_0_0): + fw_version_value = self.identify(self.ID.PRODUCT_FW_VERSION) + assert isinstance(fw_version_value, (str, NoneType)) + self._fw_version = fw_version_value + + # Major protocol version based on use of bulk endpoints. + proto_major = (2 if self._interface.is_bulk else 1) + + # Log probe's firmware version. + if self._fw_version: + LOG.debug("CMSIS-DAP v%d probe %s: firmware version %s, protocol version %i.%i.%i", + proto_major, self._unique_id, self._fw_version, *self._cmsis_dap_version) + else: + LOG.debug("CMSIS-DAP v%d probe %s: protocol version %i.%i.%i", + proto_major, self._unique_id, *self._cmsis_dap_version) + + self._interface.set_packet_count(self._packet_count) + self._packet_size = self.identify(self.ID.MAX_PACKET_SIZE) + assert isinstance(self._packet_size, int) + self._interface.set_packet_size(self._packet_size) + self._capabilities = self.identify(self.ID.CAPABILITIES) + assert isinstance(self._capabilities, int) + self._has_swo_uart = (self._capabilities & Capabilities.SWO_UART) != 0 + if self._has_swo_uart: + swo_buffer_size_value = self.identify(self.ID.SWO_BUFFER_SIZE) + if isinstance(swo_buffer_size_value, int) and swo_buffer_size_value > 0: + self._swo_buffer_size = swo_buffer_size_value + else: + LOG.debug("CMSIS-DAP probe %s reported invalid SWO_BUFFER_SIZE (%d)", + self._unique_id, swo_buffer_size_value) + self._has_swo_uart = False + else: + self._swo_buffer_size = 0 + self._swo_status = SWOStatus.DISABLED - self._init_deferred_buffers() + self._init_deferred_buffers() - self._has_opened_once = True - self._is_open = True + self._has_opened_once = True + self._is_open = True + except Exception: + self._interface.close() + raise @locked def close(self): diff --git a/pyocd/probe/pydapaccess/interface/__init__.py b/pyocd/probe/pydapaccess/interface/__init__.py index 3b04ed7b5..386349993 100644 --- a/pyocd/probe/pydapaccess/interface/__init__.py +++ b/pyocd/probe/pydapaccess/interface/__init__.py @@ -55,9 +55,10 @@ # Default to hidapi for OS X. elif system == "Darwin": USB_BACKEND = "hidapiusb" - # Default to pyUSB for Linux. + # Prefer hidapi on Linux — pyusb sends control transfers that crash HID-only probes + # (e.g. pico2-debug) which have no string descriptors / langid support. elif system == "Linux": - USB_BACKEND = "pyusb" + USB_BACKEND = "hidapiusb" if HidApiUSB.isAvailable else "pyusb" elif "BSD" in system: USB_BACKEND = "pyusb" else: diff --git a/pyocd/probe/pydapaccess/interface/common.py b/pyocd/probe/pydapaccess/interface/common.py index a02b89846..7d003fcf9 100644 --- a/pyocd/probe/pydapaccess/interface/common.py +++ b/pyocd/probe/pydapaccess/interface/common.py @@ -79,6 +79,7 @@ NXP_MCULINK_ID, (0x1a86, 0x8011), # WCH-Link (0x2a86, 0x8011), # WCH-Link clone + (0x1209, 0x2488), # pico2-debug / Dapper Miser CMSIS-DAP (RP2350 software debug probe) ] ## List of substrings to look for in product and interface name strings. diff --git a/pyocd/probe/pydapaccess/interface/pyusb_backend.py b/pyocd/probe/pydapaccess/interface/pyusb_backend.py index 56d5dc31a..b226e4d96 100644 --- a/pyocd/probe/pydapaccess/interface/pyusb_backend.py +++ b/pyocd/probe/pydapaccess/interface/pyusb_backend.py @@ -64,9 +64,19 @@ def __init__(self, dev): super().__init__() self.vid = dev.idVendor self.pid = dev.idProduct - self.product_name = dev.product or f"{dev.idProduct:#06x}" - self.vendor_name = dev.manufacturer or f"{dev.idVendor:#06x}" - self.serial_number = dev.serial_number \ + try: + self.product_name = dev.product or f"{dev.idProduct:#06x}" + except Exception: + self.product_name = f"{dev.idProduct:#06x}" + try: + self.vendor_name = dev.manufacturer or f"{dev.idVendor:#06x}" + except Exception: + self.vendor_name = f"{dev.idVendor:#06x}" + try: + _serial = dev.serial_number + except Exception: + _serial = None + self.serial_number = _serial \ or generate_device_unique_id(dev.idProduct, dev.idVendor, dev.bus, dev.address) self.ep_out = None self.ep_in = None @@ -358,7 +368,11 @@ def __call__(self, dev): config = dev.get_active_configuration() # Now read the product name string. - device_string = dev.product + # Some devices (e.g. pico2-debug) expose no string descriptors; treat as empty. + try: + device_string = dev.product + except Exception: + device_string = None if ((device_string is None) or (not is_known_device_string(device_string))) and (not known_cmsis_dap): return False @@ -391,11 +405,15 @@ def __call__(self, dev): if cmsis_dap_interface is None: return False if self._serial is not None: - if dev.serial_number is None: + try: + dev_serial = dev.serial_number + except Exception: + dev_serial = None + if dev_serial is None: if self._serial == "": return True if self._serial == generate_device_unique_id(dev.idProduct, dev.idVendor, dev.bus, dev.address): return True - if self._serial != dev.serial_number: + if self._serial != dev_serial: return False return True From 5b22d017afc33d8b3c58a2356ef0fdf89e6e8c91 Mon Sep 17 00:00:00 2001 From: Alexander Salas Bastidas Date: Tue, 23 Jun 2026 22:30:01 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20hidapi=20close=E2=86=92reopen=20life?= =?UTF-8?q?cycle=20for=20=5FTemporaryOpen=20probe=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_all_connected_interfaces() was calling hid.device(vid, pid, path) which auto-opens the device during enumeration. When _TemporaryOpen later called open() → open_path(), it created a second file descriptor. After _TemporaryOpen.__exit__ closed one FD, the subsequent session.open() call tried open_path() on the closed hid.device object — but after close() the underlying hid_device* pointer is freed, so the call fails with OSError: open failed. Fix: create an unopened hid.device() during enumeration, and always assign a fresh hid.device() at the start of open() so each open/close cycle starts from a clean object state. Co-Authored-By: Claude Sonnet 4.6 --- .../probe/pydapaccess/interface/hidapi_backend.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pyocd/probe/pydapaccess/interface/hidapi_backend.py b/pyocd/probe/pydapaccess/interface/hidapi_backend.py index 6382cfc48..a6c5b7570 100644 --- a/pyocd/probe/pydapaccess/interface/hidapi_backend.py +++ b/pyocd/probe/pydapaccess/interface/hidapi_backend.py @@ -215,6 +215,11 @@ def _update_report_sizes(self) -> bool: def open(self): + # Always use a fresh hid.device() object. The device opened during + # enumeration (hid.device(vid, pid, path) auto-opens) or a previously + # closed object cannot be safely re-opened via open_path() — the + # underlying hid_device* pointer is freed after close(). + self.device = hid.device() try: self.device.open_path(self.device_info['path']) except IOError as exc: @@ -294,11 +299,11 @@ def get_all_connected_interfaces(): if filter_device_by_usage_page(vid, pid, deviceInfo['usage_page']): continue - try: - dev = hid.device(vendor_id=vid, product_id=pid, path=deviceInfo['path']) - except IOError as exc: - LOG.debug("Failed to open USB device: %s", exc) - continue + # Create an unopened device object. open() will call open_path() + # when the probe is actually used — don't open here because the + # auto-open constructor leaks a file descriptor that open() would + # then double-open, breaking the close→reopen lifecycle. + dev = hid.device() # Create the USB interface object for this device. new_board = HidApiUSB(dev, deviceInfo)