Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
74 changes: 70 additions & 4 deletions crates/fbuild-cli/src/cli/port_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -175,18 +179,30 @@ fn render_usb_port(
pid: u16,
product: Option<&str>,
serial: Option<&str>,
interface: Option<u8>,
) {
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,
vid: u16,
pid: u16,
product: Option<&str>,
serial: Option<&str>,
interface: Option<u8>,
kernel_class: Option<fbuild_serial::port_class::PortKernelClass>,
) {
use std::fmt::Write as _;
Expand All @@ -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);
Expand Down Expand Up @@ -345,6 +368,7 @@ mod tests {
serial_number: serial.map(String::from),
manufacturer: None,
product: product.map(String::from),
interface: None,
}),
}
}
Expand Down Expand Up @@ -561,6 +585,7 @@ mod tests {
0x1001,
None,
None,
None,
Some(PortKernelClass::CdcAcm),
);
assert!(cdc.contains("cdc=yes"), "got: {cdc}");
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions crates/fbuild-core/src/usb/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions crates/fbuild-deploy/src/esp32_native/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub(super) fn discover_usb_port_info(port: &str) -> UsbPortInfo {
serial_number: None,
manufacturer: None,
product: None,
interface: None,
}
}

Expand Down
7 changes: 6 additions & 1 deletion crates/fbuild-deploy/src/teensy/port_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerialPortInfo> {
match serialport::available_ports() {
match fbuild_serial::ports::available_ports() {
Ok(ports) => ports,
Err(e) => {
tracing::warn!("port enumeration failed: {}", e);
Expand Down
10 changes: 10 additions & 0 deletions crates/fbuild-serial/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
1 change: 1 addition & 0 deletions crates/fbuild-serial/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading