diff --git a/Cargo.lock b/Cargo.lock index 4ed10810..753132c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,6 +1423,7 @@ dependencies = [ "tracing", "tracing-test", "uuid", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 024d5792..06daef88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,10 @@ hyper = { version = "1", features = ["full"] } reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "stream", "rustls-tls"] } semver = "1" tokio-serial = "5" -serialport = { version = "4", default-features = false } +# `usbportinfo-interface` exposes `UsbPortInfo.interface` (the composite +# `MI_xx` index), used by fbuild-serial's Windows enumerator to disambiguate +# multi-function devices (e.g. Teensy Serial vs Serial+MIDI). FastLED/fbuild#962. +serialport = { version = "4", default-features = false, features = ["usbportinfo-interface"] } pyo3 = { version = "0.22", features = ["abi3-py310"] } tempfile = "3" filetime = "0.2" diff --git a/crates/fbuild-cli/src/cli/port_scan.rs b/crates/fbuild-cli/src/cli/port_scan.rs index 7cb9bf20..46a18950 100644 --- a/crates/fbuild-cli/src/cli/port_scan.rs +++ b/crates/fbuild-cli/src/cli/port_scan.rs @@ -57,7 +57,10 @@ fn run_scan(offline: bool) -> Result<()> { // tier-1 + tier-3 if the overlay can't load. populate_online_overlay(); } - let ports = serialport::available_ports() + // Use fbuild-serial's blessed enumerator, not `serialport::available_ports()` + // directly: on Windows the latter drops every port whose PnP devnode reports + // a non-OK status (all PJRC/Teensy composite ports). FastLED/fbuild#962. + let ports = fbuild_serial::ports::available_ports() .map_err(|e| FbuildError::SerialError(format!("serial port enumeration failed: {e}")))?; let rendered = render_scan(&ports); // render_scan terminates every row with '\n'; strip the trailing newline @@ -139,6 +142,7 @@ pub fn render_scan(ports: &[serialport::SerialPortInfo]) -> String { info.pid, info.product.as_deref(), info.serial_number.as_deref(), + info.interface, ); } serialport::SerialPortType::PciPort => { @@ -175,11 +179,22 @@ fn render_usb_port( pid: u16, product: Option<&str>, serial: Option<&str>, + interface: Option, ) { let kernel_class = fbuild_serial::port_class::detect_port_kernel_class(name); - render_usb_port_with_kernel_class(out, name, vid, pid, product, serial, kernel_class); + render_usb_port_with_kernel_class( + out, + name, + vid, + pid, + product, + serial, + interface, + kernel_class, + ); } +#[allow(clippy::too_many_arguments)] fn render_usb_port_with_kernel_class( out: &mut String, name: &str, @@ -187,6 +202,7 @@ fn render_usb_port_with_kernel_class( pid: u16, product: Option<&str>, serial: Option<&str>, + interface: Option, kernel_class: Option, ) { use std::fmt::Write as _; @@ -195,9 +211,16 @@ fn render_usb_port_with_kernel_class( Some(s) if !s.is_empty() => format!(" ser={s}"), _ => String::new(), }; + // Surface the composite-interface index so multi-function devices (a + // Teensy exposing Serial + MIDI, say) make the data-bearing COM port + // obvious. FastLED/fbuild#962. + let interface_field = match interface { + Some(n) => format!(" if=MI_{n:02}"), + None => String::new(), + }; let _ = writeln!( out, - "{name:<10}{vid:04X}:{pid:04X} {descriptor}{serial_field}", + "{name:<10}{vid:04X}:{pid:04X} {descriptor}{serial_field}{interface_field}", ); let info = fbuild_core::usb::resolve(vid, pid); let friendly_product = friendly_product_name(vid, pid, &info.product, product); @@ -345,6 +368,7 @@ mod tests { serial_number: serial.map(String::from), manufacturer: None, product: product.map(String::from), + interface: None, }), } } @@ -561,6 +585,7 @@ mod tests { 0x1001, None, None, + None, Some(PortKernelClass::CdcAcm), ); assert!(cdc.contains("cdc=yes"), "got: {cdc}"); @@ -573,15 +598,56 @@ mod tests { 0xEA60, None, None, + None, Some(PortKernelClass::UsbSerialBridge), ); assert!(bridge.contains("cdc=no"), "got: {bridge}"); let mut unknown = String::new(); - render_usb_port_with_kernel_class(&mut unknown, "COM3", 0x303A, 0x1001, None, None, None); + render_usb_port_with_kernel_class( + &mut unknown, + "COM3", + 0x303A, + 0x1001, + None, + None, + None, + None, + ); assert!(unknown.contains("cdc=unknown"), "got: {unknown}"); } + #[test] + fn teensy_16c0_port_resolves_pjrc_from_embedded_archive() { + // The enumeration fix (fbuild-serial's Windows walk) is what makes a + // Teensy show up at all; here we pin that once it IS enumerated, the + // scan resolves VID 16C0 to PJRC. The vendor + product names come from + // the embedded FastLED/boards VID:PID archive, NOT a hardcoded table. + // FastLED/fbuild#962. + let ports = vec![usb_port( + "COM20", + 0x16C0, + 0x0483, + Some("USB Serial Device (COM20)"), + None, + )]; + let out = render_scan(&ports); + assert!(out.contains("16C0:0483"), "got: {out}"); + assert!( + out.to_lowercase().contains("pjrc") || out.to_lowercase().contains("teensy"), + "expected PJRC/Teensy from the embedded archive, got: {out}" + ); + } + + #[test] + fn composite_interface_index_is_surfaced() { + // MI_xx bonus: a Teensy Serial+MIDI composite exposes its interface + // index so the data-bearing COM port is obvious. + let mut out = String::new(); + render_usb_port(&mut out, "COM7", 0x16C0, 0x0489, None, None, Some(0)); + assert!(out.contains("if=MI_00"), "expected MI index, got: {out}"); + } + #[test] fn esp32_s3_cdc_pid_gets_friendly_supplement() { // In the tier-1/offline path the embedded archive carries vendor diff --git a/crates/fbuild-core/src/usb/resolver.rs b/crates/fbuild-core/src/usb/resolver.rs index 926c47f1..fa7670df 100644 --- a/crates/fbuild-core/src/usb/resolver.rs +++ b/crates/fbuild-core/src/usb/resolver.rs @@ -160,6 +160,29 @@ mod tests { ); } + #[test] + fn embedded_archive_resolves_pjrc_teensy_products() { + // The `fbuild port scan` Teensy rows get their product names from the + // embedded FastLED/boards VID:PID archive — NOT a hardcoded table in + // fbuild. Pin the round-trip for the PIDs a Teensy exposes as serial + // ports. FastLED/fbuild#962. + for (pid, expect) in [(0x0483u16, "serial"), (0x0489, "midi")] { + let info = try_resolve(0x16C0, pid).expect("Teensy PID in embedded archive"); + assert!( + info.vendor.to_lowercase().contains("pjrc") + || info.vendor.to_lowercase().contains("teensy"), + "16C0:{pid:04X} vendor should be PJRC/Teensy, got {:?}", + info.vendor + ); + assert!( + info.product.to_lowercase().contains("teensy") + && info.product.to_lowercase().contains(expect), + "16C0:{pid:04X} product should name the Teensy {expect} mode, got {:?}", + info.product + ); + } + } + #[test] fn embedded_resolves_ftdi_vendor() { let info = resolve_bundled(0x0403, 0x6001).expect("FTDI VID in embedded archive"); diff --git a/crates/fbuild-deploy/src/esp32_native/transport.rs b/crates/fbuild-deploy/src/esp32_native/transport.rs index f2a6f163..c64ab1fc 100644 --- a/crates/fbuild-deploy/src/esp32_native/transport.rs +++ b/crates/fbuild-deploy/src/esp32_native/transport.rs @@ -93,6 +93,7 @@ pub(super) fn discover_usb_port_info(port: &str) -> UsbPortInfo { serial_number: None, manufacturer: None, product: None, + interface: None, } } diff --git a/crates/fbuild-deploy/src/teensy/port_discovery.rs b/crates/fbuild-deploy/src/teensy/port_discovery.rs index 55095ebd..16fdfdfd 100644 --- a/crates/fbuild-deploy/src/teensy/port_discovery.rs +++ b/crates/fbuild-deploy/src/teensy/port_discovery.rs @@ -26,8 +26,13 @@ pub const PJRC_VID: u16 = 0x16C0; /// for the snapshot/diff use case, "we couldn't ask the OS" is functionally /// the same as "no ports", and we don't want a transient enumeration glitch /// to break the deploy. +/// +/// Uses fbuild-serial's blessed enumerator, which (unlike upstream +/// `serialport::available_ports()`) lists Windows ports whose PnP devnode +/// reports a non-OK status — every Teensy composite serial port. Without this +/// the pre/post-flash port diff never sees the Teensy. FastLED/fbuild#962. pub fn list_ports() -> Vec { - match serialport::available_ports() { + match fbuild_serial::ports::available_ports() { Ok(ports) => ports, Err(e) => { tracing::warn!("port enumeration failed: {}", e); diff --git a/crates/fbuild-serial/Cargo.toml b/crates/fbuild-serial/Cargo.toml index 8a71caa3..15a77c53 100644 --- a/crates/fbuild-serial/Cargo.toml +++ b/crates/fbuild-serial/Cargo.toml @@ -23,6 +23,16 @@ futures = { workspace = true } async-trait = { workspace = true } regex = { workspace = true } +# Windows serial-port enumeration that (unlike upstream serialport) does not +# drop devnodes with a non-OK PnP problem status — see `src/ports.rs`. +# FastLED/fbuild#962. Version matches serialport 4.9's windows-sys pin. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Foundation", + "Win32_System_Registry", +] } + [dev-dependencies] tempfile = { workspace = true } tracing-test = { workspace = true } diff --git a/crates/fbuild-serial/src/lib.rs b/crates/fbuild-serial/src/lib.rs index bb76b6eb..634ba03a 100644 --- a/crates/fbuild-serial/src/lib.rs +++ b/crates/fbuild-serial/src/lib.rs @@ -38,6 +38,7 @@ pub mod esp_reset; pub mod manager; pub mod messages; pub mod port_class; +pub mod ports; pub mod preemption; pub mod rpc_validate; pub mod session; diff --git a/crates/fbuild-serial/src/ports.rs b/crates/fbuild-serial/src/ports.rs new file mode 100644 index 00000000..6a2e104d --- /dev/null +++ b/crates/fbuild-serial/src/ports.rs @@ -0,0 +1,585 @@ +//! Blessed cross-platform serial-port enumeration for fbuild. +//! +//! On non-Windows platforms this delegates straight to +//! [`serialport::available_ports`]. On Windows it replaces the upstream +//! enumeration so that serial ports whose PnP devnode reports a **non-OK +//! problem status** (`CM_PROB_*`, phantom, composite `MI_00` interfaces) +//! are still listed. +//! +//! ## Why fbuild forks the Windows enumeration +//! +//! `serialport` 4.9's `available_ports()` skips any devnode where +//! `CM_Get_DevNode_Status` reports a problem code other than `0` +//! (`windows/enumerate.rs`: `if port_device.problem() != Some(0) { continue }`). +//! PJRC/Teensy (VID `16C0`) serial functions enumerate on Windows as +//! composite `MI_00` interfaces that commonly report `Status = Unknown`, so +//! upstream drops **every** Teensy COM port — a physically-attached Teensy is +//! invisible to `fbuild port scan` and to the deploy port-discovery snapshot. +//! FastLED/fbuild#962. +//! +//! This module is a fork of serialport's `windows/enumerate.rs` (MIT/Apache-2.0) +//! with the single behavioural change of **not filtering on the problem code**, +//! plus population of the composite-interface index (`MI_xx`) so callers can +//! disambiguate a Teensy's Serial vs Serial+MIDI functions. + +/// Enumerate every serial port currently visible to the OS. +/// +/// Unlike [`serialport::available_ports`], on Windows this includes ports +/// whose devnode status is not "OK" (the Teensy / composite-device case). +pub fn available_ports() -> serialport::Result> { + #[cfg(windows)] + { + imp::available_ports() + } + #[cfg(not(windows))] + { + serialport::available_ports() + } +} + +#[cfg(windows)] +mod imp { + use std::collections::HashSet; + use std::ptr; + + use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo}; + use windows_sys::core::GUID; + use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ + CM_Get_DevNode_Status, CM_Get_Device_IDW, CM_Get_Parent, SetupDiClassGuidsFromNameW, + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, + SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceRegistryPropertyW, SetupDiOpenDevRegKey, + CR_SUCCESS, DICS_FLAG_GLOBAL, DIREG_DEV, HDEVINFO, MAX_DEVICE_ID_LEN, SPDRP_FRIENDLYNAME, + SPDRP_HARDWAREID, SPDRP_MFG, SP_DEVINFO_DATA, + }; + use windows_sys::Win32::Foundation::{FALSE, FILETIME, INVALID_HANDLE_VALUE, MAX_PATH}; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegEnumValueW, RegOpenKeyExW, RegQueryInfoKeyW, RegQueryValueExW, HKEY, + HKEY_LOCAL_MACHINE, KEY_READ, REG_SZ, + }; + + const CONNECTOR_PUNCTUATION_SELECTION: &[char] = &[':', '_', '\u{ff3f}']; + + fn as_utf16(utf8: &str) -> Vec { + utf8.encode_utf16().chain(Some(0)).collect() + } + + fn from_utf16_lossy_trimmed(utf16: &[u16]) -> String { + String::from_utf16_lossy(utf16) + .trim_end_matches(0 as char) + .to_string() + } + + fn get_ports_guids() -> serialport::Result> { + let class_names = ["Ports", "Modem"]; + let mut guids: Vec = Vec::new(); + for class_name in class_names { + let class_name_w = as_utf16(class_name); + let mut num_guids: u32 = 1; + let class_start_idx = guids.len(); + + for _ in 0..2 { + guids.resize(class_start_idx + num_guids as usize, GUID::from_u128(0)); + let guid_buffer = &mut guids[class_start_idx..]; + let res = unsafe { + SetupDiClassGuidsFromNameW( + class_name_w.as_ptr(), + guid_buffer.as_mut_ptr(), + guid_buffer.len() as u32, + &mut num_guids, + ) + }; + if res == FALSE { + return Err(serialport::Error::new( + serialport::ErrorKind::Unknown, + "Unable to determine number of Ports GUIDs", + )); + } + let len_cmp = guid_buffer.len().cmp(&(num_guids as usize)); + if len_cmp == std::cmp::Ordering::Less { + continue; + } else if len_cmp == std::cmp::Ordering::Greater { + guids.truncate(class_start_idx + num_guids as usize); + } + break; + } + } + 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 { + let mut hwid_tail = hwid; + let vid_start = hwid.find("VID_")?; + let vid = hwid_tail.get(vid_start + 4..vid_start + 8)?; + hwid_tail = hwid_tail.get(vid_start + 8..)?; + + let pid = if hwid_tail.starts_with("&PID_") || hwid_tail.starts_with("+PID_") { + hwid_tail.get(5..9)? + } else { + return None; + }; + hwid_tail = hwid_tail.get(9..)?; + + let iid = if hwid_tail.starts_with("&MI_") || hwid_tail.starts_with("+MI_") { + let iid = hwid_tail.get(4..6); + hwid_tail = hwid_tail.get(6..).unwrap_or(hwid_tail); + iid + } else { + None + }; + + 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() + || CONNECTOR_PUNCTUATION_SELECTION.contains(&char)) + }) + .map(|(index, _)| index) + .unwrap_or(tail.len()); + tail.get(..index) + }) + } else { + None + }; + + Some(Self { + vid, + pid, + serial, + interface: iid, + }) + } + } + + /// Parse a Windows HWID string into [`UsbPortInfo`] (with the composite + /// `MI_xx` interface index preserved). Pure — unit-tested below. + /// + /// VID/PID always come from the device's own hardware id (a composite + /// interface's `MI_xx` hwid carries the same VID/PID as its parent). Only + /// the serial number is taken from the parent for composite devices — and + /// if the parent isn't available (a **phantom** devnode whose live parent + /// no longer exists, i.e. the Status=Unknown Teensy case) we fall back to + /// the child's own serial tail rather than giving up. This is the key + /// difference from upstream serialport, which returns `None` (→ no VID/PID) + /// for a composite devnode with no reachable parent. FastLED/fbuild#962. + fn parse_usb_port_info( + hardware_id: &str, + parent_hardware_id: Option<&str>, + ) -> Option { + let child = HwidMatches::new(hardware_id)?; + let interface = child.interface.and_then(|m| u8::from_str_radix(m, 16).ok()); + let serial = if interface.is_some() { + parent_hardware_id + .and_then(HwidMatches::new) + .and_then(|p| p.serial) + .or(child.serial) + } else { + child.serial + }; + + Some(UsbPortInfo { + vid: u16::from_str_radix(child.vid, 16).ok()?, + pid: u16::from_str_radix(child.pid, 16).ok()?, + serial_number: serial.map(str::to_string), + manufacturer: None, + product: None, + // The workspace enables serialport's `usbportinfo-interface` + // feature (Cargo.toml) precisely so this field exists; it carries + // the `MI_xx` index used to disambiguate Teensy Serial vs MIDI. + interface, + }) + } + + struct PortDevices { + hdi: HDEVINFO, + dev_idx: u32, + } + + impl PortDevices { + fn new(guid: &GUID) -> Self { + PortDevices { + // flags = 0 (NOT `DIGCF_PRESENT`) so non-present / phantom / + // Status=Unknown devnodes — every PJRC/Teensy composite serial + // port — are enumerated too. We re-derive real presence below + // via `CM_Get_DevNode_Status`. FastLED/fbuild#962. + hdi: unsafe { SetupDiGetClassDevsW(guid, ptr::null(), 0, 0) }, + dev_idx: 0, + } + } + } + + impl Iterator for PortDevices { + type Item = PortDevice; + + fn next(&mut self) -> Option { + let mut port_dev = PortDevice { + hdi: self.hdi, + devinfo_data: SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ClassGuid: GUID::from_u128(0), + 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) { + unsafe { + SetupDiDestroyDeviceInfoList(self.hdi); + } + } + } + + struct PortDevice { + hdi: HDEVINFO, + devinfo_data: SP_DEVINFO_DATA, + } + + impl PortDevice { + fn parent_instance_id(&mut self) -> Option { + let mut result_buf = [0u16; MAX_PATH as usize]; + 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 u32, + 0, + ) + }; + if res == CR_SUCCESS { + Some(from_utf16_lossy_trimmed(&result_buf)) + } else { + None + } + } else { + None + } + } + + fn instance_id(&mut self) -> Option { + let mut result_buf = [0u16; MAX_DEVICE_ID_LEN as usize]; + let working_buffer_len = result_buf.len() - 1; + let mut desired_result_len = 0; + let res = unsafe { + SetupDiGetDeviceInstanceIdW( + self.hdi, + &self.devinfo_data, + result_buf.as_mut_ptr(), + working_buffer_len as u32, + &mut desired_result_len, + ) + }; + if res == FALSE { + 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 port name (i.e. COM6) associated with this device. + fn name(&mut self) -> String { + let hkey = unsafe { + SetupDiOpenDevRegKey( + self.hdi, + &self.devinfo_data, + DICS_FLAG_GLOBAL, + 0, + DIREG_DEV, + KEY_READ, + ) + }; + if hkey == INVALID_HANDLE_VALUE { + return String::new(); + } + + let mut port_name_buffer = [0u16; MAX_PATH as usize]; + let buffer_byte_len = 2 * port_name_buffer.len() as u32; + 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 err != 0 { + return String::new(); + } + if value_type != REG_SZ || byte_len % 2 != 0 || byte_len > buffer_byte_len { + return String::new(); + } + let len = buffer_byte_len as usize / 2; + let port_name = &port_name_buffer[0..len]; + from_utf16_lossy_trimmed(port_name) + } + + /// True when the devnode is instantiated in the live device tree. + /// A phantom devnode (unplugged, or a Status=Unknown Teensy interface + /// whose function driver never started) returns `CR_NO_SUCH_DEVINST` + /// here, i.e. `false`. + fn present(&mut self) -> bool { + let mut status = 0u32; + let mut problem = 0u32; + let res = unsafe { + CM_Get_DevNode_Status(&mut status, &mut problem, self.devinfo_data.DevInst, 0) + }; + res == CR_SUCCESS + } + + fn port_type(&mut self) -> SerialPortType { + self.instance_id() + .map(|s| (s, self.parent_instance_id())) + .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) + } + + fn property(&mut self, property_id: u32) -> Option { + let mut value_type = 0; + let mut property_buf = [0u16; MAX_PATH as usize]; + let res = unsafe { + SetupDiGetDeviceRegistryPropertyW( + self.hdi, + &self.devinfo_data, + property_id, + &mut value_type, + property_buf.as_mut_ptr() as *mut u8, + property_buf.len() as u32, + ptr::null_mut(), + ) + }; + if res == FALSE || value_type != REG_SZ { + return None; + } + from_utf16_lossy_trimmed(&property_buf) + .split(';') + .next_back() + .map(str::to_string) + } + } + + /// COM ports listed under `HKLM\HARDWARE\DEVICEMAP\SERIALCOMM` that the + /// "Ports" class walk did not surface (parity with upstream serialport). + fn get_registry_com_ports() -> HashSet { + let mut ports_list = HashSet::new(); + let reg_key = as_utf16("HARDWARE\\DEVICEMAP\\SERIALCOMM"); + let mut ports_key: HKEY = 0; + let open_res = unsafe { + RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + reg_key.as_ptr(), + 0, + KEY_READ, + &mut ports_key, + ) + }; + if open_res != 0 { + return ports_list; + } + let mut class_name_buff = [0u16; MAX_PATH as usize]; + let mut class_name_size = MAX_PATH; + 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, + }; + let query_res = unsafe { + RegQueryInfoKeyW( + ports_key, + class_name_buff.as_mut_ptr(), + &mut class_name_size, + ptr::null(), + &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 query_res == 0 { + for idx in 0..num_key_values { + let mut val_name_buff = [0u16; MAX_PATH as usize]; + let mut val_name_size = MAX_PATH; + let mut value_type = 0; + let mut val_data = [0u16; MAX_PATH as usize]; + let buffer_byte_len = 2 * val_data.len() as u32; + let mut byte_len = buffer_byte_len; + let res = unsafe { + RegEnumValueW( + ports_key, + idx, + val_name_buff.as_mut_ptr(), + &mut val_name_size, + ptr::null(), + &mut value_type, + val_data.as_mut_ptr() as *mut u8, + &mut byte_len, + ) + }; + if res != 0 + || value_type != REG_SZ + || byte_len % 2 != 0 + || byte_len > buffer_byte_len + { + break; + } + let val_data = from_utf16_lossy_trimmed(unsafe { + let utf16_len = byte_len / 2; + std::slice::from_raw_parts(val_data.as_ptr(), utf16_len as usize) + }); + ports_list.insert(val_data); + } + } + unsafe { RegCloseKey(ports_key) }; + ports_list + } + + pub(super) fn available_ports() -> serialport::Result> { + let mut ports = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for guid in get_ports_guids()? { + let port_devices = PortDevices::new(&guid); + for mut port_device in port_devices { + let port_name = port_device.name(); + if port_name.is_empty() { + // No PortName in the devnode registry key → not an actual + // COM port (e.g. a modem enumerator entry). Skip. + continue; + } + if port_name.starts_with("LPT") { + continue; + } + let present = port_device.present(); + let port_type = port_device.port_type(); + let is_usb = matches!(port_type, SerialPortType::UsbPort(_)); + // Include every present port (unchanged behaviour), PLUS + // non-present USB serial ports — the Status=Unknown Teensy + // case the whole fix exists for. A non-present *non-USB* + // devnode is a stale phantom with no VID:PID to act on, so we + // leave it out to avoid resurrecting ancient ACPI/BT junk. + // FastLED/fbuild#962. + if !present && !is_usb { + continue; + } + // A phantom devnode can be enumerated once per matching class + // GUID; de-dup on the COM name. + if !seen.insert(port_name.clone()) { + continue; + } + ports.push(SerialPortInfo { + port_name, + port_type, + }); + } + } + + // Fold in any DEVICEMAP\SERIALCOMM ports not already found. + for raw_port in get_registry_com_ports() { + if seen.insert(raw_port.clone()) { + ports.push(SerialPortInfo { + port_name: raw_port, + port_type: SerialPortType::Unknown, + }); + } + } + Ok(ports) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parses_teensy_composite_serial_with_interface() { + // Teensy 4.x USB Serial enumerates as a composite MI_00 interface; + // the serial comes from the PARENT instance id, VID/PID from the child. + let child = r"USB\VID_16C0&PID_0483&MI_00\8&226AD2B7&0&0000"; + let parent = r"USB\VID_16C0&PID_0483\12345678"; + let info = parse_usb_port_info(child, Some(parent)).expect("parse"); + assert_eq!(info.vid, 0x16C0); + assert_eq!(info.pid, 0x0483); + assert_eq!(info.serial_number.as_deref(), Some("12345678")); + assert_eq!(info.interface, Some(0)); + } + + #[test] + fn parses_phantom_teensy_composite_without_parent() { + // The bug's core case: a Status=Unknown Teensy port is a phantom + // devnode whose live parent no longer exists, so no parent hwid is + // available. We must STILL recover VID/PID (16C0:0483) from the + // child's own MI_00 hardware id — upstream serialport returns None + // here, which is why the Teensy was invisible. FastLED/fbuild#962. + let child = r"USB\VID_16C0&PID_0483&MI_00\8&226AD2B7&0&0000"; + let info = parse_usb_port_info(child, None).expect("parse without parent"); + assert_eq!(info.vid, 0x16C0); + assert_eq!(info.pid, 0x0483); + assert_eq!(info.interface, Some(0)); + } + + #[test] + fn parses_teensy_serial_midi_audio_pid() { + let child = r"USB\VID_16C0&PID_0489&MI_00\9&32144BF9&0&0000"; + let parent = r"USB\VID_16C0&PID_0489\ABCDEF"; + let info = parse_usb_port_info(child, Some(parent)).expect("parse"); + assert_eq!(info.vid, 0x16C0); + assert_eq!(info.pid, 0x0489); + assert_eq!(info.interface, Some(0)); + } + + #[test] + fn non_composite_device_has_no_interface() { + let info = parse_usb_port_info(r"USB\VID_303A&PID_1001\B4:3A:45:B0:08:24", None) + .expect("parse"); + assert_eq!(info.vid, 0x303A); + assert_eq!(info.interface, None); + assert_eq!(info.serial_number.as_deref(), Some("B4:3A:45:B0:08:24")); + } + } +}