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
118 changes: 90 additions & 28 deletions crates/fbuild-cli/src/cli/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,59 @@ fn operation_streams_include_message(resp: &OperationResponse) -> bool {
.any(|stream| stream.trim() == message)
}

fn select_monitor_port(
requested: Option<String>,
ports: &[fbuild_serial::ports::DetectedPort],
) -> fbuild_core::Result<String> {
match requested {
Some(port) => {
if let Some(detected) = ports
.iter()
.find(|detected| detected.info.port_name == port)
.filter(|detected| detected.health.is_known_unhealthy())
{
return Err(fbuild_core::FbuildError::SerialError(format!(
"refusing to monitor {port}: endpoint health is {}; run `fbuild port scan` for recovery details",
detected.health.label()
)));
}
Ok(port)
}
None => {
let selectable = ports
.iter()
.filter(|port| !port.health.is_known_unhealthy())
.collect::<Vec<_>>();
match selectable.as_slice() {
[port] => Ok(port.info.port_name.clone()),
[] if ports.is_empty() => Err(fbuild_core::FbuildError::SerialError(
"no serial ports detected; specify one with --port".to_string(),
)),
[] => {
let unhealthy = ports
.iter()
.map(|port| format!("{} ({})", port.info.port_name, port.health.label()))
.collect::<Vec<_>>()
.join(", ");
Err(fbuild_core::FbuildError::SerialError(format!(
"no selectable serial ports detected ({unhealthy}); run `fbuild port scan` for recovery details"
)))
}
selectable => {
let names = selectable
.iter()
.map(|port| port.info.port_name.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(fbuild_core::FbuildError::SerialError(format!(
"multiple serial ports detected ({names}); specify one with --port"
)))
}
}
}
}
}

#[allow(clippy::too_many_arguments)]
pub async fn run_monitor(
project_dir: String,
Expand All @@ -354,34 +407,10 @@ pub async fn run_monitor(
daemon_client::warn_if_daemon_identity_mismatch(&client, &project_dir).await;

let _ = (project_dir, environment);
let port = match port {
Some(port) => port,
None => {
let ports = fbuild_serial::ports::available_ports().map_err(|e| {
fbuild_core::FbuildError::SerialError(format!(
"failed to enumerate serial ports: {e}"
))
})?;
match ports.as_slice() {
[port] => port.port_name.clone(),
[] => {
return Err(fbuild_core::FbuildError::SerialError(
"no serial ports detected; specify one with --port".to_string(),
));
}
ports => {
let names = ports
.iter()
.map(|p| p.port_name.as_str())
.collect::<Vec<_>>()
.join(", ");
return Err(fbuild_core::FbuildError::SerialError(format!(
"multiple serial ports detected ({names}); specify one with --port"
)));
}
}
}
};
let ports = fbuild_serial::ports::available_ports().map_err(|e| {
fbuild_core::FbuildError::SerialError(format!("failed to enumerate serial ports: {e}"))
})?;
let port = select_monitor_port(port, &ports)?;
let baud_rate = baud_rate.unwrap_or(115200);
let client_id = format!("fbuild-monitor-{}", std::process::id());
let (mut socket, _) = connect_async(client.websocket_url("/ws/serial-monitor"))
Expand Down Expand Up @@ -493,6 +522,21 @@ pub async fn run_monitor(
mod tests {
use super::*;

fn detected_port(
name: &str,
health: fbuild_serial::ports::PortHealth,
) -> fbuild_serial::ports::DetectedPort {
fbuild_serial::ports::DetectedPort {
info: serialport::SerialPortInfo {
port_name: name.to_string(),
port_type: serialport::SerialPortType::Unknown,
},
health,
instance_id: None,
parent_instance_id: None,
}
}

fn response(message: &str, stdout: Option<&str>, stderr: Option<&str>) -> OperationResponse {
OperationResponse {
success: false,
Expand Down Expand Up @@ -522,4 +566,22 @@ mod tests {
let resp = response("deploy failed", None, Some("transport detail"));
assert!(!operation_streams_include_message(&resp));
}

#[test]
fn monitor_selection_never_chooses_a_known_unhealthy_endpoint() {
let ports = vec![detected_port(
"COM12",
fbuild_serial::ports::PortHealth::Phantom {
problem_code: Some(45),
status: Some(0),
},
)];

let auto_error = select_monitor_port(None, &ports).unwrap_err().to_string();
assert!(auto_error.contains("no selectable serial ports"));
let explicit_error = select_monitor_port(Some("COM12".to_string()), &ports)
.unwrap_err()
.to_string();
assert!(explicit_error.contains("endpoint health is phantom"));
}
}
71 changes: 50 additions & 21 deletions crates/fbuild-cli/src/cli/port_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ fn overlay_json_cache_path_in(root: &std::path::Path) -> Option<std::path::PathB
/// Each port produces two rows + a blank line. The trailing summary
/// line is `N USB ports, M non-USB` for non-empty input, `no serial
/// ports visible\n` for empty.
pub fn render_scan(ports: &[serialport::SerialPortInfo]) -> String {
pub fn render_scan(ports: &[fbuild_serial::ports::DetectedPort]) -> String {
if ports.is_empty() {
return "no serial ports visible\n".to_string();
}
Expand All @@ -168,7 +168,8 @@ pub fn render_scan(ports: &[serialport::SerialPortInfo]) -> String {
let mut usb_count = 0usize;
let mut other_count = 0usize;

for port in ports {
for detected in ports {
let port = &detected.info;
match &port.port_type {
serialport::SerialPortType::UsbPort(info) => {
usb_count += 1;
Expand All @@ -195,6 +196,7 @@ pub fn render_scan(ports: &[serialport::SerialPortInfo]) -> String {
render_non_usb(&mut out, &port.port_name, "Unknown");
}
}
append_health_annotation(&mut out, detected);
out.push('\n');
}

Expand All @@ -209,6 +211,22 @@ pub fn render_scan(ports: &[serialport::SerialPortInfo]) -> String {
out
}

fn append_health_annotation(out: &mut String, port: &fbuild_serial::ports::DetectedPort) {
use std::fmt::Write as _;
let _ = out.pop();
let _ = write!(out, " health={}", port.health.label());
if port.health.is_known_unhealthy() {
out.push_str(" selectable=no");
}
if let Some(problem_code) = port.health.problem_code() {
let _ = write!(out, " problem={problem_code}");
}
if let Some(instance_id) = port.instance_id.as_deref() {
let _ = write!(out, " instance={instance_id}");
}
out.push('\n');
}

fn render_usb_port(
out: &mut String,
name: &str,
Expand Down Expand Up @@ -362,8 +380,8 @@ mod tests {
pid: u16,
product: Option<&str>,
serial: Option<&str>,
) -> SerialPortInfo {
SerialPortInfo {
) -> fbuild_serial::ports::DetectedPort {
fbuild_serial::ports::DetectedPort::unknown(SerialPortInfo {
port_name: name.to_string(),
port_type: SerialPortType::UsbPort(UsbPortInfo {
vid,
Expand All @@ -373,7 +391,14 @@ mod tests {
product: product.map(String::from),
interface: None,
}),
}
})
}

fn non_usb_port(name: &str, port_type: SerialPortType) -> fbuild_serial::ports::DetectedPort {
fbuild_serial::ports::DetectedPort::unknown(SerialPortInfo {
port_name: name.to_string(),
port_type,
})
}

#[test]
Expand Down Expand Up @@ -543,18 +568,9 @@ mod tests {
#[test]
fn non_usb_ports_get_kind_label_and_uniform_two_rows() {
let ports = vec![
SerialPortInfo {
port_name: "BT0".to_string(),
port_type: SerialPortType::BluetoothPort,
},
SerialPortInfo {
port_name: "PCI3".to_string(),
port_type: SerialPortType::PciPort,
},
SerialPortInfo {
port_name: "X1".to_string(),
port_type: SerialPortType::Unknown,
},
non_usb_port("BT0", SerialPortType::BluetoothPort),
non_usb_port("PCI3", SerialPortType::PciPort),
non_usb_port("X1", SerialPortType::Unknown),
];
let out = render_scan(&ports);
assert!(out.contains("[Bluetooth]"));
Expand Down Expand Up @@ -620,6 +636,22 @@ mod tests {
assert!(unknown.contains("cdc=unknown"), "got: {unknown}");
}

#[test]
fn scan_marks_phantom_ports_as_not_selectable_diagnostics() {
let mut port = usb_port("COM12", 0x2E8A, 0x000A, None, Some("PICO-1"));
port.health = fbuild_serial::ports::PortHealth::Phantom {
problem_code: Some(45),
status: Some(0),
};
port.instance_id = Some(r"USB\VID_2E8A&PID_000A\PICO-1".to_string());

let out = render_scan(&[port]);
assert!(out.contains("health=phantom"), "got: {out}");
assert!(out.contains("selectable=no"), "got: {out}");
assert!(out.contains("problem=45"), "got: {out}");
assert!(out.contains("instance=USB\\VID_2E8A&PID_000A\\PICO-1"));
}

#[test]
fn usb_problem_warning_recommends_root_port_for_hub_node() {
let devices = vec![fbuild_serial::ports::UsbProblemDevice {
Expand Down Expand Up @@ -765,10 +797,7 @@ mod tests {
fn mixed_port_list_summary_counts_correctly() {
let ports = vec![
usb_port("COM1", 0x303A, 0x1001, None, None),
SerialPortInfo {
port_name: "BT0".to_string(),
port_type: SerialPortType::BluetoothPort,
},
non_usb_port("BT0", SerialPortType::BluetoothPort),
usb_port("COM2", 0x16C0, 0x0483, None, None),
];
let out = render_scan(&ports);
Expand Down
29 changes: 17 additions & 12 deletions crates/fbuild-cli/src/cli/serial_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ fn run_probe(action: ProbeAction) -> Result<()> {

/// `fbuild serial probe list` — enumerate every port with annotation.
fn list_ports() -> Result<()> {
let ports = serialport::available_ports()
let ports = fbuild_serial::ports::available_ports()
.map_err(|e| FbuildError::SerialError(format!("serial port enumeration failed: {e}")))?;

if ports.is_empty() {
Expand All @@ -126,9 +126,10 @@ fn list_ports() -> Result<()> {
Ok(())
}

fn print_port_summary(port: &serialport::SerialPortInfo) {
let name = &port.port_name;
match &port.port_type {
fn print_port_summary(port: &fbuild_serial::ports::DetectedPort) {
let name = &port.info.port_name;
let health = port.health.label();
match &port.info.port_type {
serialport::SerialPortType::UsbPort(info) => {
let hint = board_hint(info.vid, info.pid)
.map(|h| format!("[{h}]"))
Expand All @@ -141,16 +142,20 @@ fn print_port_summary(port: &serialport::SerialPortInfo) {
format!("ser={serial} ")
};
output::result(format!(
"{name:<10} {vid:04X}:{pid:04X} {serial_field}{product} {hint}",
"{name:<10} {vid:04X}:{pid:04X} {serial_field}{product} {hint} health={health}",
vid = info.vid,
pid = info.pid,
));
}
serialport::SerialPortType::PciPort => output::result(format!("{name:<10} [PCI]")),
serialport::SerialPortType::PciPort => {
output::result(format!("{name:<10} [PCI] health={health}"))
}
serialport::SerialPortType::BluetoothPort => {
output::result(format!("{name:<10} [Bluetooth]"))
output::result(format!("{name:<10} [Bluetooth] health={health}"))
}
serialport::SerialPortType::Unknown => {
output::result(format!("{name:<10} [Unknown] health={health}"))
}
serialport::SerialPortType::Unknown => output::result(format!("{name:<10} [Unknown]")),
}
}

Expand Down Expand Up @@ -178,13 +183,13 @@ fn find_port(vid_pid: Option<&str>, env: Option<&str>) -> Result<()> {
}
};

let ports = serialport::available_ports()
let ports = fbuild_serial::ports::available_ports()
.map_err(|e| FbuildError::SerialError(format!("serial port enumeration failed: {e}")))?;

for port in ports {
if let serialport::SerialPortType::UsbPort(info) = &port.port_type {
if (info.vid, info.pid) == target {
output::result(&port.port_name);
if let serialport::SerialPortType::UsbPort(info) = &port.info.port_type {
if (info.vid, info.pid) == target && !port.health.is_known_unhealthy() {
output::result(&port.info.port_name);
return Ok(());
}
}
Expand Down
Loading
Loading