use anyhow::{Result, bail}; use colored::*; use serde::Deserialize; use std::path::{Path, PathBuf}; use std::process::Command; pub mod presets; pub mod pinmap; /// Information about a detected serial port. #[derive(Debug, Clone)] pub struct PortInfo { pub port_name: String, 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` #[derive(Debug, Deserialize)] struct BoardListOutput { #[serde(default)] detected_ports: Vec, } #[derive(Debug, Deserialize)] struct DetectedPort { #[serde(default)] port: Option, #[serde(default)] matching_boards: Option>, } #[derive(Debug, Deserialize)] #[allow(dead_code)] struct PortEntry { #[serde(default)] address: String, #[serde(default)] protocol: String, #[serde(default)] protocol_label: String, #[serde(default)] properties: Option, } #[derive(Debug, Deserialize)] struct MatchingBoard { #[serde(default)] name: String, #[serde(default)] fqbn: String, } /// Enumerate serial ports via `arduino-cli board list --format json`. /// Falls back to OS-level detection if arduino-cli is unavailable. pub fn list_ports() -> Vec { if let Some(cli) = find_arduino_cli() { if let Ok(ports) = list_ports_via_cli(&cli) { return ports; } } list_ports_fallback() } fn list_ports_via_cli(cli: &Path) -> Result> { let output = Command::new(cli) .args(["board", "list", "--format", "json"]) .output()?; if !output.status.success() { bail!("arduino-cli board list failed"); } let stdout = String::from_utf8_lossy(&output.stdout); let parsed: BoardListOutput = serde_json::from_str(&stdout)?; let mut result = Vec::new(); for dp in parsed.detected_ports { let port = match dp.port { Some(p) => p, None => continue, }; if port.protocol != "serial" { continue; } let (board_name, fqbn) = match dp.matching_boards { Some(ref boards) if !boards.is_empty() => { (boards[0].name.clone(), boards[0].fqbn.clone()) } _ => ("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, }); } Ok(result) } /// Fallback port detection when arduino-cli is not available. fn list_ports_fallback() -> Vec { let mut result = Vec::new(); #[cfg(unix)] { use std::fs; if let Ok(entries) = fs::read_dir("/dev") { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with("ttyUSB") || name.starts_with("ttyACM") { let path = format!("/dev/{}", name); let board = if name.starts_with("ttyUSB") { "Likely CH340/FTDI (run 'anvil setup' for full detection)" } else { "Likely Arduino (run 'anvil setup' for full detection)" }; result.push(PortInfo { port_name: path, protocol: "serial".to_string(), board_name: board.to_string(), fqbn: String::new(), vid: String::new(), pid: String::new(), serial_number: String::new(), }); } } } } #[cfg(windows)] { if let Ok(output) = Command::new("powershell") .args([ "-NoProfile", "-Command", "Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Caption | ConvertTo-Json", ]) .output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { if let Some(idx) = line.find("COM") { let port = line[idx..].split('"').next().unwrap_or("").trim(); if !port.is_empty() { result.push(PortInfo { port_name: port.to_string(), protocol: "serial".to_string(), board_name: "Detected via WMI".to_string(), fqbn: String::new(), vid: String::new(), pid: String::new(), serial_number: String::new(), }); } } } } } } result } /// Find a port by VID:PID. Returns the port name if found. pub fn resolve_vid_pid(vid_pid: &str) -> Option { 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 { 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 { let ports = list_ports(); if ports.is_empty() { bail!( "No serial ports found. Is the board plugged in?\n\ Run: anvil devices" ); } let idx = pick_default_port(&ports).unwrap_or(0); 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() ); } Ok(ports[idx].port_name.clone()) } /// Print detailed port information. pub fn print_port_details(ports: &[PortInfo]) { if ports.is_empty() { println!(" {}", "No serial devices found.".yellow()); println!(); println!(" Checklist:"); println!(" 1. Is the board plugged in via USB?"); println!(" 2. Is the USB cable a data cable (not charge-only)?"); #[cfg(target_os = "linux")] { println!(" 3. Check kernel log: dmesg | tail -20"); println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); } println!(" 5. Try a different USB port or cable"); return; } 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); } if !port.protocol.is_empty() { 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; let path = std::path::Path::new(&port.port_name); if path.exists() { match OpenOptions::new().write(true).open(path) { Ok(_) => { println!( " Permissions: {}", "OK (writable)".green() ); } Err(_) => { println!( " Permissions: {}", "NOT writable -- run: sudo usermod -aG dialout $USER" .red() ); } } } } 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. pub fn find_arduino_cli() -> Option { if let Ok(path) = which::which("arduino-cli") { return Some(path); } if let Ok(home) = crate::project::config::anvil_home() { let name = if cfg!(target_os = "windows") { "arduino-cli.exe" } else { "arduino-cli" }; let bin = home.join("bin").join(name); if bin.exists() { return Some(bin); } } None } /// Check if the arduino:avr core is installed. pub fn is_avr_core_installed(cli_path: &Path) -> bool { let output = Command::new(cli_path) .args(["core", "list"]) .output(); match output { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); stdout.contains("arduino:avr") } Err(_) => false, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_list_ports_does_not_panic() { let _ports = list_ports(); } #[test] fn test_port_info_clone() { let info = PortInfo { port_name: "/dev/ttyUSB0".to_string(), 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_vid_pid_case_insensitive() { let info = PortInfo { port_name: "COM3".to_string(), protocol: "Serial Port (USB)".to_string(), board_name: "Unknown".to_string(), fqbn: String::new(), vid: "0x2341".to_string(), pid: "0x0043".to_string(), serial_number: String::new(), }; // vid_pid() should normalize to lowercase assert_eq!(info.vid_pid(), "2341:0043"); let upper = PortInfo { port_name: "COM3".to_string(), protocol: "Serial Port (USB)".to_string(), board_name: "Unknown".to_string(), fqbn: String::new(), vid: "0x1A86".to_string(), pid: "0x7523".to_string(), serial_number: String::new(), }; assert_eq!(upper.vid_pid(), "1a86:7523"); } #[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": []}"#; let parsed: BoardListOutput = serde_json::from_str(json).unwrap(); assert!(parsed.detected_ports.is_empty()); } #[test] fn test_parse_board_list_with_port() { let json = r#"{ "detected_ports": [{ "port": { "address": "/dev/ttyUSB0", "protocol": "serial", "protocol_label": "Serial Port (USB)", "properties": { "vid": "0x2341", "pid": "0x0043", "serialNumber": "ABC123" } }, "matching_boards": [{ "name": "Arduino Uno", "fqbn": "arduino:avr:uno" }] }] }"#; let parsed: BoardListOutput = serde_json::from_str(json).unwrap(); assert_eq!(parsed.detected_ports.len(), 1); let dp = &parsed.detected_ports[0]; 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 = 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)); } }