Skip to main content

mboot/mboot/protocols/
usb.rs

1// Copyright 2025 NXP
2//
3// SPDX-License-Identifier: BSD-3-Clause
4
5use std::{io, time::Duration};
6
7use crate::{mboot::ResultComm, parsers};
8use color_print::cstr;
9use hidapi::{DeviceInfo, HidApi, HidDevice};
10use log::{debug, info};
11use std::fmt::Debug;
12
13use super::{CommunicationError, Protocol, ProtocolOpen};
14
15/// Report IDs for USB-HID protocol as per NXP documentation
16mod report {
17    /// Command packet from host to device
18    pub const CMD_OUT: u8 = 0x01;
19    /// Data packet from host to device
20    pub const DATA_OUT: u8 = 0x02;
21    /// Response packet from device to host
22    pub const CMD_IN: u8 = 0x03;
23    /// Data packet from device to host
24    pub const DATA_IN: u8 = 0x04;
25}
26
27/// Maximum packet size for USB transfers
28const MAX_PACKET_SIZE: usize = 1024;
29
30/// Known NXP USB Vendor IDs.
31///
32/// Used by [`USBProtocol::list_devices`] to filter the raw OS HID device list
33/// down to devices that are plausibly NXP MCU bootloaders, instead of showing
34/// every HID device on the system (mice, keyboards, etc.). Kept in sync with
35/// the equivalent `NXP_VIDS` list in SPSDK (`spsdk/utils/usbfilter.py`).
36pub const NXP_USB_VIDS: [u16; 4] = [0x1FC9, 0x15A2, 0x0471, 0x0D28];
37
38/// Returns `true` if `vid` is a known NXP USB vendor ID (see [`NXP_USB_VIDS`]).
39fn is_nxp_vid(vid: u16) -> bool {
40    NXP_USB_VIDS.contains(&vid)
41}
42
43#[derive(Debug)]
44pub struct USBProtocol {
45    interface: String,
46    device: HidDevice,
47    timeout_ms: i32,
48    polling_interval: Duration,
49}
50
51/// Information about a connected USB-HID device.
52///
53/// Used to disambiguate between several devices sharing the same VID/PID
54/// (e.g. identical boards connected for parallel production programming).
55/// Returned by [`USBProtocol::list_devices`] and primarily meant for the
56/// `list-usb` CLI command and for building a `-u/--usb` selector.
57#[derive(Clone, Debug)]
58pub struct UsbDeviceInfo {
59    /// USB Vendor ID
60    pub vid: u16,
61    /// USB Product ID
62    pub pid: u16,
63    /// OS-specific device path.
64    ///
65    /// On Windows this is a stable device instance path, unique per physical
66    /// connection even across reconnects. **On Linux this is `/dev/hidrawN`,
67    /// where `N` is assigned by the kernel at enumeration time and is NOT
68    /// guaranteed stable across a device disconnect/reconnect or reset**
69    /// (e.g. during provisioning). Prefer [`UsbDeviceInfo::topology_path`]
70    /// on Linux when a stable identifier is required. On macOS,
71    /// the path looks like `"DevSrvsID:4294969627"`, an `IORegistry` entry ID
72    /// that is likewise reassigned on every reconnect or reset, so prefer
73    /// [`UsbDeviceInfo::topology_path`] there too. Several HID *interfaces*
74    /// exposed by the same physical device can also report the same macOS
75    /// path, so it may not be unique to a single `hidapi` device entry.
76    pub path: String,
77    /// Physical USB topology (port-chain) path, e.g. `"1-1.4.3"`.
78    ///
79    /// Identifies the device by its physical location in the USB hub
80    /// hierarchy rather than by enumeration order. Unlike [`path`](Self::path)
81    /// on Linux, this remains stable across disconnect/reconnect and device
82    /// reset events, as long as the physical cabling/hub topology doesn't
83    /// change. Available on Linux (from sysfs) and macOS (decoded from the
84    /// USB `locationID`, e.g. `0x14320000` -> `"20-3.2"`); `None` on Windows,
85    /// where [`path`](Self::path) is already stable and should be used instead.
86    pub topology_path: Option<String>,
87    /// Serial number string reported by the device, if any
88    pub serial_number: Option<String>,
89    /// Product string reported by the device, if any
90    pub product_string: Option<String>,
91    /// Manufacturer string reported by the device, if any
92    pub manufacturer_string: Option<String>,
93}
94
95impl std::fmt::Display for UsbDeviceInfo {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(
98            f,
99            "0x{:04X}:0x{:04X}  path={}  topology={}  serial={}  product={}",
100            self.vid,
101            self.pid,
102            self.path,
103            self.topology_path.as_deref().unwrap_or("N/A"),
104            self.serial_number.as_deref().unwrap_or("N/A"),
105            self.product_string.as_deref().unwrap_or("N/A"),
106        )
107    }
108}
109
110impl From<&DeviceInfo> for UsbDeviceInfo {
111    fn from(info: &DeviceInfo) -> Self {
112        let path = info.path().to_string_lossy().into_owned();
113        UsbDeviceInfo {
114            vid: info.vendor_id(),
115            pid: info.product_id(),
116            topology_path: usb_topology_path_from_hid_path(&path),
117            path,
118            serial_number: info.serial_number().map(str::to_owned),
119            product_string: info.product_string().map(str::to_owned),
120            manufacturer_string: info.manufacturer_string().map(str::to_owned),
121        }
122    }
123}
124
125impl UsbDeviceInfo {
126    /// Build a `-u/--usb` argument (`<vid>,<pid>,<path>`) that identifies this device, ready to
127    /// copy-paste directly, e.g. in `rblhost -u "<value>" get-property 1`.
128    ///
129    /// Always includes the full OS device path as the selector (rather than trying to find
130    /// the shortest unambiguous form), to keep the output predictable regardless of how many
131    /// other devices are connected.
132    ///
133    /// Caveats on the OS path's uniqueness (none of which are specific to this method, they
134    /// apply to [`path`](Self::path) itself and thus to any selector built from it):
135    /// - On Linux, the path (`/dev/hidrawN`) is only unique for as long as the device stays
136    ///   connected without being re-enumerated; see [`topology_path`](Self::topology_path) and
137    ///   [`usb_topology_arg`](Self::usb_topology_arg) for a selector that survives
138    ///   disconnect/reconnect/reset.
139    /// - On macOS, the path (`DevSrvsID:<id>`) changes on every reconnect/reset as well; use
140    ///   [`usb_topology_arg`](Self::usb_topology_arg) instead. Several HID interfaces exposed
141    ///   by the *same physical device* can also share the
142    ///   same path (e.g. a composite device also exposing a non-bootloader interface), so the
143    ///   resulting argument may not be unique to a single `hidapi` device entry even though it
144    ///   does identify a single physical device.
145    #[must_use]
146    pub fn usb_arg(&self) -> String {
147        format!("0x{:04X},0x{:04X},{}", self.vid, self.pid, self.path)
148    }
149
150    /// Build a `-u/--usb` argument using the stable USB topology (port-chain) path instead of
151    /// the OS device path, e.g. `"0x1FC9,0x0135,1-1.4.3"`.
152    ///
153    /// Returns `None` if [`topology_path`](Self::topology_path) couldn't be determined (always
154    /// the case on Windows, or if the sysfs/IOKit topology lookup failed on Linux/macOS).
155    /// Unlike [`usb_arg`](Self::usb_arg), the value returned here remains valid across
156    /// disconnect/reconnect and device reset events, as long as the physical USB
157    /// cabling/hub topology doesn't change.
158    #[must_use]
159    pub fn usb_topology_arg(&self) -> Option<String> {
160        self.topology_path
161            .as_deref()
162            .map(|topology| format!("0x{:04X},0x{:04X},{}", self.vid, self.pid, topology))
163    }
164}
165
166/// Resolves the stable USB topology (port-chain) path, e.g. `"1-1.4.3"`, for the HID device at
167/// `hid_path` (a hidapi OS device path, `/dev/hidrawN` on Linux).
168///
169/// This identifies a device by its physical location in the USB hub hierarchy, which (unlike
170/// `/dev/hidrawN`, whose `N` is assigned by the kernel at enumeration time) stays stable across
171/// disconnect/reconnect and device reset events as long as the physical cabling/hub topology
172/// doesn't change. This allows deterministically targeting one of several identical boards
173/// attached through a USB hub tree, e.g. in manufacturing/provisioning setups.
174///
175/// Implemented by walking `/sys/class/hidraw/<N>/device` up through its ancestor sysfs
176/// directories until one is found whose name matches the kernel's USB device naming scheme
177/// (`<bus>-<port>[.<port>]*`, e.g. `1-1.4.3`); that name *is* the topology path. Returns `None`
178/// on non-Linux platforms, or if `hid_path` isn't a `/dev/hidrawN` path, or if the sysfs lookup
179/// fails for any reason (e.g. the device was unplugged between listing and this call, or sysfs
180/// isn't mounted) -- the topology path is a best-effort convenience, callers must be prepared
181/// to fall back to [`UsbDeviceInfo::path`] or VID/PID+serial selection.
182#[cfg(target_os = "linux")]
183fn usb_topology_path_from_hid_path(hid_path: &str) -> Option<String> {
184    let hidraw_name = hid_path.strip_prefix("/dev/")?;
185    if !hidraw_name.starts_with("hidraw") {
186        return None;
187    }
188
189    let device_link = std::path::Path::new("/sys/class/hidraw")
190        .join(hidraw_name)
191        .join("device");
192    let resolved = std::fs::canonicalize(device_link).ok()?;
193
194    // Walk up the resolved sysfs path (.../usb1/1-1/1-1.4/1-1.4.3/1-1.4.3:1.0/<hid>/hidraw/hidrawN)
195    // looking for an ancestor directory name that matches the USB device naming scheme used by
196    // the kernel for topology: "<root hub number>-<port>[.<port>]*", e.g. "1-1.4.3". USB
197    // interface directories (e.g. "1-1.4.3:1.0") and the root hub itself (e.g. "usb1") don't
198    // match this pattern and are skipped.
199    resolved
200        .ancestors()
201        .filter_map(|p| p.file_name())
202        .filter_map(|name| name.to_str())
203        .find(|name| is_usb_topology_name(name))
204        .map(str::to_owned)
205}
206
207/// Resolves the stable USB topology (port-chain) path for the HID device at `hid_path` (a hidapi
208/// macOS device path, `"DevSrvsID:<registry entry ID>"`).
209///
210/// The entry ID in the path is assigned anew every time the device enumerates, so it changes on
211/// reset/reconnect. The USB `locationID` of the device, found by walking up the `IOService` plane
212/// from the HID entry, encodes its physical bus/port chain instead; it is converted to the same
213/// `<bus>-<port>[.<port>]*` form as on Linux by [`topology_path_from_location_id`]. Returns `None`
214/// if `hid_path` isn't a `DevSrvsID:` path or the `IOKit` lookup fails.
215#[cfg(target_os = "macos")]
216fn usb_topology_path_from_hid_path(hid_path: &str) -> Option<String> {
217    let entry_id = hid_path.strip_prefix("DevSrvsID:")?.parse::<u64>().ok()?;
218    topology_path_from_location_id(macos::usb_location_id(entry_id)?)
219}
220
221#[cfg(not(any(target_os = "linux", target_os = "macos")))]
222fn usb_topology_path_from_hid_path(_hid_path: &str) -> Option<String> {
223    None
224}
225
226/// Converts a macOS USB `locationID` to a `<bus>-<port>[.<port>]*` topology path, e.g.
227/// `0x14320000` -> `"20-3.2"`.
228///
229/// The top byte of a `locationID` is the bus number, and each following hex digit is the port
230/// number at the next hub level, terminated by the first zero digit. Returns `None` for IDs
231/// without any port (e.g. `0`, reported by built-in devices that aren't on a USB bus).
232#[cfg(any(target_os = "macos", test))]
233fn topology_path_from_location_id(location_id: u32) -> Option<String> {
234    let ports: Vec<String> = (0..6)
235        .map(|level| (location_id >> (20 - 4 * level)) & 0xF)
236        .take_while(|&port| port != 0)
237        .map(|port| port.to_string())
238        .collect();
239    if ports.is_empty() {
240        return None;
241    }
242    Some(format!("{}-{}", location_id >> 24, ports.join(".")))
243}
244
245#[cfg(target_os = "macos")]
246mod macos {
247    use std::ffi::{c_char, c_void};
248
249    type CFTypeRef = *const c_void;
250    type IoObject = u32;
251
252    const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
253    const K_CF_NUMBER_SINT64_TYPE: isize = 4;
254    const K_IO_REGISTRY_ITERATE_RECURSIVELY: u32 = 1;
255    const K_IO_REGISTRY_ITERATE_PARENTS: u32 = 2;
256
257    #[link(name = "IOKit", kind = "framework")]
258    unsafe extern "C" {
259        fn IORegistryEntryIDMatching(entry_id: u64) -> *mut c_void;
260        fn IOServiceGetMatchingService(main_port: u32, matching: *mut c_void) -> IoObject;
261        fn IORegistryEntrySearchCFProperty(
262            entry: IoObject,
263            plane: *const c_char,
264            key: CFTypeRef,
265            allocator: CFTypeRef,
266            options: u32,
267        ) -> CFTypeRef;
268        fn IOObjectRelease(object: IoObject) -> i32;
269    }
270
271    #[link(name = "CoreFoundation", kind = "framework")]
272    unsafe extern "C" {
273        fn CFStringCreateWithCString(allocator: CFTypeRef, c_str: *const c_char, encoding: u32) -> CFTypeRef;
274        fn CFGetTypeID(cf: CFTypeRef) -> usize;
275        fn CFNumberGetTypeID() -> usize;
276        fn CFNumberGetValue(number: CFTypeRef, number_type: isize, value: *mut c_void) -> u8;
277        fn CFRelease(cf: CFTypeRef);
278    }
279
280    /// Looks up the USB `locationID` of the nearest USB ancestor of the `IORegistry` entry with
281    /// the given ID, without opening the device.
282    pub(super) fn usb_location_id(entry_id: u64) -> Option<u32> {
283        // SAFETY: plain IOKit/CoreFoundation calls; every object obtained here is checked for
284        // null and released exactly once. IOServiceGetMatchingService consumes `matching`.
285        unsafe {
286            let matching = IORegistryEntryIDMatching(entry_id);
287            if matching.is_null() {
288                return None;
289            }
290            let service = IOServiceGetMatchingService(0, matching);
291            if service == 0 {
292                return None;
293            }
294            let key = CFStringCreateWithCString(std::ptr::null(), c"locationID".as_ptr(), K_CF_STRING_ENCODING_UTF8);
295            let value = if key.is_null() {
296                std::ptr::null()
297            } else {
298                let value = IORegistryEntrySearchCFProperty(
299                    service,
300                    c"IOService".as_ptr(),
301                    key,
302                    std::ptr::null(),
303                    K_IO_REGISTRY_ITERATE_RECURSIVELY | K_IO_REGISTRY_ITERATE_PARENTS,
304                );
305                CFRelease(key);
306                value
307            };
308            IOObjectRelease(service);
309            if value.is_null() {
310                return None;
311            }
312            let mut location_id: i64 = 0;
313            let ok = CFGetTypeID(value) == CFNumberGetTypeID()
314                && CFNumberGetValue(value, K_CF_NUMBER_SINT64_TYPE, (&raw mut location_id).cast()) != 0;
315            CFRelease(value);
316            // Apple Silicon reports some locationIDs sign-extended to 64 bits (e.g.
317            // 0xFFFFFFFF80000000); only the low 32 bits are meaningful.
318            if ok {
319                u32::try_from(location_id & 0xFFFF_FFFF).ok()
320            } else {
321                None
322            }
323        }
324    }
325}
326
327/// Returns `true` if `name` matches the kernel's USB device topology naming scheme:
328/// `<bus>-<port>[.<port>]*`, e.g. `"1-1"`, `"1-1.4"`, `"1-1.4.3"`. Rejects USB interface
329/// directory names such as `"1-1.4.3:1.0"` (which additionally contain a `':'`) and anything
330/// else that doesn't fit the pattern (e.g. `"usb1"`, `"hidraw0"`).
331#[cfg(target_os = "linux")]
332fn is_usb_topology_name(name: &str) -> bool {
333    let Some((bus, ports)) = name.split_once('-') else {
334        return false;
335    };
336    if bus.is_empty() || !bus.chars().all(|c| c.is_ascii_digit()) {
337        return false;
338    }
339    !ports.is_empty()
340        && ports
341            .split('.')
342            .all(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()))
343}
344
345/// Parsed form of a `-u/--usb` device identifier.
346///
347/// Supports the original `<vid>` / `<vid>,<pid>` forms (opens the first
348/// matching device, as before, with no guarantee which one that is if several
349/// identical devices are connected), an additional `<vid>,<pid>,<selector>`
350/// form used to target one specific device when several identical boards
351/// (same VID/PID) are connected at once, and a bare `<selector>` form (no
352/// VID/PID at all) for when the device's OS path, serial number, or USB
353/// topology path alone is enough to uniquely identify it. In all cases,
354/// `<selector>` matches case-insensitively against either a substring of the
355/// device's OS path, its exact serial number, or its exact USB topology path.
356///
357/// Caveat: a bare `<selector>` that happens to look like a plain number
358/// (decimal, or hex without a `0x` prefix containing only `0`-`9`/`a`-`f`) is
359/// always parsed as `VidPid` with that number as the VID, never as
360/// `PathOrSerial` -- e.g. a numeric serial number `"1234"` is interpreted as
361/// `vid=0x04D2`, and a hex-looking serial `"ABCD"` as `vid=0xABCD`. Numeric or
362/// short hex-like serial numbers must therefore always be given with an
363/// explicit (even if wildcard, `pid=0`) VID/PID prefix, e.g.
364/// `"0x1FC9,0,1234"`, to be unambiguously treated as a selector rather than a
365/// VID.
366#[derive(Debug, PartialEq, Eq)]
367enum UsbSelector {
368    /// Open the first device matching VID/PID (PID `0` matches any product)
369    VidPid { vid: u16, pid: u16 },
370    /// Open the device matching VID/PID (PID `0` matches any product) whose
371    /// path (substring), serial number (exact), or USB topology path (exact)
372    /// matches `selector`, case-insensitively
373    VidPidWithSelector { vid: u16, pid: u16, selector: String },
374    /// Open the device (any VID/PID) whose path (substring), serial number
375    /// (exact), or USB topology path (exact) matches `selector`,
376    /// case-insensitively. Used when the input can't be parsed as a VID
377    /// number, e.g. a raw OS device path copied from `list-usb` or Device
378    /// Manager.
379    PathOrSerial { selector: String },
380}
381
382impl ProtocolOpen for USBProtocol {
383    fn open(identifier: &str) -> ResultComm<Self> {
384        Self::open_with_options(identifier, 0, Duration::from_secs(5), Duration::from_millis(1))
385    }
386
387    fn open_with_options(
388        identifier: &str,
389        _baudrate: u32, // Not used for USB
390        timeout: Duration,
391        polling_interval: Duration,
392    ) -> ResultComm<Self> {
393        let selector = parse_usb_identifier(identifier)?;
394
395        // Initialize HidApi
396        let api =
397            HidApi::new().map_err(|e| CommunicationError::ParseError(format!("Failed to initialize HID API: {e}")))?;
398
399        let device = open_selected_device(&api, &selector)?;
400
401        // Convert timeout to i32, clamping if necessary
402        let timeout_ms = timeout.as_millis().try_into().unwrap_or(i32::MAX);
403
404        let usb_protocol = USBProtocol {
405            interface: identifier.to_owned(),
406            device,
407            timeout_ms,
408            polling_interval,
409        };
410
411        info!(
412            "Opened USB-HID device {} with {}ms timeout",
413            usb_protocol.interface,
414            timeout.as_millis()
415        );
416
417        Ok(usb_protocol)
418    }
419}
420
421impl Protocol for USBProtocol {
422    fn get_polling_interval(&self) -> Duration {
423        self.polling_interval
424    }
425
426    fn get_timeout(&self) -> Duration {
427        Duration::from_millis(self.timeout_ms.try_into().expect("negative timeout in USB"))
428    }
429
430    fn get_identifier(&self) -> &str {
431        &self.interface
432    }
433
434    fn read(&mut self, bytes: usize) -> ResultComm<Vec<u8>> {
435        let mut buf = vec![0u8; bytes];
436        self.read_usb(&mut buf)?;
437        Ok(buf)
438    }
439    fn write_packet_raw(&mut self, data: &[u8]) -> ResultComm<()> {
440        // For USB-HID, we need to extract the command data from the UART framing
441        // UART frame format: [5A, cmd_type, len_lsb, len_msb, crc_lsb, crc_msb, ...data...]
442        if data.len() < 6 || data[0] != 0x5A {
443            return Err(CommunicationError::InvalidHeader);
444        }
445
446        let cmd_type = data[1];
447        let data_len = u16::from_le_bytes([data[2], data[3]]) as usize;
448
449        if data.len() < 6 + data_len {
450            return Err(CommunicationError::InvalidData);
451        }
452
453        // Extract the command data (without UART framing and CRC)
454        // Skip the UART header (4 bytes) and CRC (2 bytes)
455        let cmd_data = &data[6..6 + data_len];
456
457        // Determine report ID based on packet type
458        let report_id = match cmd_type {
459            0xA4 => report::CMD_OUT,  // Command packet
460            0xA5 => report::DATA_OUT, // Data packet
461            _ => return Err(CommunicationError::InvalidHeader),
462        };
463
464        // Create a generic HID report
465        let mut report = vec![0u8; 4 + cmd_data.len()]; // 4 bytes for header + data
466
467        // Set report header
468        report[0] = report_id;
469        report[1] = 0x00; // Padding (should be 0)
470        report[2] = (cmd_data.len() & 0xFF) as u8;
471        report[3] = ((cmd_data.len() >> 8) & 0xFF) as u8;
472
473        // Copy command data
474        report[4..4 + cmd_data.len()].copy_from_slice(cmd_data);
475
476        // Write the report
477        self.write_usb(&report)?;
478
479        Ok(())
480    }
481    //
482
483    fn read_packet_raw(&mut self, _: u8) -> ResultComm<Vec<u8>> {
484        // Read the initial response
485        let mut report = vec![0u8; MAX_PACKET_SIZE];
486        let size = self
487            .device
488            .read_timeout(&mut report, self.timeout_ms)
489            .map_err(|e| CommunicationError::IOError(io::Error::other(e.to_string())))?;
490
491        debug!("{}: Read {} bytes: {:02X?}", cstr!("<r!>RX"), size, &report[..size]);
492
493        if size < 4 {
494            return Err(CommunicationError::InvalidHeader);
495        }
496
497        // Extract report ID and packet length
498        let report_id = report[0];
499        let packet_length = u16::from_le_bytes([report[2], report[3]]) as usize;
500
501        if packet_length == 0 {
502            // error!(cstr!("<r!>RX</>: Data aborted by sender!"));
503            return Err(CommunicationError::Aborted);
504        }
505
506        // Check if this is a command response (report ID 0x03)
507        if report_id == report::CMD_IN {
508            // For other command responses, extract the payload
509            let mut response = Vec::new();
510
511            // Extract the command tag and other fields
512            response.extend_from_slice(&report[4..4 + packet_length]);
513
514            debug!("Constructed response: {response:02X?}");
515
516            return Ok(response);
517        } else if report_id == report::DATA_IN {
518            // Data packet - extract the data portion
519            if size >= 4 + packet_length {
520                return Ok(report[4..4 + packet_length].to_vec());
521            }
522        }
523
524        // For other packet types, just return the data portion
525        if size > 4 {
526            Ok(report[4..size].to_vec())
527        } else {
528            Ok(Vec::new())
529        }
530    }
531}
532
533impl USBProtocol {
534    /// List currently connected USB-HID devices.
535    ///
536    /// By default, only devices whose VID matches a known NXP vendor ID
537    /// (see [`NXP_USB_VIDS`]) are returned, to avoid cluttering the output
538    /// with unrelated HID devices (mice, keyboards, etc.) present on the
539    /// system. Pass `all = true` to return every HID device the OS reports,
540    /// unfiltered.
541    ///
542    /// This is primarily intended to help a user find the `path` or `serial`
543    /// of a specific board when several identical devices (same VID/PID) are
544    /// connected, for use as the `<selector>` part of a
545    /// `-u/--usb <vid>,<pid>,<selector>` identifier.
546    ///
547    /// # Errors
548    /// Returns an error if the underlying HID API fails to initialize.
549    pub fn list_devices(all: bool) -> ResultComm<Vec<UsbDeviceInfo>> {
550        let api =
551            HidApi::new().map_err(|e| CommunicationError::ParseError(format!("Failed to initialize HID API: {e}")))?;
552        Ok(api
553            .device_list()
554            .filter(|info| all || is_nxp_vid(info.vendor_id()))
555            .map(UsbDeviceInfo::from)
556            .collect())
557    }
558
559    fn read_usb(&mut self, buf: &mut [u8]) -> Result<(), io::Error> {
560        match self.device.read(buf) {
561            Ok(size) => {
562                debug!("{}: Read {} bytes: {:02X?}", cstr!("<r!>RX"), size, &buf[..size]);
563                Ok(())
564            }
565            Err(e) => Err(io::Error::other(e.to_string())),
566        }
567    }
568    fn write_usb(&self, buf: &[u8]) -> Result<(), io::Error> {
569        debug!("{}: {:02X?}", cstr!("<g!>TX"), buf);
570
571        match self.device.write(buf) {
572            Ok(written) => {
573                // Platform-specific validation
574                #[cfg(target_os = "windows")]
575                {
576                    // Windows HID might report different sizes due to report descriptors
577                    // As long as write succeeded (written > 0), consider it successful
578                    if written > 0 {
579                        Ok(())
580                    } else {
581                        Err(io::Error::other("Failed to write to USB device"))
582                    }
583                }
584                #[cfg(not(target_os = "windows"))]
585                {
586                    // On other platforms, we expect the exact byte count
587                    if written == buf.len() {
588                        Ok(())
589                    } else {
590                        Err(io::Error::other(format!(
591                            "Failed to write all bytes: wrote {} of {}",
592                            written,
593                            buf.len()
594                        )))
595                    }
596                }
597            }
598            Err(e) => Err(io::Error::other(e.to_string())),
599        }
600    }
601}
602
603// Helper functions
604
605/// Returns `true` if `info`'s OS path matches `selector` (exactly if `exact_path`, otherwise
606/// as a substring of it), if its serial number matches `selector` exactly, or if its USB
607/// topology path (see [`usb_topology_path_from_hid_path`]) matches `selector` exactly. All
608/// comparisons are case-insensitive.
609fn device_matches_selector(info: &DeviceInfo, selector: &str, selector_lower: &str, exact_path: bool) -> bool {
610    let path = info.path().to_string_lossy();
611    let path_matches = if exact_path {
612        path.eq_ignore_ascii_case(selector)
613    } else {
614        path.to_lowercase().contains(selector_lower)
615    };
616    path_matches
617        || info
618            .serial_number()
619            .is_some_and(|serial| serial.eq_ignore_ascii_case(selector))
620        || usb_topology_path_from_hid_path(&path).is_some_and(|topology| topology.eq_ignore_ascii_case(selector))
621}
622
623/// Open the device described by `selector`.
624///
625/// For [`UsbSelector::VidPid`], behaves exactly as before: opens the first
626/// device matching VID/PID as reported by the OS/hidapi (no guarantee which
627/// one that is if several identical devices are connected). No ambiguity
628/// check is done here on purpose, to keep this form backward-compatible with
629/// its pre-existing "open first match" behavior.
630///
631/// For [`UsbSelector::VidPidWithSelector`] and [`UsbSelector::PathOrSerial`],
632/// scans all connected devices (restricted to matching VID/PID for the
633/// former; every HID device for the latter) and matches `selector` against
634/// each one's OS path (substring), serial number (exact), or USB topology
635/// (port-chain) path (exact, Linux/macOS), all case-insensitively. Unlike
636/// [`UsbSelector::VidPid`], disambiguating by selector is the entire point of
637/// these two forms, so **more than one match is treated as an error** (listing
638/// every ambiguous candidate) rather than silently opening the first one --
639/// a substring selector such as a single digit can otherwise match several
640/// devices at once (e.g. Linux `/dev/hidraw1` also matches `/dev/hidraw1[0-9]`)
641/// and parallel-programming callers must be able to trust that they always
642/// open the one board they asked for. Returns an error listing the available
643/// candidates if none match either.
644fn open_selected_device(api: &HidApi, selector: &UsbSelector) -> ResultComm<HidDevice> {
645    match *selector {
646        // NOTE: `pid == 0` is documented as "match any product", but `HidApi::open`
647        // performs an exact PID match, so `open(vid, 0)` would never find a real
648        // device whose PID isn't literally 0. Filter the device list ourselves
649        // instead, so `pid == 0` really means "any PID" (and an exact PID still
650        // matches exactly).
651        UsbSelector::VidPid { vid, pid } => {
652            let matched = api
653                .device_list()
654                .find(|info| info.vendor_id() == vid && (pid == 0 || info.product_id() == pid));
655
656            match matched {
657                Some(info) => info
658                    .open_device(api)
659                    .map_err(|e| CommunicationError::ParseError(format!("Failed to open USB device: {e}"))),
660                None => Err(CommunicationError::ParseError(format!(
661                    "No USB-HID device matching vid=0x{vid:04X}{} found. Use the 'list-usb' command to see all \
662                     connected devices.",
663                    if pid == 0 {
664                        String::new()
665                    } else {
666                        format!(" pid=0x{pid:04X}")
667                    }
668                ))),
669            }
670        }
671        UsbSelector::VidPidWithSelector {
672            vid,
673            pid,
674            selector: ref selector_str,
675        } => {
676            // Same `pid == 0` => "any PID" convention as `VidPid` above, so that e.g.
677            // "0x1FC9,0,<selector>" behaves consistently with "0x1FC9,0" rather than
678            // silently matching nothing (hidapi/real devices never report PID 0).
679            let selector_lower = selector_str.to_lowercase();
680            let candidates: Vec<&DeviceInfo> = api
681                .device_list()
682                .filter(|info| info.vendor_id() == vid && (pid == 0 || info.product_id() == pid))
683                .collect();
684
685            open_unique_match(api, &candidates, selector_str, &selector_lower, || {
686                format!("vid=0x{vid:04X} pid=0x{pid:04X} and selector '{selector_str}'")
687            })
688        }
689        UsbSelector::PathOrSerial {
690            selector: ref selector_str,
691        } => {
692            let selector_lower = selector_str.to_lowercase();
693            let candidates: Vec<&DeviceInfo> = api.device_list().collect();
694
695            open_unique_match(api, &candidates, selector_str, &selector_lower, || {
696                format!("path/serial/topology '{selector_str}'")
697            })
698        }
699    }
700}
701
702/// Shared matching logic for [`UsbSelector::VidPidWithSelector`] and
703/// [`UsbSelector::PathOrSerial`]: finds every device among `candidates` that
704/// matches `selector`/`selector_lower` (see [`device_matches_selector`]), and:
705/// - prefers devices whose OS path equals the selector exactly, so a full path copied from
706///   `list-usb` is never rejected as ambiguous just because it is a substring of another
707///   device's path (e.g. `/dev/hidraw1` vs. `/dev/hidraw10`); substring matching is only
708///   used when no device matches exactly,
709/// - opens it if exactly one matched,
710/// - errors out listing all ambiguous candidates if more than one matched
711///   (disambiguation is the whole point of these two selector forms, so
712///   silently opening "the first one" would defeat it -- see
713///   [`open_selected_device`]),
714/// - errors out listing all same-VID/PID candidates (if any) if none matched.
715///
716/// `describe_target` lazily builds the "no device matching ..." message
717/// prefix (only called when actually needed, i.e. on the error paths).
718fn open_unique_match(
719    api: &HidApi,
720    candidates: &[&DeviceInfo],
721    selector_str: &str,
722    selector_lower: &str,
723    describe_target: impl FnOnce() -> String,
724) -> ResultComm<HidDevice> {
725    let find_matches = |exact_path| -> Vec<&&DeviceInfo> {
726        candidates
727            .iter()
728            .filter(|info| device_matches_selector(info, selector_str, selector_lower, exact_path))
729            .collect()
730    };
731    let mut matches = find_matches(true);
732    if matches.is_empty() {
733        matches = find_matches(false);
734    }
735
736    match matches.as_slice() {
737        [info] => info
738            .open_device(api)
739            .map_err(|e| CommunicationError::ParseError(format!("Failed to open USB device: {e}"))),
740        [] => {
741            let available: Vec<String> = candidates
742                .iter()
743                .map(|info| UsbDeviceInfo::from(*info).to_string())
744                .collect();
745            Err(CommunicationError::ParseError(format!(
746                "No USB-HID device matching {}. Use the 'list-usb' command to see all connected devices.{}",
747                describe_target(),
748                if available.is_empty() {
749                    String::new()
750                } else {
751                    format!(" Other connected devices: [{}]", available.join(", "))
752                }
753            )))
754        }
755        _ => {
756            let ambiguous: Vec<String> = matches
757                .iter()
758                .map(|info| UsbDeviceInfo::from(**info).to_string())
759                .collect();
760            Err(CommunicationError::ParseError(format!(
761                "Selector '{selector_str}' matches {} devices, refusing to guess which one to open: [{}]. Use a \
762                 more specific selector (e.g. the full OS path, serial number, or USB topology path from \
763                 'list-usb').",
764                matches.len(),
765                ambiguous.join(", ")
766            )))
767        }
768    }
769}
770
771fn parse_usb_identifier(identifier: &str) -> ResultComm<UsbSelector> {
772    // NOTE: both ':' and ',' are accepted as the vid/pid separator (and as the
773    // pid/selector separator), for compatibility with historical "<vid>:<pid>"
774    // usage. To avoid mis-splitting a bare path/serial selector that happens
775    // to contain a literal ':' or ',' (e.g. a macOS HID path such as
776    // "DevSrvsID:4294969627"), we only treat the identifier as containing a
777    // vid/pid prefix if its first ':'/','-separated part actually parses as a
778    // number; otherwise the *whole* identifier is used verbatim as a bare
779    // path/serial/topology-path selector, with no splitting at all. Windows
780    // (`\\?\HID#...`) and typical Linux paths/serials/topology paths don't
781    // contain ':' or ',' either, so this only matters on macOS.
782    let parts: Vec<&str> = identifier.splitn(3, [':', ',']).collect();
783
784    let vid_str = parts[0];
785    if vid_str.is_empty() {
786        return Err(CommunicationError::ParseError(format!(
787            "Invalid USB identifier: {identifier} (empty)"
788        )));
789    }
790    let Ok(vid) = parse_number_string(vid_str) else {
791        // The first part isn't a valid VID number: treat the *entire* identifier
792        // (not just `vid_str`) as a raw OS device path (substring match), exact
793        // serial number, or exact USB topology path, e.g. a value copied
794        // directly from `list-usb` or the OS's device manager.
795        return Ok(UsbSelector::PathOrSerial {
796            selector: identifier.to_owned(),
797        });
798    };
799
800    match parts.as_slice() {
801        [_] => {
802            // Use 0 as default PID, which will match any device with the specified VID
803            Ok(UsbSelector::VidPid { vid, pid: 0 })
804        }
805        [_, pid_str] => {
806            let pid = parse_number_string(pid_str)
807                .map_err(|_| CommunicationError::ParseError(format!("Invalid PID: {pid_str}")))?;
808            Ok(UsbSelector::VidPid { vid, pid })
809        }
810        [_, pid_str, selector] => {
811            let pid = parse_number_string(pid_str)
812                .map_err(|_| CommunicationError::ParseError(format!("Invalid PID: {pid_str}")))?;
813            if selector.is_empty() {
814                return Err(CommunicationError::ParseError(format!(
815                    "Invalid USB identifier: {identifier} (selector part is empty)"
816                )));
817            }
818            Ok(UsbSelector::VidPidWithSelector {
819                vid,
820                pid,
821                selector: (*selector).to_owned(),
822            })
823        }
824        _ => Err(CommunicationError::ParseError(format!(
825            "Invalid USB identifier: {identifier}"
826        ))),
827    }
828}
829
830/// Parse a number string that can be either decimal or hexadecimal.
831///
832/// Delegates to [`parsers::parse_number`] (same `0x`-prefixed-hex-or-decimal rules used
833/// everywhere else in the CLI; the trimming and uppercase-`0X`-prefix normalization is done
834/// here at the call site, as `parse_number` itself only recognizes a lowercase `0x` prefix),
835/// with a fallback to hex-without-prefix (e.g. `"ABCD"`) for backward compatibility with
836/// pre-existing bare-hex VID/PID input.
837fn parse_number_string(s: &str) -> Result<u16, String> {
838    let trimmed = s.trim();
839    let lowercased_prefix = trimmed.strip_prefix("0X").map(|rest| format!("0x{rest}"));
840    let normalized = lowercased_prefix.as_deref().unwrap_or(trimmed);
841
842    parsers::parse_number::<u16>(normalized).or_else(|_| {
843        u16::from_str_radix(trimmed, 16).map_err(|_| format!("number '{trimmed}' is invalid or too large!"))
844    })
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850
851    #[test]
852    fn test_parse_vid_only() {
853        let selector = parse_usb_identifier("0x1FC9").unwrap();
854        assert_eq!(selector, UsbSelector::VidPid { vid: 0x1FC9, pid: 0 });
855    }
856
857    #[test]
858    fn test_parse_vid_uppercase_prefix() {
859        // `parse_number_string` must still accept an uppercase "0X" prefix, same as before
860        // it was simplified to delegate to `parsers::parse_number` (which only recognizes a
861        // lowercase "0x" prefix on its own).
862        let selector = parse_usb_identifier("0X1FC9").unwrap();
863        assert_eq!(selector, UsbSelector::VidPid { vid: 0x1FC9, pid: 0 });
864    }
865
866    #[test]
867    fn test_parse_vid_hex_without_prefix() {
868        // Bare hex-looking VID (no "0x"/"0X" prefix, but containing a-f so it can't be parsed
869        // as decimal) must still be accepted, e.g. copy-pasted straight from Device
870        // Manager/`lsusb` output.
871        let selector = parse_usb_identifier("1FC9").unwrap();
872        assert_eq!(selector, UsbSelector::VidPid { vid: 0x1FC9, pid: 0 });
873    }
874
875    #[test]
876    fn test_parse_vid_pid_comma() {
877        let selector = parse_usb_identifier("0x1FC9,0x0135").unwrap();
878        assert_eq!(
879            selector,
880            UsbSelector::VidPid {
881                vid: 0x1FC9,
882                pid: 0x0135
883            }
884        );
885    }
886
887    #[test]
888    fn test_parse_vid_pid_colon() {
889        let selector = parse_usb_identifier("0x1FC9:0x0135").unwrap();
890        assert_eq!(
891            selector,
892            UsbSelector::VidPid {
893                vid: 0x1FC9,
894                pid: 0x0135
895            }
896        );
897    }
898
899    #[test]
900    fn test_parse_vid_pid_decimal() {
901        let selector = parse_usb_identifier("8137,309").unwrap();
902        assert_eq!(selector, UsbSelector::VidPid { vid: 8137, pid: 309 });
903    }
904
905    #[test]
906    fn test_parse_vid_pid_with_path_selector() {
907        let selector = parse_usb_identifier("0x1FC9,0x0135,HID\\VID_1FC9&PID_0135\\6&7909BF&0&0000").unwrap();
908        assert_eq!(
909            selector,
910            UsbSelector::VidPidWithSelector {
911                vid: 0x1FC9,
912                pid: 0x0135,
913                selector: "HID\\VID_1FC9&PID_0135\\6&7909BF&0&0000".to_owned()
914            }
915        );
916    }
917
918    #[test]
919    fn test_parse_vid_pid_with_serial_selector() {
920        let selector = parse_usb_identifier("0x1FC9,0x0135,ABCD1234").unwrap();
921        assert_eq!(
922            selector,
923            UsbSelector::VidPidWithSelector {
924                vid: 0x1FC9,
925                pid: 0x0135,
926                selector: "ABCD1234".to_owned()
927            }
928        );
929    }
930
931    #[test]
932    fn test_parse_vid_pid_with_topology_selector() {
933        let selector = parse_usb_identifier("0x1FC9,0x0135,1-1.4.3").unwrap();
934        assert_eq!(
935            selector,
936            UsbSelector::VidPidWithSelector {
937                vid: 0x1FC9,
938                pid: 0x0135,
939                selector: "1-1.4.3".to_owned()
940            }
941        );
942    }
943
944    #[test]
945    fn test_parse_empty_selector_errors() {
946        let result = parse_usb_identifier("0x1FC9,0x0135,");
947        assert!(result.is_err());
948    }
949
950    #[test]
951    fn test_parse_single_token_non_number_is_path_or_serial() {
952        // A single token that isn't a valid VID number is treated as a bare
953        // path/serial selector (e.g. a raw path copied from `list-usb`),
954        // rather than being rejected as an "invalid VID".
955        let selector = parse_usb_identifier("not_a_number").unwrap();
956        assert_eq!(
957            selector,
958            UsbSelector::PathOrSerial {
959                selector: "not_a_number".to_owned()
960            }
961        );
962    }
963
964    #[test]
965    fn test_parse_windows_path_as_path_or_serial() {
966        let path = r"\\?\HID#VID_1FC9&PID_0021#9&1fee2255&0&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}";
967        let selector = parse_usb_identifier(path).unwrap();
968        assert_eq!(
969            selector,
970            UsbSelector::PathOrSerial {
971                selector: path.to_owned()
972            }
973        );
974    }
975
976    #[test]
977    fn test_parse_serial_as_path_or_serial() {
978        let selector = parse_usb_identifier("ABCD1234").unwrap();
979        assert_eq!(
980            selector,
981            UsbSelector::PathOrSerial {
982                selector: "ABCD1234".to_owned()
983            }
984        );
985    }
986
987    #[test]
988    fn test_parse_bare_topology_path_as_path_or_serial() {
989        // A bare "1-1.4.3" topology path (no vid/pid prefix) is parsed as a
990        // generic PathOrSerial selector, same as any other bare path/serial;
991        // the topology match itself happens later in `device_matches_selector`.
992        let selector = parse_usb_identifier("1-1.4.3").unwrap();
993        assert_eq!(
994            selector,
995            UsbSelector::PathOrSerial {
996                selector: "1-1.4.3".to_owned()
997            }
998        );
999    }
1000
1001    #[test]
1002    fn test_parse_macos_path_with_colon_as_path_or_serial() {
1003        // macOS hidapi paths look like "DevSrvsID:4294969627": the leading
1004        // "DevSrvsID" isn't a valid VID number, so the whole identifier
1005        // (including the ':' and everything after it) must be kept intact as
1006        // a single bare selector, rather than being mis-split into a bogus
1007        // "<vid>,<pid>" pair (which would previously fail with "Invalid VID:
1008        // DevSrvsID").
1009        let path = "DevSrvsID:4294969627";
1010        let selector = parse_usb_identifier(path).unwrap();
1011        assert_eq!(
1012            selector,
1013            UsbSelector::PathOrSerial {
1014                selector: path.to_owned()
1015            }
1016        );
1017    }
1018
1019    #[test]
1020    fn test_parse_empty_identifier_errors() {
1021        let result = parse_usb_identifier("");
1022        assert!(result.is_err());
1023    }
1024
1025    #[test]
1026    fn test_nxp_vid_filter_accepts_known_nxp_vids() {
1027        for vid in NXP_USB_VIDS {
1028            assert!(is_nxp_vid(vid));
1029        }
1030    }
1031
1032    #[test]
1033    fn test_nxp_vid_filter_rejects_unknown_vendor() {
1034        // Common non-NXP VIDs (mouse/keyboard vendors) must not be mistaken for NXP devices.
1035        for vid in [0x413Cu16, 0x046Du16, 0x8087u16] {
1036            assert!(!is_nxp_vid(vid));
1037        }
1038    }
1039
1040    #[test]
1041    fn test_usb_arg_round_trips_through_parse_usb_identifier() {
1042        let device = UsbDeviceInfo {
1043            vid: 0x1FC9,
1044            pid: 0x0021,
1045            path: r"\\?\HID#VID_1FC9&PID_0021#9&1fee2255&0&0000#{...}".to_owned(),
1046            topology_path: None,
1047            serial_number: None,
1048            product_string: Some("USB COMPOSITE DEVICE".to_owned()),
1049            manufacturer_string: None,
1050        };
1051        let arg = device.usb_arg();
1052        assert_eq!(arg, r"0x1FC9,0x0021,\\?\HID#VID_1FC9&PID_0021#9&1fee2255&0&0000#{...}");
1053
1054        // The printed argument must be directly usable as a `-u/--usb` value.
1055        let selector = parse_usb_identifier(&arg).unwrap();
1056        assert_eq!(
1057            selector,
1058            UsbSelector::VidPidWithSelector {
1059                vid: 0x1FC9,
1060                pid: 0x0021,
1061                selector: device.path,
1062            }
1063        );
1064    }
1065
1066    #[test]
1067    fn test_usb_topology_arg_none_without_topology_path() {
1068        let device = UsbDeviceInfo {
1069            vid: 0x1FC9,
1070            pid: 0x0021,
1071            path: "/dev/hidraw3".to_owned(),
1072            topology_path: None,
1073            serial_number: None,
1074            product_string: None,
1075            manufacturer_string: None,
1076        };
1077        assert_eq!(device.usb_topology_arg(), None);
1078    }
1079
1080    #[test]
1081    fn test_usb_topology_arg_formats_like_usb_arg() {
1082        let device = UsbDeviceInfo {
1083            vid: 0x1FC9,
1084            pid: 0x0021,
1085            path: "/dev/hidraw3".to_owned(),
1086            topology_path: Some("1-1.4.3".to_owned()),
1087            serial_number: None,
1088            product_string: None,
1089            manufacturer_string: None,
1090        };
1091        let arg = device.usb_topology_arg().unwrap();
1092        assert_eq!(arg, "0x1FC9,0x0021,1-1.4.3");
1093
1094        // Must round-trip through the parser, same as `usb_arg`.
1095        let selector = parse_usb_identifier(&arg).unwrap();
1096        assert_eq!(
1097            selector,
1098            UsbSelector::VidPidWithSelector {
1099                vid: 0x1FC9,
1100                pid: 0x0021,
1101                selector: "1-1.4.3".to_owned(),
1102            }
1103        );
1104    }
1105
1106    #[test]
1107    fn test_topology_path_from_location_id() {
1108        assert_eq!(topology_path_from_location_id(0x1432_0000).as_deref(), Some("20-3.2"));
1109        assert_eq!(topology_path_from_location_id(0x0110_0000).as_deref(), Some("1-1"));
1110        assert_eq!(
1111            topology_path_from_location_id(0x8012_3456).as_deref(),
1112            Some("128-1.2.3.4.5.6")
1113        );
1114        // Ports above 9 are still single hex digits in the locationID.
1115        assert_eq!(topology_path_from_location_id(0x02FA_0000).as_deref(), Some("2-15.10"));
1116        // A zero digit terminates the chain; anything after it is ignored.
1117        assert_eq!(topology_path_from_location_id(0x0130_5000).as_deref(), Some("1-3"));
1118        // Root hubs / non-USB devices have no port component.
1119        assert_eq!(topology_path_from_location_id(0), None);
1120        assert_eq!(topology_path_from_location_id(0x8000_0000), None);
1121    }
1122
1123    #[test]
1124    fn test_parse_bare_macos_topology_path_as_path_or_serial() {
1125        assert_eq!(
1126            parse_usb_identifier("20-3.2").unwrap(),
1127            UsbSelector::PathOrSerial {
1128                selector: "20-3.2".to_owned()
1129            }
1130        );
1131    }
1132
1133    #[cfg(target_os = "linux")]
1134    #[test]
1135    fn test_is_usb_topology_name_accepts_valid_names() {
1136        for name in ["1-1", "1-1.4", "1-1.4.3", "2-10.3.1.4"] {
1137            assert!(is_usb_topology_name(name), "{name} should be a valid topology name");
1138        }
1139    }
1140
1141    #[cfg(target_os = "linux")]
1142    #[test]
1143    fn test_is_usb_topology_name_rejects_non_topology_names() {
1144        for name in [
1145            "usb1",        // root hub directory, not a topology name
1146            "hidraw0",     // hidraw device node name
1147            "1-1.4.3:1.0", // USB interface directory (has a ':')
1148            "1-",          // no port component
1149            "-1",          // no bus component
1150            "1-1.",        // trailing empty port component
1151            "1-.1",        // leading empty port component
1152            "a-1",         // non-numeric bus
1153            "1-1.a",       // non-numeric port
1154            "",
1155        ] {
1156            assert!(
1157                !is_usb_topology_name(name),
1158                "{name} should not be a valid topology name"
1159            );
1160        }
1161    }
1162}