diff --git a/crates/fbuild-cli/src/cli/port_scan.rs b/crates/fbuild-cli/src/cli/port_scan.rs index 543acf73..8d3e0d07 100644 --- a/crates/fbuild-cli/src/cli/port_scan.rs +++ b/crates/fbuild-cli/src/cli/port_scan.rs @@ -53,9 +53,53 @@ fn run_scan(offline: bool) -> Result<()> { // render_scan terminates every row with '\n'; strip the trailing newline // so result()'s newline doesn't double up. output::result(rendered.trim_end_matches('\n')); + let problem_devices = fbuild_serial::ports::present_usb_problem_devices(); + if !problem_devices.is_empty() { + // This is actionable final command output. Keep it visible under the + // default tracing filter instead of requiring users to set RUST_LOG. + output::diagnostic(format!("warning: {}", format_usb_problem_warning(&problem_devices))); + } Ok(()) } +fn format_usb_problem_warning(devices: &[fbuild_serial::ports::UsbProblemDevice]) -> String { + use std::fmt::Write as _; + let mut warning = String::from( + "Windows reports present USB device(s) with hardware problems; fbuild cannot associate these unidentified nodes with a target board:", + ); + let mut hub_seen = false; + for device in devices { + let location = device + .location + .as_deref() + .map(|value| format!(" at {value}")) + .unwrap_or_default(); + let topology = match device.behind_external_hub { + Some(true) => { + hub_seen = true; + " behind an external USB hub" + } + Some(false) => " on a direct root USB port", + None => " (USB topology unavailable)", + }; + let name = device + .friendly_name + .as_deref() + .unwrap_or("Unknown USB device"); + let _ = write!( + warning, + "\n - {name}: Windows problem code {}{location}{topology}", + device.problem_code + ); + } + if hub_seen { + warning.push_str( + "\nRecommendation: connect platform USB devices directly to a motherboard USB port; external hubs can introduce power/reset/timing conditions that disrupt USB enumeration.", + ); + } + warning +} + /// Fetch the FastLED/boards display-name artifact backing /// [`fbuild_core::usb::resolve`] into the local cache root, then install it. /// @@ -573,6 +617,40 @@ mod tests { assert!(unknown.contains("cdc=unknown"), "got: {unknown}"); } + #[test] + fn usb_problem_warning_recommends_root_port_for_hub_node() { + let devices = vec![fbuild_serial::ports::UsbProblemDevice { + instance_id: r"USB\VID_0000&PID_0002\failure".to_string(), + problem_code: 43, + friendly_name: Some( + "Unknown USB Device (Device Descriptor Request Failed)".to_string(), + ), + location: Some("Port_#0001.Hub_#0011".to_string()), + behind_external_hub: Some(true), + }]; + let warning = format_usb_problem_warning(&devices); + assert!(warning.contains("problem code 43")); + assert!(warning.contains("behind an external USB hub")); + assert!(warning.contains("Port_#0001.Hub_#0011")); + assert!(warning.contains("connect platform USB devices directly")); + assert!(warning.contains("cannot associate")); + } + + #[test] + fn usb_problem_warning_does_not_overclaim_direct_root_node() { + let devices = vec![fbuild_serial::ports::UsbProblemDevice { + instance_id: r"USB\VID_0000&PID_0002\failure".to_string(), + problem_code: 43, + friendly_name: None, + location: Some("Port_#0014.Hub_#0001".to_string()), + behind_external_hub: Some(false), + }]; + let warning = format_usb_problem_warning(&devices); + assert!(warning.contains("Unknown USB device")); + assert!(warning.contains("on a direct root USB port")); + assert!(!warning.contains("connect platform USB devices directly")); + } + #[test] fn teensy_16c0_port_resolves_pjrc_from_runtime_fixture() { let _usb = install_name_fixture(); diff --git a/crates/fbuild-serial/src/ports.rs b/crates/fbuild-serial/src/ports.rs index e41559a1..40315119 100644 --- a/crates/fbuild-serial/src/ports.rs +++ b/crates/fbuild-serial/src/ports.rs @@ -37,18 +37,52 @@ pub fn available_ports() -> serialport::Result> } } +/// A USB device that Windows has instantiated but could not start normally. +/// +/// These nodes may not have a usable VID/PID or serial number (for example, +/// Windows reports a descriptor failure as `VID_0000&PID_0002`). The result +/// is deliberately diagnostic only: callers must not treat one of these +/// nodes as a particular target board. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UsbProblemDevice { + pub instance_id: String, + pub problem_code: u32, + pub friendly_name: Option, + pub location: Option, + /// `Some(true)` means a USB device ancestor exists before the root hub; + /// `Some(false)` means the node reaches a root hub directly; `None` means + /// the host could not provide enough ancestry to classify it. + pub behind_external_hub: Option, +} + +/// Best-effort enumeration of present USB devnodes with a non-zero Windows +/// problem code. This is empty on non-Windows hosts and never makes a port +/// scan fail merely because host diagnostics are unavailable. +pub fn present_usb_problem_devices() -> Vec { + #[cfg(windows)] + { + imp::present_usb_problem_devices() + } + #[cfg(not(windows))] + { + Vec::new() + } +} + #[cfg(windows)] mod imp { + use super::UsbProblemDevice; use std::collections::HashSet; use std::ptr; use serialport::{SerialPortInfo, SerialPortType, UsbPortInfo}; use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ CM_Get_DevNode_Status, CM_Get_Device_IDW, CM_Get_Parent, CR_SUCCESS, DICS_FLAG_GLOBAL, - DIREG_DEV, HDEVINFO, MAX_DEVICE_ID_LEN, SP_DEVINFO_DATA, SPDRP_FRIENDLYNAME, - SPDRP_HARDWAREID, SPDRP_MFG, SetupDiClassGuidsFromNameW, SetupDiDestroyDeviceInfoList, - SetupDiEnumDeviceInfo, SetupDiGetClassDevsW, SetupDiGetDeviceInstanceIdW, - SetupDiGetDeviceRegistryPropertyW, SetupDiOpenDevRegKey, + DIGCF_PRESENT, DIREG_DEV, GUID_DEVCLASS_USB, HDEVINFO, MAX_DEVICE_ID_LEN, + SP_DEVINFO_DATA, SPDRP_FRIENDLYNAME, SPDRP_HARDWAREID, SPDRP_LOCATION_INFORMATION, + SPDRP_MFG, SetupDiClassGuidsFromNameW, SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInfo, + SetupDiGetClassDevsW, SetupDiGetDeviceInstanceIdW, SetupDiGetDeviceRegistryPropertyW, + SetupDiOpenDevRegKey, }; use windows_sys::Win32::Foundation::{FALSE, FILETIME, INVALID_HANDLE_VALUE, MAX_PATH}; use windows_sys::Win32::System::Registry::{ @@ -396,6 +430,141 @@ mod imp { } } + fn ancestor_ids(devinst: u32) -> Vec { + let mut ids = Vec::new(); + let mut current = devinst; + for _ in 0..16 { + let mut parent = 0; + let result = unsafe { CM_Get_Parent(&mut parent, current, 0) }; + if result != CR_SUCCESS { + break; + } + let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; + let result = unsafe { + CM_Get_Device_IDW(parent, buffer.as_mut_ptr(), buffer.len() as u32, 0) + }; + if result != CR_SUCCESS { + break; + } + let length = buffer.iter().position(|&unit| unit == 0).unwrap_or(buffer.len()); + ids.push(String::from_utf16_lossy(&buffer[..length])); + current = parent; + } + ids + } + + fn classify_usb_ancestry(devinst: u32) -> Option { + let ancestors = ancestor_ids(devinst); + let root_index = ancestors.iter().position(|id| { + id.to_ascii_uppercase().starts_with("USB\\ROOT_HUB") + })?; + Some(ancestors[..root_index].iter().any(|id| { + let upper = id.to_ascii_uppercase(); + upper.starts_with("USB\\VID_") && upper.contains("&PID_") + })) + } + + pub(super) fn present_usb_problem_devices() -> Vec { + let hdi = unsafe { + SetupDiGetClassDevsW( + &GUID_DEVCLASS_USB, + std::ptr::null(), + 0, + DIGCF_PRESENT, + ) + }; + if hdi == INVALID_HANDLE_VALUE { + return Vec::new(); + } + + let mut devices = Vec::new(); + let mut index = 0u32; + loop { + let mut info = SP_DEVINFO_DATA { + cbSize: std::mem::size_of::() as u32, + ClassGuid: GUID::from_u128(0), + DevInst: 0, + Reserved: 0, + }; + if unsafe { SetupDiEnumDeviceInfo(hdi, index, &mut info) } == FALSE { + break; + } + index += 1; + + let Some(instance_id) = device_instance_id_from_info(hdi, &info) else { + continue; + }; + if !instance_id.to_ascii_uppercase().starts_with("USB\\") { + continue; + } + let mut status = 0u32; + let mut problem_code = 0u32; + if unsafe { CM_Get_DevNode_Status(&mut status, &mut problem_code, info.DevInst, 0) } + != CR_SUCCESS + || problem_code == 0 + { + continue; + } + + devices.push(UsbProblemDevice { + instance_id, + problem_code, + friendly_name: property_from_info(hdi, &info, SPDRP_FRIENDLYNAME), + location: property_from_info(hdi, &info, SPDRP_LOCATION_INFORMATION), + behind_external_hub: classify_usb_ancestry(info.DevInst), + }); + } + unsafe { + SetupDiDestroyDeviceInfoList(hdi); + } + devices + } + + fn device_instance_id_from_info(hdi: HDEVINFO, info: &SP_DEVINFO_DATA) -> Option { + let mut buffer = [0u16; MAX_DEVICE_ID_LEN as usize]; + let mut required = 0u32; + let ok = unsafe { + SetupDiGetDeviceInstanceIdW( + hdi, + info, + buffer.as_mut_ptr(), + buffer.len() as u32, + &mut required, + ) + }; + if ok == FALSE { + return None; + } + let length = buffer.iter().position(|&unit| unit == 0).unwrap_or(buffer.len()); + Some(String::from_utf16_lossy(&buffer[..length])) + } + + fn property_from_info( + hdi: HDEVINFO, + info: &SP_DEVINFO_DATA, + property_id: u32, + ) -> Option { + let mut value_type = 0u32; + let mut buffer = [0u16; MAX_PATH as usize]; + let ok = unsafe { + SetupDiGetDeviceRegistryPropertyW( + hdi, + info, + property_id, + &mut value_type, + buffer.as_mut_ptr() as *mut u8, + (buffer.len() * 2) as u32, + std::ptr::null_mut(), + ) + }; + if ok == FALSE || value_type != REG_SZ { + return None; + } + let length = buffer.iter().position(|&unit| unit == 0).unwrap_or(buffer.len()); + let value = String::from_utf16_lossy(&buffer[..length]); + (!value.is_empty()).then_some(value) + } + /// 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 { @@ -581,5 +750,6 @@ mod imp { assert_eq!(info.interface, None); assert_eq!(info.serial_number.as_deref(), Some("B4:3A:45:B0:08:24")); } + } }