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:
Eric Ratliff
2026-02-16 08:29:33 -06:00
parent 3298844399
commit 8fe1ef0e27
25 changed files with 2551 additions and 731 deletions

View File

@@ -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));
}
}