diff --git a/src/board/mod.rs b/src/board/mod.rs index ad6c7cb..0aff78f 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -35,6 +35,10 @@ impl PortInfo { if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") { return true; } + // FreeBSD: cuaU* are USB serial ports + if self.port_name.contains("cuaU") { + return true; + } false } @@ -171,12 +175,19 @@ fn list_ports_fallback() -> Vec { 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") { + // Linux: ttyUSB* (CH340/FTDI), ttyACM* (Arduino native USB) + // FreeBSD: cuaU* (USB serial) + let is_usb_port = name.starts_with("ttyUSB") + || name.starts_with("ttyACM") + || name.starts_with("cuaU"); + if is_usb_port { let path = format!("/dev/{}", name); let board = if name.starts_with("ttyUSB") { "Likely CH340/FTDI (run 'anvil setup' for full detection)" - } else { + } else if name.starts_with("ttyACM") { "Likely Arduino (run 'anvil setup' for full detection)" + } else { + "USB serial device (run 'anvil setup' for full detection)" }; result.push(PortInfo { port_name: path, diff --git a/src/commands/devices.rs b/src/commands/devices.rs index d879545..9ef1675 100644 --- a/src/commands/devices.rs +++ b/src/commands/devices.rs @@ -62,6 +62,14 @@ pub fn scan_devices() -> Result<()> { println!(" - Check kernel log: dmesg | tail -20"); println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); } + #[cfg(target_os = "freebsd")] + { + println!(" - Check kernel log: dmesg | grep -i usb"); + println!(" - List USB devices: usbconfig list"); + println!(" - Board should appear as /dev/cuaU0 (or cuaU1, cuaU2...)"); + println!(" - Make sure you are in the dialer group: sudo pw groupmod dialer -m $USER"); + println!(" - Then log out and back in for group change to take effect"); + } #[cfg(target_os = "windows")] { println!(" - Open Device Manager and check Ports (COM & LPT)"); diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 1e0598a..b1ed1be 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -10,7 +10,9 @@ pub struct SystemHealth { pub arduino_cli_path: Option, pub avr_core_ok: bool, pub avr_size_ok: bool, + pub avr_size_path: Option, pub dialout_ok: bool, + pub operator_ok: bool, pub cmake_ok: bool, pub cpp_compiler_ok: bool, pub cpp_on_path: bool, @@ -45,7 +47,8 @@ pub fn run_diagnostics(fix: bool) -> Result<()> { if fix { let optional_missing = !health.cmake_ok || !health.cpp_compiler_ok - || !health.git_ok; + || !health.git_ok + || !health.avr_size_ok; if optional_missing { println!(); run_fix_optional(&health)?; @@ -87,8 +90,9 @@ pub fn check_system_health() -> SystemHealth { false }; - let avr_size_ok = which::which("avr-size").is_ok(); + let (avr_size_ok, avr_size_path) = find_avr_size(); let dialout_ok = check_dialout(); + let operator_ok = check_operator_group(); let cmake_ok = which::which("cmake").is_ok(); let cpp_on_path = which::which("g++").is_ok() @@ -108,7 +112,9 @@ pub fn check_system_health() -> SystemHealth { arduino_cli_path, avr_core_ok, avr_size_ok, + avr_size_path, dialout_ok, + operator_ok, cmake_ok, cpp_compiler_ok, cpp_on_path, @@ -189,10 +195,11 @@ fn print_diagnostics(health: &SystemHealth) { "included with arduino:avr core (no separate install)".yellow() ); } else { + let hint = hint_avr_size_not_on_path(&health.avr_size_path); println!( " {} avr-size {}", "na".yellow(), - hint_avr_size_not_on_path().yellow() + hint.as_str().yellow() ); } @@ -207,7 +214,20 @@ fn print_diagnostics(health: &SystemHealth) { " {} {} group {}", "WARN".yellow(), group_name, - format!("run: {}", fix_cmd).yellow() + format!("run: {} (then log out and back in)", fix_cmd).yellow() + ); + } + } + + #[cfg(target_os = "freebsd")] + { + if health.operator_ok { + println!(" {} user in operator group (USB device access)", "ok".green()); + } else { + println!( + " {} operator group {}", + "WARN".yellow(), + "run: sudo pw groupmod operator -m $USER (then log out and back in)".yellow() ); } } @@ -254,21 +274,37 @@ fn print_diagnostics(health: &SystemHealth) { if health.ports_found > 0 { println!( - " {} {} serial port(s) detected", + " {} {} Arduino board(s) detected", "ok".green(), health.ports_found ); } else { - let port_hint = if is_freebsd() { - "(plug in a board to detect -- FreeBSD ports appear as /dev/cuaU0)" - } else { - "(plug in a board to detect)" - }; - println!( - " {} no serial ports {}", - "na".yellow(), - port_hint.yellow() - ); + // On FreeBSD, missing group membership prevents port detection entirely -- + // make that clear so the user doesn't chase a phantom hardware problem. + #[cfg(target_os = "freebsd")] + { + if !health.dialout_ok || !health.operator_ok { + println!( + " {} no board detected {}", + "na".yellow(), + "(cannot scan for boards until group access is fixed -- resolve the WARNs above and log out and back in)".yellow() + ); + } else { + println!( + " {} no board detected {}", + "na".yellow(), + "(plug in your Arduino -- on FreeBSD boards appear as /dev/cuaU0)".yellow() + ); + } + } + #[cfg(not(target_os = "freebsd"))] + { + println!( + " {} no board detected {}", + "na".yellow(), + "(plug in your Arduino to continue)".yellow() + ); + } } } @@ -388,6 +424,16 @@ fn print_fix_instructions(health: &SystemHealth) { println!(); } + #[cfg(target_os = "freebsd")] + if !health.operator_ok { + println!( + " {}", + "Tip: add yourself to the operator group for USB device access: sudo pw groupmod operator -m $USER" + .bright_black() + ); + println!(); + } + if !health.git_ok { println!( " {}", @@ -618,6 +664,43 @@ fn run_fix(health: &SystemHealth) -> Result<()> { } } + // -- serial group (dialout / dialer) -- + #[cfg(unix)] + if !health.dialout_ok { + let group = serial_group_name(); + let fix_cmd = serial_group_fix_command(); + println!(); + println!( + "{}", + format!("You are not in the {} group (needed for board upload).", group).bright_yellow() + ); + if confirm(&format!("Add yourself to {} now?", group)) { + let parts: Vec<&str> = fix_cmd.split_whitespace().collect(); + if run_cmd(parts[0], &parts[1..]) { + println!("{} Added to {} group. Log out and back in to apply.", "ok".green(), group); + } else { + println!("{} Failed. Run manually: {}", "FAIL".red(), fix_cmd); + } + } + } + + // -- operator group (FreeBSD USB device access) -- + #[cfg(target_os = "freebsd")] + if !health.operator_ok { + println!(); + println!( + "{}", + "You are not in the operator group (needed for USB device access on FreeBSD).".bright_yellow() + ); + if confirm("Add yourself to operator group now?") { + if run_cmd("sudo", &["pw", "groupmod", "operator", "-m", &whoami()]) { + println!("{} Added to operator group. Log out and back in to apply.", "ok".green()); + } else { + println!("{} Failed. Run manually: sudo pw groupmod operator -m $USER", "FAIL".red()); + } + } + } + run_fix_optional(health)?; Ok(()) } @@ -625,6 +708,52 @@ fn run_fix(health: &SystemHealth) -> Result<()> { fn run_fix_optional(health: &SystemHealth) -> Result<()> { let pm = detect_package_manager(); + // -- avr-size PATH fix -- + if !health.avr_size_ok { + if let Some(ref bin_path) = health.avr_size_path { + let bin_dir = std::path::Path::new(bin_path) + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + println!(); + println!( + "{}", + "avr-size is installed but not on PATH.".bright_yellow() + ); + println!( + " Found at: {}", + bin_path.bright_cyan() + ); + if !bin_dir.is_empty() { + let shell_rc = detect_shell_rc(); + let export_line = format!(r#"export PATH="{}:$PATH""#, bin_dir); + if confirm(&format!("Add {} to PATH in {}?", bin_dir, shell_rc)) { + let rc_path = std::path::Path::new(&shell_rc); + let existing = std::fs::read_to_string(rc_path).unwrap_or_default(); + if existing.contains(&export_line) { + println!("{} Already in {} -- log out and back in to apply.", "ok".green(), shell_rc); + } else { + let line = format!(" +# Added by anvil doctor --fix +{} +", export_line); + if std::fs::OpenOptions::new() + .append(true) + .open(rc_path) + .and_then(|mut f| { use std::io::Write; f.write_all(line.as_bytes()) }) + .is_ok() + { + println!("{} Added to {}. Log out and back in to apply.", "ok".green(), shell_rc); + } else { + println!("{} Could not write to {}. Add manually:", "FAIL".red(), shell_rc); + println!(" {}", export_line.bright_cyan()); + } + } + } + } + } + } + let items: Vec<(&str, bool, FixSpec)> = vec![ ("cmake", health.cmake_ok, fix_spec_cmake(pm)), ("C++ compiler", health.cpp_compiler_ok, fix_spec_cpp(pm)), @@ -632,6 +761,9 @@ fn run_fix_optional(health: &SystemHealth) -> Result<()> { ]; let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect(); + if missing.is_empty() && health.avr_size_ok { + return Ok(()); + } if missing.is_empty() { return Ok(()); } @@ -795,14 +927,45 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec { } } -fn hint_avr_size_not_on_path() -> &'static str { +fn hint_avr_size_not_on_path(found_path: &Option) -> String { + let location = match found_path { + Some(p) => format!("found at {} -- add its directory to PATH, or log out and back in", p), + None => "not found -- install the AVR core first: anvil setup".to_string(), + }; if cfg!(target_os = "windows") { - "installed but not on PATH (binary size reports will be skipped)" + format!("installed but not on PATH ({})", location) } else { - "installed but not on PATH" + format!("not on PATH ({})", location) } } +/// Search common locations for avr-size and return (on_path, found_path). +fn find_avr_size() -> (bool, Option) { + if which::which("avr-size").is_ok() { + return (true, None); + } + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let search_roots = vec![ + format!("{}/.arduino15/packages/arduino/tools/avr-gcc", home), + format!("{}/.local/share/arduino15/packages/arduino/tools/avr-gcc", home), + format!("{}\\AppData\\Local\\Arduino15\\packages\\arduino\\tools\\avr-gcc", home), + ]; + for root in &search_roots { + let root_path = std::path::Path::new(root); + if !root_path.exists() { continue; } + if let Ok(entries) = std::fs::read_dir(root_path) { + for version_entry in entries.flatten() { + let bin_name = if cfg!(target_os = "windows") { "avr-size.exe" } else { "avr-size" }; + let bin = version_entry.path().join("bin").join(bin_name); + if bin.exists() { + return (false, Some(bin.display().to_string())); + } + } + } + } + (false, None) +} + fn hint_cmake() -> String { if cfg!(target_os = "windows") { "install: winget install Kitware.CMake (or choco install cmake)".into() @@ -888,4 +1051,47 @@ fn check_dialout() -> bool { { true } +} +fn check_operator_group() -> bool { + #[cfg(target_os = "freebsd")] + { + match std::process::Command::new("groups").output() { + Ok(out) => { + let groups = String::from_utf8_lossy(&out.stdout); + groups.contains("operator") + } + Err(_) => false, + } + } + + #[cfg(not(target_os = "freebsd"))] + { + true // not applicable on other platforms + } +} + +fn whoami() -> String { + std::process::Command::new("whoami") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "$USER".to_string()) +} + +/// Detect the user's shell RC file for PATH exports. +fn detect_shell_rc() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + // Check SHELL env var first + let shell = std::env::var("SHELL").unwrap_or_default(); + if shell.contains("zsh") { + return format!("{}/.zshrc", home); + } + if shell.contains("fish") { + return format!("{}/.config/fish/config.fish", home); + } + // Fall back to checking which RC files exist + let zshrc = format!("{}/.zshrc", home); + if std::path::Path::new(&zshrc).exists() { + return zshrc; + } + format!("{}/.bashrc", home) } \ No newline at end of file