blob: c303e24668874cb7f42442b16ff595d2d9124a51 [file] [log] [blame]
use std::collections::HashSet;
use std::{mem, ptr};
use winapi::ctypes::c_void;
use winapi::shared::guiddef::*;
use winapi::shared::minwindef::*;
use winapi::shared::winerror::*;
use winapi::um::cfgmgr32::*;
use winapi::um::cguid::GUID_NULL;
use winapi::um::setupapi::*;
use winapi::um::winnt::{KEY_READ, REG_SZ};
use winapi::um::winreg::*;
use crate::{Error, ErrorKind, Result, SerialPortInfo, SerialPortType, UsbPortInfo};
/// takes normal Rust `str` and outputs a null terminated UTF-16 encoded string
fn as_utf16(utf8: &str) -> Vec<u16> {
utf8.encode_utf16().chain(Some(0)).collect()
}
/// takes a UTF-16 encoded slice (null termination not required)
/// and converts to a UTF8 Rust string. Trailing null chars are removed
fn from_utf16_lossy_trimmed(utf16: &[u16]) -> String {
String::from_utf16_lossy(utf16)
.trim_end_matches(0 as char)
.to_string()
}
/// According to the MSDN docs, we should use SetupDiGetClassDevs, SetupDiEnumDeviceInfo
/// and SetupDiGetDeviceInstanceId in order to enumerate devices.
/// https://msdn.microsoft.com/en-us/windows/hardware/drivers/install/enumerating-installed-devices
fn get_ports_guids() -> Result<Vec<GUID>> {
// SetupDiGetClassDevs returns the devices associated with a particular class of devices.
// We want the list of devices which are listed as COM ports (generally those that show up in the
// Device Manager as "Ports (COM & LPT)" which is otherwise known as the "Ports" class).
//
// The list of system defined classes can be found here:
// https://learn.microsoft.com/en-us/windows-hardware/drivers/install/system-defined-device-setup-classes-available-to-vendors
let class_names = ["Ports", "Modem"];
let mut guids: Vec<GUID> = Vec::new();
for class_name in class_names {
let class_name_w = as_utf16(class_name);
let mut num_guids: DWORD = 1; // Initially assume that there is only 1 guid per name.
let class_start_idx = guids.len(); // start idx for this name (for potential resize with multiple guids)
// first attempt with size == 1, second with the size returned from the first try
for _ in 0..2 {
guids.resize(class_start_idx + num_guids as usize, GUID_NULL);
let guid_buffer = &mut guids[class_start_idx..];
// Find out how many GUIDs are associated with this class name. num_guids will tell us how many there actually are.
let res = unsafe {
SetupDiClassGuidsFromNameW(
class_name_w.as_ptr(),
guid_buffer.as_mut_ptr(),
guid_buffer.len() as DWORD,
&mut num_guids,
)
};
if res == FALSE {
return Err(Error::new(
ErrorKind::Unknown,
"Unable to determine number of Ports GUIDs",
));
}
let len_cmp = guid_buffer.len().cmp(&(num_guids as usize));
// under allocated
if len_cmp == std::cmp::Ordering::Less {
continue; // retry
}
// allocation > required len
else if len_cmp == std::cmp::Ordering::Greater {
guids.truncate(class_start_idx + num_guids as usize);
}
break; // next name
}
}
Ok(guids)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct HwidMatches<'hwid> {
vid: &'hwid str,
pid: &'hwid str,
serial: Option<&'hwid str>,
interface: Option<&'hwid str>,
}
impl<'hwid> HwidMatches<'hwid> {
fn new(hwid: &'hwid str) -> Option<Self> {
// When we match something, update this so that we are always looking forward
let mut hwid_tail = hwid;
// VID_(?P<vid>[[:xdigit:]]{4})
let vid_start = hwid.find("VID_")?;
// We won't match for hex characters here. That can be done when parsed.
let vid = hwid_tail.get(vid_start + 4..vid_start + 8)?;
hwid_tail = hwid_tail.get(vid_start + 8..)?;
// [&+]PID_(?P<pid>[[:xdigit:]]{4})
let pid = if hwid_tail.starts_with("&PID_") || hwid_tail.starts_with("+PID_") {
// We will let the hex parser fail if there are not hex digits.
hwid_tail.get(5..9)?
} else {
return None;
};
hwid_tail = hwid_tail.get(9..)?;
// (?:[&+]MI_(?P<iid>[[:xdigit:]]{2})){0,1}
let iid = if hwid_tail.starts_with("&MI_") || hwid_tail.starts_with("+MI_") {
// We will let the hex parser fail if there are not hex digits.
let iid = hwid_tail.get(4..6);
hwid_tail = hwid_tail.get(6..).unwrap_or(hwid_tail);
iid
} else {
None
};
// ([\\+](?P<serial>\w+))? with slightly modified check for alphanumeric characters instead
// of regex word character
//
// TODO: Fix returning no serial number at all for devices without one. The previous regex
// and the code below return the first thing from the intance ID. See issue #203.
let serial = if hwid_tail.starts_with('\\') || hwid_tail.starts_with('+') {
hwid_tail.get(1..).and_then(|tail| {
let index = tail
.char_indices()
.find(|&(_, char)| !char.is_alphanumeric())
.map(|(index, _)| index)
.unwrap_or(tail.len());
tail.get(..index)
})
} else {
None
};
Some(Self {
vid,
pid,
serial,
interface: iid,
})
}
}
/// Windows usb port information can be determined by the port's HWID string.
///
/// This function parses the HWID string using regex, and returns the USB port
/// information if the hardware ID can be parsed correctly. The manufacturer
/// and product names cannot be determined from the HWID string, so those are
/// set as None.
///
/// For composite USB devices, the HWID string will be for the interface. In
/// this case, the parent HWID string must be provided so that the correct
/// serial number can be determined.
///
/// Some HWID examples are:
/// - MicroPython pyboard: USB\VID_F055&PID_9802\385435603432
/// - BlackMagic GDB Server: USB\VID_1D50&PID_6018&MI_00\6&A694CA9&0&0000
/// - BlackMagic UART port: USB\VID_1D50&PID_6018&MI_02\6&A694CA9&0&0002
/// - FTDI Serial Adapter: FTDIBUS\VID_0403+PID_6001+A702TB52A\0000
fn parse_usb_port_info(hardware_id: &str, parent_hardware_id: Option<&str>) -> Option<UsbPortInfo> {
let mut caps = HwidMatches::new(hardware_id)?;
let interface = caps.interface.and_then(|m| u8::from_str_radix(m, 16).ok());
if interface.is_some() {
// If this is a composite device, we need to parse the parent's HWID to get the correct information.
caps = HwidMatches::new(parent_hardware_id?)?;
}
Some(UsbPortInfo {
vid: u16::from_str_radix(caps.vid, 16).ok()?,
pid: u16::from_str_radix(caps.pid, 16).ok()?,
serial_number: caps.serial.map(str::to_string),
manufacturer: None,
product: None,
#[cfg(feature = "usbportinfo-interface")]
interface,
})
}
struct PortDevices {
/// Handle to a device information set.
hdi: HDEVINFO,
/// Index used by iterator.
dev_idx: DWORD,
}
impl PortDevices {
// Creates PortDevices object which represents the set of devices associated with a particular
// Ports class (given by `guid`).
pub fn new(guid: &GUID) -> Self {
PortDevices {
hdi: unsafe { SetupDiGetClassDevsW(guid, ptr::null(), ptr::null_mut(), DIGCF_PRESENT) },
dev_idx: 0,
}
}
}
impl Iterator for PortDevices {
type Item = PortDevice;
/// Iterator which returns a PortDevice from the set of PortDevices associated with a
/// particular PortDevices class (guid).
fn next(&mut self) -> Option<PortDevice> {
let mut port_dev = PortDevice {
hdi: self.hdi,
devinfo_data: SP_DEVINFO_DATA {
cbSize: mem::size_of::<SP_DEVINFO_DATA>() as DWORD,
ClassGuid: GUID_NULL,
DevInst: 0,
Reserved: 0,
},
};
let res =
unsafe { SetupDiEnumDeviceInfo(self.hdi, self.dev_idx, &mut port_dev.devinfo_data) };
if res == FALSE {
None
} else {
self.dev_idx += 1;
Some(port_dev)
}
}
}
impl Drop for PortDevices {
fn drop(&mut self) {
// Release the PortDevices object allocated in the constructor.
unsafe {
SetupDiDestroyDeviceInfoList(self.hdi);
}
}
}
struct PortDevice {
/// Handle to a device information set.
hdi: HDEVINFO,
/// Information associated with this device.
pub devinfo_data: SP_DEVINFO_DATA,
}
impl PortDevice {
/// Retrieves the device instance id string associated with this device's parent.
/// This is useful for determining the serial number of a composite USB device.
fn parent_instance_id(&mut self) -> Option<String> {
let mut result_buf = [0u16; MAX_PATH];
let mut parent_device_instance_id = 0;
let res =
unsafe { CM_Get_Parent(&mut parent_device_instance_id, self.devinfo_data.DevInst, 0) };
if res == CR_SUCCESS {
let buffer_len = result_buf.len() - 1;
let res = unsafe {
CM_Get_Device_IDW(
parent_device_instance_id,
result_buf.as_mut_ptr(),
buffer_len as ULONG,
0,
)
};
if res == CR_SUCCESS {
Some(from_utf16_lossy_trimmed(&result_buf))
} else {
None
}
} else {
None
}
}
/// Retrieves the device instance id string associated with this device. Some examples of
/// instance id strings are:
/// * MicroPython Board: USB\VID_F055&PID_9802\385435603432
/// * FTDI USB Adapter: FTDIBUS\VID_0403+PID_6001+A702TB52A\0000
/// * Black Magic Probe (Composite device with 2 UARTS):
/// * GDB Port: USB\VID_1D50&PID_6018&MI_00\6&A694CA9&0&0000
/// * UART Port: USB\VID_1D50&PID_6018&MI_02\6&A694CA9&0&0002
///
/// Reference: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/device-instance-ids
fn instance_id(&mut self) -> Option<String> {
let mut result_buf = [0u16; MAX_DEVICE_ID_LEN];
let working_buffer_len = result_buf.len() - 1; // always null terminated
let mut desired_result_len = 0; // possibly larger than the buffer
let res = unsafe {
SetupDiGetDeviceInstanceIdW(
self.hdi,
&mut self.devinfo_data,
result_buf.as_mut_ptr(),
working_buffer_len as DWORD,
&mut desired_result_len,
)
};
if res == FALSE {
// Try to retrieve hardware id property.
self.property(SPDRP_HARDWAREID)
} else {
let actual_result_len = working_buffer_len.min(desired_result_len as usize);
Some(from_utf16_lossy_trimmed(&result_buf[..actual_result_len]))
}
}
/// Retrieves the problem status of this device. For example, `CM_PROB_DISABLED` indicates
/// the device has been disabled in Device Manager.
fn problem(&mut self) -> Option<ULONG> {
let mut status = 0;
let mut problem_number = 0;
let res = unsafe {
CM_Get_DevNode_Status(
&mut status,
&mut problem_number,
self.devinfo_data.DevInst,
0,
)
};
if res == CR_SUCCESS {
Some(problem_number)
} else {
None
}
}
// Retrieves the port name (i.e. COM6) associated with this device.
pub fn name(&mut self) -> String {
// https://learn.microsoft.com/en-us/windows/win32/api/setupapi/nf-setupapi-setupdiopendevregkey
let hkey = unsafe {
SetupDiOpenDevRegKey(
self.hdi,
&mut self.devinfo_data,
DICS_FLAG_GLOBAL,
0,
DIREG_DEV,
KEY_READ,
)
};
if hkey as *mut c_void == winapi::um::handleapi::INVALID_HANDLE_VALUE {
// failed to open registry key. Return empty string as the failure case
return String::new();
}
// https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regqueryvalueexw
let mut port_name_buffer = [0u16; MAX_PATH];
let buffer_byte_len = 2 * port_name_buffer.len() as DWORD;
let mut byte_len = buffer_byte_len;
let mut value_type = 0;
let value_name = as_utf16("PortName");
let err = unsafe {
RegQueryValueExW(
hkey,
value_name.as_ptr(),
ptr::null_mut(),
&mut value_type,
port_name_buffer.as_mut_ptr() as *mut u8,
&mut byte_len,
)
};
unsafe { RegCloseKey(hkey) };
if FAILED(err) {
// failed to query registry for some reason. Return empty string as the failure case
return String::new();
}
// https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry-value-types
if value_type != REG_SZ || byte_len % 2 != 0 || byte_len > buffer_byte_len {
// read something but it wasn't the expected registry type
return String::new();
}
// len of u16 chars, not bytes
let len = buffer_byte_len as usize / 2;
let port_name = &port_name_buffer[0..len];
from_utf16_lossy_trimmed(port_name)
}
// Determines the port_type for this device, and if it's a USB port populate the various fields.
pub fn port_type(&mut self) -> SerialPortType {
self.instance_id()
.map(|s| (s, self.parent_instance_id())) // Get parent instance id if it exists.
.and_then(|(d, p)| parse_usb_port_info(&d, p.as_deref()))
.map(|mut info: UsbPortInfo| {
info.manufacturer = self.property(SPDRP_MFG);
info.product = self.property(SPDRP_FRIENDLYNAME);
SerialPortType::UsbPort(info)
})
.unwrap_or(SerialPortType::Unknown)
}
// Retrieves a device property and returns it, if it exists. Returns None if the property
// doesn't exist.
fn property(&mut self, property_id: DWORD) -> Option<String> {
let mut value_type = 0;
let mut property_buf = [0u16; MAX_PATH];
let res = unsafe {
SetupDiGetDeviceRegistryPropertyW(
self.hdi,
&mut self.devinfo_data,
property_id,
&mut value_type,
property_buf.as_mut_ptr() as PBYTE,
property_buf.len() as DWORD,
ptr::null_mut(),
)
};
if res == FALSE || value_type != REG_SZ {
return None;
}
// Using the unicode version of 'SetupDiGetDeviceRegistryProperty' seems to report the
// entire mfg registry string. This typically includes some driver information that we should discard.
// Example string: 'FTDI5.inf,%ftdi%;FTDI'
from_utf16_lossy_trimmed(&property_buf)
.split(';')
.last()
.map(str::to_string)
}
}
/// Not all COM ports are listed under the "Ports" device class
/// The full list of COM ports is available from the registry at
/// HKEY_LOCAL_MACHINE\HARDWARE\DEVICEMAP\SERIALCOMM
///
/// port of https://learn.microsoft.com/en-us/windows/win32/sysinfo/enumerating-registry-subkeys
fn get_registry_com_ports() -> HashSet<String> {
let mut ports_list = HashSet::new();
let reg_key = as_utf16("HARDWARE\\DEVICEMAP\\SERIALCOMM");
let key_ptr = reg_key.as_ptr();
let mut ports_key = std::ptr::null_mut();
// SAFETY: ffi, all inputs are correct
let open_res =
unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, key_ptr, 0, KEY_READ, &mut ports_key) };
if SUCCEEDED(open_res) {
let mut class_name_buff = [0u16; MAX_PATH];
let mut class_name_size = MAX_PATH as u32;
let mut sub_key_count = 0;
let mut largest_sub_key = 0;
let mut largest_class_string = 0;
let mut num_key_values = 0;
let mut longest_value_name = 0;
let mut longest_value_data = 0;
let mut size_security_desc = 0;
let mut last_write_time = FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
};
// SAFETY: ffi, all inputs are correct
let query_res = unsafe {
RegQueryInfoKeyW(
ports_key,
class_name_buff.as_mut_ptr(),
&mut class_name_size,
std::ptr::null_mut(),
&mut sub_key_count,
&mut largest_sub_key,
&mut largest_class_string,
&mut num_key_values,
&mut longest_value_name,
&mut longest_value_data,
&mut size_security_desc,
&mut last_write_time,
)
};
if SUCCEEDED(query_res) {
for idx in 0..num_key_values {
let mut val_name_buff = [0u16; MAX_PATH];
let mut val_name_size = MAX_PATH as u32;
let mut value_type = 0;
let mut val_data = [0u16; MAX_PATH];
let buffer_byte_len = 2 * val_data.len() as DWORD; // len doubled
let mut byte_len = buffer_byte_len;
// SAFETY: ffi, all inputs are correct
let res = unsafe {
RegEnumValueW(
ports_key,
idx,
val_name_buff.as_mut_ptr(),
&mut val_name_size,
std::ptr::null_mut(),
&mut value_type,
val_data.as_mut_ptr() as *mut u8,
&mut byte_len,
)
};
if FAILED(res)
|| value_type != REG_SZ // only valid for text values
|| byte_len % 2 != 0 // out byte len should be a multiple of u16 size
|| byte_len > buffer_byte_len
{
break;
}
// key data is returned as u16
// SAFETY: data_size is checked and pointer is valid
let val_data = from_utf16_lossy_trimmed(unsafe {
let utf16_len = byte_len / 2; // utf16 len
std::slice::from_raw_parts(val_data.as_ptr(), utf16_len as usize)
});
ports_list.insert(val_data);
}
}
// SAFETY: ffi, all inputs are correct
unsafe { RegCloseKey(ports_key) };
}
ports_list
}
/// List available serial ports on the system.
pub fn available_ports() -> Result<Vec<SerialPortInfo>> {
let mut ports = Vec::new();
for guid in get_ports_guids()? {
let port_devices = PortDevices::new(&guid);
for mut port_device in port_devices {
// Ignore nonfunctional devices
if port_device.problem() != Some(0) {
continue;
}
let port_name = port_device.name();
debug_assert!(
port_name.as_bytes().last().map_or(true, |c| *c != b'\0'),
"port_name has a trailing nul: {:?}",
port_name
);
// This technique also returns parallel ports, so we filter these out.
if port_name.starts_with("LPT") {
continue;
}
ports.push(SerialPortInfo {
port_name,
port_type: port_device.port_type(),
});
}
}
// ports identified through the registry have no additional information
let mut raw_ports_set = get_registry_com_ports();
if raw_ports_set.len() > ports.len() {
// remove any duplicates. HashSet makes this relatively cheap
for port in ports.iter() {
raw_ports_set.remove(&port.port_name);
}
// add remaining ports as "unknown" type
for raw_port in raw_ports_set {
ports.push(SerialPortInfo {
port_name: raw_port,
port_type: SerialPortType::Unknown,
})
}
}
Ok(ports)
}
#[cfg(test)]
mod tests {
use super::*;
use quickcheck_macros::quickcheck;
#[test]
fn from_utf16_lossy_trimmed_trimming_empty() {
assert_eq!("", from_utf16_lossy_trimmed(&[]));
assert_eq!("", from_utf16_lossy_trimmed(&[0]));
}
#[test]
fn from_utf16_lossy_trimmed_trimming() {
let test_str = "Testing";
let wtest_str: Vec<u16> = as_utf16(test_str);
let wtest_str_trailing = wtest_str
.iter()
.copied()
.chain([0, 0, 0, 0]) // add some null chars
.collect::<Vec<_>>();
let and_back = from_utf16_lossy_trimmed(&wtest_str_trailing);
assert_eq!(test_str, and_back);
}
// Check that passing some random data to HwidMatches::new() does not cause a panic.
#[quickcheck]
fn quickcheck_hwidmatches_new_does_not_panic_from_random_input(hwid: String) -> bool {
let _ = HwidMatches::new(&hwid);
true
}
// Corner cases which might not always represent what we want to/should parse. But they at
// least illustrate how we are parsing device identification strings today.
#[test]
fn test_hwidmatches_new_corner_cases() {
assert!(HwidMatches::new("").is_none());
assert!(HwidMatches::new("ROOT").is_none());
assert!(HwidMatches::new("ROOT\\").is_none());
assert!(HwidMatches::new("USB\\").is_none());
assert!(HwidMatches::new("USB\\VID_1234").is_none());
assert!(HwidMatches::new("USB\\PID_1234").is_none());
assert!(HwidMatches::new("USB\\MI_12").is_none());
assert_eq!(
HwidMatches::new("VID_1234&PID_5678").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: None,
interface: None,
}
);
assert_eq!(
HwidMatches::new("ABC\\VID_1234&PID_5678&MI_90").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: None,
interface: Some("90"),
}
);
assert_eq!(
HwidMatches::new("FTDIBUS\\VID_1234&PID_5678&MI_90").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: None,
interface: Some("90"),
}
);
assert_eq!(
HwidMatches::new("USB\\VID_1234+PID_5678+MI_90").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: None,
interface: Some("90"),
}
);
assert_eq!(
HwidMatches::new("FTDIBUS\\VID_1234+PID_5678\\0000").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: Some("0000"),
interface: None,
}
);
}
#[test]
fn test_hwidmatches_new_standard_cases_ftdi() {
assert_eq!(
HwidMatches::new("FTDIBUS\\VID_1234+PID_5678+SERIAL123\\0000").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: Some("SERIAL123"),
interface: None,
}
);
}
#[test]
fn test_hwidmatches_new_standard_cases_usb() {
assert_eq!(
HwidMatches::new("USB\\VID_1234&PID_5678").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: None,
interface: None,
}
);
assert_eq!(
HwidMatches::new("USB\\VID_1234&PID_5678&MI_90").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: None,
interface: Some("90"),
}
);
assert_eq!(
HwidMatches::new("USB\\VID_1234&PID_5678\\SERIAL123").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: Some("SERIAL123"),
interface: None,
}
);
assert_eq!(
HwidMatches::new("USB\\VID_1234&PID_5678&MI_90\\SERIAL123").unwrap(),
HwidMatches {
vid: "1234",
pid: "5678",
serial: Some("SERIAL123"),
interface: Some("90"),
}
);
}
#[test]
fn test_parsing_usb_port_information() {
let madeup_hwid = r"USB\VID_1D50&PID_6018+6&A694CA9&0&0000";
let info = parse_usb_port_info(madeup_hwid, None).unwrap();
// TODO: Fix returning no serial at all for devices without one. See issue #203.
assert_eq!(info.serial_number, Some("6".to_string()));
let bm_uart_hwid = r"USB\VID_1D50&PID_6018&MI_02\6&A694CA9&0&0000";
let bm_parent_hwid = r"USB\VID_1D50&PID_6018\85A12F01";
let info = parse_usb_port_info(bm_uart_hwid, Some(bm_parent_hwid)).unwrap();
assert_eq!(info.vid, 0x1D50);
assert_eq!(info.pid, 0x6018);
assert_eq!(info.serial_number, Some("85A12F01".to_string()));
#[cfg(feature = "usbportinfo-interface")]
assert_eq!(info.interface, Some(2));
let ftdi_serial_hwid = r"FTDIBUS\VID_0403+PID_6001+A702TB52A\0000";
let info = parse_usb_port_info(ftdi_serial_hwid, None).unwrap();
assert_eq!(info.vid, 0x0403);
assert_eq!(info.pid, 0x6001);
assert_eq!(info.serial_number, Some("A702TB52A".to_string()));
#[cfg(feature = "usbportinfo-interface")]
assert_eq!(info.interface, None);
let pyboard_hwid = r"USB\VID_F055&PID_9802\385435603432";
let info = parse_usb_port_info(pyboard_hwid, None).unwrap();
assert_eq!(info.vid, 0xF055);
assert_eq!(info.pid, 0x9802);
assert_eq!(info.serial_number, Some("385435603432".to_string()));
#[cfg(feature = "usbportinfo-interface")]
assert_eq!(info.interface, None);
let unicode_serial = r"USB\VID_F055&PID_9802\3854356β03432&test";
let info = parse_usb_port_info(unicode_serial, None).unwrap();
assert_eq!(info.serial_number.as_deref(), Some("3854356β03432"));
let unicode_serial = r"USB\VID_F055&PID_9802\3854356β03432";
let info = parse_usb_port_info(unicode_serial, None).unwrap();
assert_eq!(info.serial_number.as_deref(), Some("3854356β03432"));
let unicode_serial = r"USB\VID_F055&PID_9802\3854356β";
let info = parse_usb_port_info(unicode_serial, None).unwrap();
assert_eq!(info.serial_number.as_deref(), Some("3854356β"));
}
}