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}