Refactor CLI, add refresh command, fix port detection, add device tracking
- Remove build/upload/monitor subcommands (projects are self-contained) - Remove ctrlc dependency (only used by removed monitor watch mode) - Update next-steps messaging to reference project scripts directly - Add 'anvil refresh [DIR] [--force]' to update project scripts to latest templates without touching user code - Fix Windows port detection: replace fragile findstr/batch TOML parsing with proper comment-skipping logic; add _detect_port.ps1 helper for reliable JSON-based port detection via PowerShell - Add .anvil.local for machine-specific config (gitignored) - 'anvil devices --set [PORT] [-d DIR]' saves port + VID:PID - 'anvil devices --get [-d DIR]' shows saved port status - VID:PID tracks USB devices across COM port reassignment - Port resolution: -p flag > VID:PID > saved port > auto-detect - Uppercase normalization for Windows COM port names - Update all .bat/.sh templates to read from .anvil.local - Remove port entries from .anvil.toml (no machine-specific config in git) - Add .anvil.local to .gitignore template - Expand 'anvil devices' output with VID:PID, serial number, and usage instructions
This commit is contained in:
408
src/board/mod.rs
408
src/board/mod.rs
@@ -11,6 +11,40 @@ pub struct PortInfo {
|
||||
pub protocol: String,
|
||||
pub board_name: String,
|
||||
pub fqbn: String,
|
||||
pub vid: String,
|
||||
pub pid: String,
|
||||
pub serial_number: String,
|
||||
}
|
||||
|
||||
impl PortInfo {
|
||||
/// Returns true if this looks like a USB serial port rather than a
|
||||
/// legacy motherboard COM port.
|
||||
pub fn is_usb(&self) -> bool {
|
||||
// Has USB identifiers
|
||||
if !self.vid.is_empty() && !self.pid.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// arduino-cli labels USB ports "Serial Port (USB)"
|
||||
if self.protocol.contains("USB") {
|
||||
return true;
|
||||
}
|
||||
// Unix: ttyUSB* and ttyACM* are always USB
|
||||
if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns VID:PID string like "2341:0043", or empty if unknown.
|
||||
pub fn vid_pid(&self) -> String {
|
||||
if self.vid.is_empty() || self.pid.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// Normalize: strip 0x prefix, lowercase, 4-digit padded
|
||||
let vid = self.vid.trim_start_matches("0x").trim_start_matches("0X");
|
||||
let pid = self.pid.trim_start_matches("0x").trim_start_matches("0X");
|
||||
format!("{}:{}", vid.to_lowercase(), pid.to_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON schema for `arduino-cli board list --format json`
|
||||
@@ -90,11 +124,34 @@ fn list_ports_via_cli(cli: &Path) -> Result<Vec<PortInfo>> {
|
||||
_ => ("Unknown".to_string(), String::new()),
|
||||
};
|
||||
|
||||
// Extract VID, PID, serial number from properties
|
||||
let (vid, pid, serial_number) = match &port.properties {
|
||||
Some(props) => {
|
||||
let v = props.get("vid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let p = props.get("pid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let sn = props.get("serialNumber")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(v, p, sn)
|
||||
}
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
|
||||
result.push(PortInfo {
|
||||
port_name: port.address,
|
||||
protocol: port.protocol_label,
|
||||
board_name,
|
||||
fqbn,
|
||||
vid,
|
||||
pid,
|
||||
serial_number,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,6 +180,9 @@ fn list_ports_fallback() -> Vec<PortInfo> {
|
||||
protocol: "serial".to_string(),
|
||||
board_name: board.to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(),
|
||||
pid: String::new(),
|
||||
serial_number: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -149,6 +209,9 @@ fn list_ports_fallback() -> Vec<PortInfo> {
|
||||
protocol: "serial".to_string(),
|
||||
board_name: "Detected via WMI".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(),
|
||||
pid: String::new(),
|
||||
serial_number: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -160,6 +223,50 @@ fn list_ports_fallback() -> Vec<PortInfo> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Find a port by VID:PID. Returns the port name if found.
|
||||
pub fn resolve_vid_pid(vid_pid: &str) -> Option<String> {
|
||||
let ports = list_ports();
|
||||
let needle = vid_pid.to_lowercase();
|
||||
|
||||
for p in &ports {
|
||||
if p.vid_pid() == needle {
|
||||
return Some(p.port_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Pick the best default port from a list. Returns the index into the
|
||||
/// slice, or None if the list is empty.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. A port with a recognized board (non-empty FQBN)
|
||||
/// 2. A USB serial port (skip legacy motherboard COM ports)
|
||||
/// 3. First port in the list
|
||||
pub fn pick_default_port(ports: &[PortInfo]) -> Option<usize> {
|
||||
if ports.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Prefer a port with a recognized FQBN
|
||||
for (i, p) in ports.iter().enumerate() {
|
||||
if !p.fqbn.is_empty() {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer a USB port over a legacy serial port
|
||||
for (i, p) in ports.iter().enumerate() {
|
||||
if p.is_usb() {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the first port
|
||||
Some(0)
|
||||
}
|
||||
|
||||
/// Auto-detect a single serial port.
|
||||
pub fn auto_detect_port() -> Result<String> {
|
||||
let ports = list_ports();
|
||||
@@ -171,35 +278,23 @@ pub fn auto_detect_port() -> Result<String> {
|
||||
);
|
||||
}
|
||||
|
||||
if ports.len() == 1 {
|
||||
return Ok(ports[0].port_name.clone());
|
||||
}
|
||||
let idx = pick_default_port(&ports).unwrap_or(0);
|
||||
|
||||
eprintln!("{}", "Multiple serial ports detected:".yellow());
|
||||
for p in &ports {
|
||||
eprintln!(" {} ({})", p.port_name, p.board_name);
|
||||
}
|
||||
|
||||
// Prefer a port with a recognized board
|
||||
for p in &ports {
|
||||
if !p.fqbn.is_empty() {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"Auto-selected {} ({}). Use -p to override.",
|
||||
p.port_name, p.board_name
|
||||
).yellow()
|
||||
);
|
||||
return Ok(p.port_name.clone());
|
||||
if ports.len() > 1 {
|
||||
eprintln!("{}", "Multiple serial ports detected:".yellow());
|
||||
for p in &ports {
|
||||
eprintln!(" {} ({})", p.port_name, p.board_name);
|
||||
}
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"Auto-selected {}. Use -p to override.",
|
||||
ports[idx].port_name
|
||||
).yellow()
|
||||
);
|
||||
}
|
||||
|
||||
let selected = ports[0].port_name.clone();
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!("Auto-selected {}. Use -p to override.", selected).yellow()
|
||||
);
|
||||
Ok(selected)
|
||||
Ok(ports[idx].port_name.clone())
|
||||
}
|
||||
|
||||
/// Print detailed port information.
|
||||
@@ -219,8 +314,18 @@ pub fn print_port_details(ports: &[PortInfo]) {
|
||||
return;
|
||||
}
|
||||
|
||||
for port in ports {
|
||||
println!(" {}", port.port_name.green().bold());
|
||||
let default_idx = pick_default_port(ports);
|
||||
|
||||
for (i, port) in ports.iter().enumerate() {
|
||||
let is_default = default_idx == Some(i);
|
||||
|
||||
if is_default {
|
||||
print!(" {}", port.port_name.green().bold());
|
||||
println!(" {}", "<-- default".bright_green());
|
||||
} else {
|
||||
println!(" {}", port.port_name.green().bold());
|
||||
}
|
||||
|
||||
println!(" Board: {}", port.board_name);
|
||||
if !port.fqbn.is_empty() {
|
||||
println!(" FQBN: {}", port.fqbn);
|
||||
@@ -229,6 +334,14 @@ pub fn print_port_details(ports: &[PortInfo]) {
|
||||
println!(" Protocol: {}", port.protocol);
|
||||
}
|
||||
|
||||
let vp = port.vid_pid();
|
||||
if !vp.is_empty() {
|
||||
println!(" VID:PID: {}", vp.bright_cyan());
|
||||
}
|
||||
if !port.serial_number.is_empty() {
|
||||
println!(" Serial: {}", port.serial_number.bright_black());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
@@ -254,6 +367,85 @@ pub fn print_port_details(ports: &[PortInfo]) {
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
// Show help section
|
||||
if let Some(idx) = default_idx {
|
||||
if ports.len() > 1 {
|
||||
println!(
|
||||
" Anvil will use {} when no port is specified.",
|
||||
ports[idx].port_name.bright_white()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
println!(
|
||||
" {}",
|
||||
"Save a default port for your project:".bright_white()
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
format!("anvil devices --set {}", ports[idx].port_name).bright_cyan(),
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"anvil devices --set".bright_cyan(),
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"Both forms save the port AND VID:PID to .anvil.local automatically.".bright_black()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"Use --get to see what's saved, --set to change it.".bright_black()
|
||||
);
|
||||
println!();
|
||||
|
||||
// Show VID:PID explanation if any USB devices are present
|
||||
let has_usb = ports.iter().any(|p| !p.vid_pid().is_empty());
|
||||
if has_usb {
|
||||
println!(
|
||||
" {}",
|
||||
"VID:PID identifies the USB device, not the port number.".bright_white()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"If the device moves to a different port after replug,".bright_black()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"Anvil will find it automatically by VID:PID.".bright_black()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
println!(
|
||||
" {}",
|
||||
"Port resolution priority:".bright_white()
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
"1.".bright_white(),
|
||||
"-p flag (upload.bat -p COM3)".bright_black()
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
"2.".bright_white(),
|
||||
"VID:PID from .anvil.local (tracks device across port changes)".bright_black()
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
"3.".bright_white(),
|
||||
"Saved port from .anvil.local".bright_black()
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
"4.".bright_white(),
|
||||
"Auto-detect (prefers USB over legacy COM)".bright_black()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
/// Find arduino-cli in PATH or in ~/.anvil/bin.
|
||||
@@ -308,11 +500,70 @@ mod tests {
|
||||
protocol: "serial".to_string(),
|
||||
board_name: "Test".to_string(),
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
vid: "0x2341".to_string(),
|
||||
pid: "0x0043".to_string(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
let cloned = info.clone();
|
||||
assert_eq!(cloned.port_name, info.port_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vid_pid_formatting() {
|
||||
let info = PortInfo {
|
||||
port_name: "COM3".to_string(),
|
||||
protocol: "Serial Port (USB)".to_string(),
|
||||
board_name: "Arduino Uno".to_string(),
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
vid: "0x2341".to_string(),
|
||||
pid: "0x0043".to_string(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
assert_eq!(info.vid_pid(), "2341:0043");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vid_pid_empty() {
|
||||
let info = PortInfo {
|
||||
port_name: "COM1".to_string(),
|
||||
protocol: "Serial Port".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(),
|
||||
pid: String::new(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
assert_eq!(info.vid_pid(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vid_pid_no_prefix() {
|
||||
let info = PortInfo {
|
||||
port_name: "COM3".to_string(),
|
||||
protocol: "Serial Port (USB)".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: "2341".to_string(),
|
||||
pid: "0043".to_string(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
assert_eq!(info.vid_pid(), "2341:0043");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_usb_from_vid_pid() {
|
||||
let info = PortInfo {
|
||||
port_name: "COM5".to_string(),
|
||||
protocol: "Serial Port".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: "1a86".to_string(),
|
||||
pid: "7523".to_string(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
assert!(info.is_usb());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_board_list() {
|
||||
let json = r#"{"detected_ports": []}"#;
|
||||
@@ -327,7 +578,12 @@ mod tests {
|
||||
"port": {
|
||||
"address": "/dev/ttyUSB0",
|
||||
"protocol": "serial",
|
||||
"protocol_label": "Serial Port (USB)"
|
||||
"protocol_label": "Serial Port (USB)",
|
||||
"properties": {
|
||||
"vid": "0x2341",
|
||||
"pid": "0x0043",
|
||||
"serialNumber": "ABC123"
|
||||
}
|
||||
},
|
||||
"matching_boards": [{
|
||||
"name": "Arduino Uno",
|
||||
@@ -338,8 +594,96 @@ mod tests {
|
||||
let parsed: BoardListOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.detected_ports.len(), 1);
|
||||
let dp = &parsed.detected_ports[0];
|
||||
assert_eq!(dp.port.as_ref().unwrap().address, "/dev/ttyUSB0");
|
||||
let boards = dp.matching_boards.as_ref().unwrap();
|
||||
assert_eq!(boards[0].name, "Arduino Uno");
|
||||
let port = dp.port.as_ref().unwrap();
|
||||
assert_eq!(port.address, "/dev/ttyUSB0");
|
||||
let props = port.properties.as_ref().unwrap();
|
||||
assert_eq!(props["vid"].as_str().unwrap(), "0x2341");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_usb_from_protocol_label() {
|
||||
let usb = PortInfo {
|
||||
port_name: "COM3".to_string(),
|
||||
protocol: "Serial Port (USB)".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(),
|
||||
pid: String::new(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
assert!(usb.is_usb());
|
||||
|
||||
let legacy = PortInfo {
|
||||
port_name: "COM1".to_string(),
|
||||
protocol: "Serial Port".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(),
|
||||
pid: String::new(),
|
||||
serial_number: String::new(),
|
||||
};
|
||||
assert!(!legacy.is_usb());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_default_prefers_fqbn() {
|
||||
let ports = vec![
|
||||
PortInfo {
|
||||
port_name: "COM1".to_string(),
|
||||
protocol: "Serial Port".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(), pid: String::new(), serial_number: String::new(),
|
||||
},
|
||||
PortInfo {
|
||||
port_name: "COM3".to_string(),
|
||||
protocol: "Serial Port (USB)".to_string(),
|
||||
board_name: "Arduino Uno".to_string(),
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
vid: "0x2341".to_string(), pid: "0x0043".to_string(), serial_number: String::new(),
|
||||
},
|
||||
];
|
||||
assert_eq!(pick_default_port(&ports), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_default_prefers_usb_over_legacy() {
|
||||
let ports = vec![
|
||||
PortInfo {
|
||||
port_name: "COM1".to_string(),
|
||||
protocol: "Serial Port".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(), pid: String::new(), serial_number: String::new(),
|
||||
},
|
||||
PortInfo {
|
||||
port_name: "COM3".to_string(),
|
||||
protocol: "Serial Port (USB)".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: "1a86".to_string(), pid: "7523".to_string(), serial_number: String::new(),
|
||||
},
|
||||
];
|
||||
assert_eq!(pick_default_port(&ports), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_default_empty() {
|
||||
let ports: Vec<PortInfo> = vec![];
|
||||
assert_eq!(pick_default_port(&ports), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pick_default_single() {
|
||||
let ports = vec![
|
||||
PortInfo {
|
||||
port_name: "COM1".to_string(),
|
||||
protocol: "Serial Port".to_string(),
|
||||
board_name: "Unknown".to_string(),
|
||||
fqbn: String::new(),
|
||||
vid: String::new(), pid: String::new(), serial_number: String::new(),
|
||||
},
|
||||
];
|
||||
assert_eq!(pick_default_port(&ports), Some(0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user