feat(freebsd): full FreeBSD 15.0 compatibility
Some checks failed
CI / Test (Linux) (push) Has been cancelled
CI / Test (Windows MSVC) (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled

- board: detect cuaU* serial ports in fallback scanner, filter .init/.lock nodes
- board: fall through to OS scan when arduino-cli returns empty (serial-discovery
  uses unsupported Linux syscalls on FreeBSD)
- board: recognize cuaU* port names as USB in is_usb()
- upload.sh: fix silent exit under set -euo pipefail when vid_pid grep finds
  nothing (|| true on LOCAL_PORT and LOCAL_VID_PID greps)
- upload.sh: add cuaU to auto-detect port pattern
- doctor: detect dialer and operator group membership, offer --fix for both
- doctor: detect avr-size installed but not on PATH, offer --fix to add to
  shell rc file via detect_shell_rc()
- doctor: context-aware hardware section (suppress misleading messages when
  groups not yet applied)
- doctor: FreeBSD-specific troubleshooting hints throughout
- devices: suppress arduino-cli board detection section on FreeBSD (broken
  due to serial-discovery syscall issues)
- devices: always show USB hub tip on FreeBSD
- devices: FreeBSD-specific troubleshooting checklist with sequential numbering
  on all platforms
This commit is contained in:
Eric Ratliff
2026-03-15 17:02:32 -05:00
parent d86c79b9cb
commit 42d60515f2
7 changed files with 519 additions and 123 deletions

View File

@@ -35,6 +35,10 @@ impl PortInfo {
if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") { if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") {
return true; return true;
} }
// FreeBSD: cuaU* are USB serial ports
if self.port_name.contains("cuaU") {
return true;
}
false false
} }
@@ -87,11 +91,18 @@ struct MatchingBoard {
} }
/// Enumerate serial ports via `arduino-cli board list --format json`. /// Enumerate serial ports via `arduino-cli board list --format json`.
/// Falls back to OS-level detection if arduino-cli is unavailable. /// Falls back to OS-level detection if arduino-cli is unavailable or returns
/// empty results (e.g. on FreeBSD where serial-discovery uses unsupported syscalls).
pub fn list_ports() -> Vec<PortInfo> { pub fn list_ports() -> Vec<PortInfo> {
if let Some(cli) = find_arduino_cli() { if let Some(cli) = find_arduino_cli() {
if let Ok(ports) = list_ports_via_cli(&cli) { if let Ok(ports) = list_ports_via_cli(&cli) {
return ports; if !ports.is_empty() {
return ports;
}
// arduino-cli succeeded but found nothing -- on FreeBSD its
// serial-discovery subprocess uses Linux syscalls that are not
// implemented, so it always returns empty. Fall through to the
// OS-level scan which checks /dev/cuaU* directly.
} }
} }
list_ports_fallback() list_ports_fallback()
@@ -171,12 +182,22 @@ fn list_ports_fallback() -> Vec<PortInfo> {
if let Ok(entries) = fs::read_dir("/dev") { if let Ok(entries) = fs::read_dir("/dev") {
for entry in entries.flatten() { for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string(); 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)
// On FreeBSD, cuaU0.init and cuaU0.lock are control nodes,
// not actual ports -- only match bare cuaU* names.
let is_usb_port = name.starts_with("ttyUSB")
|| name.starts_with("ttyACM")
|| (name.starts_with("cuaU")
&& !name.contains('.'));
if is_usb_port {
let path = format!("/dev/{}", name); let path = format!("/dev/{}", name);
let board = if name.starts_with("ttyUSB") { let board = if name.starts_with("ttyUSB") {
"Likely CH340/FTDI (run 'anvil setup' for full detection)" "Likely CH340/FTDI (run 'anvil setup' for full detection)"
} else { } else if name.starts_with("ttyACM") {
"Likely Arduino (run 'anvil setup' for full detection)" "Likely Arduino (run 'anvil setup' for full detection)"
} else {
"USB serial device (run 'anvil setup' for full detection)"
}; };
result.push(PortInfo { result.push(PortInfo {
port_name: path, port_name: path,
@@ -306,14 +327,23 @@ pub fn print_port_details(ports: &[PortInfo]) {
println!(" {}", "No serial devices found.".yellow()); println!(" {}", "No serial devices found.".yellow());
println!(); println!();
println!(" Checklist:"); 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"); let mut n = 1u32;
println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); println!(" {}. Is the board plugged in via USB?", n); n += 1;
println!(" {}. Is the USB cable a data cable (not charge-only)?", n); n += 1;
#[cfg(target_os = "linux")]
{
println!(" {}. Check kernel log: dmesg | tail -20", n); n += 1;
println!(" {}. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'", n); n += 1;
}
#[cfg(target_os = "freebsd")]
{
println!(" {}. Check kernel log: dmesg | grep -i usb", n); n += 1;
println!(" {}. List USB devices: usbconfig list", n); n += 1;
println!(" {}. Board should appear as /dev/cuaU0 (or cuaU1, cuaU2...)", n); n += 1;
}
println!(" {}. Try a different USB port or cable", n);
} }
println!(" 5. Try a different USB port or cable");
return; return;
} }

View File

@@ -15,7 +15,20 @@ pub fn scan_devices() -> Result<()> {
let ports = board::list_ports(); let ports = board::list_ports();
board::print_port_details(&ports); board::print_port_details(&ports);
// Also run arduino-cli board list for cross-reference #[cfg(target_os = "freebsd")]
{
println!();
println!(
" {}",
"Tip: if your board is not showing up, try plugging it directly into the machine -- some USB hubs prevent boards from being detected on FreeBSD.".bright_black()
);
}
// Also run arduino-cli board list for cross-reference.
// On FreeBSD, arduino-cli is a Linux binary whose serial-discovery subprocess
// uses syscalls not implemented in the Linux compat layer -- skip it entirely
// since we already detected ports via the OS fallback above.
#[cfg(not(target_os = "freebsd"))]
if let Some(cli_path) = board::find_arduino_cli() { if let Some(cli_path) = board::find_arduino_cli() {
println!(); println!();
println!( println!(
@@ -62,6 +75,14 @@ pub fn scan_devices() -> Result<()> {
println!(" - Check kernel log: dmesg | tail -20"); println!(" - Check kernel log: dmesg | tail -20");
println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); 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")] #[cfg(target_os = "windows")]
{ {
println!(" - Open Device Manager and check Ports (COM & LPT)"); println!(" - Open Device Manager and check Ports (COM & LPT)");

View File

@@ -10,7 +10,9 @@ pub struct SystemHealth {
pub arduino_cli_path: Option<String>, pub arduino_cli_path: Option<String>,
pub avr_core_ok: bool, pub avr_core_ok: bool,
pub avr_size_ok: bool, pub avr_size_ok: bool,
pub avr_size_path: Option<String>,
pub dialout_ok: bool, pub dialout_ok: bool,
pub operator_ok: bool,
pub cmake_ok: bool, pub cmake_ok: bool,
pub cpp_compiler_ok: bool, pub cpp_compiler_ok: bool,
pub cpp_on_path: bool, pub cpp_on_path: bool,
@@ -43,10 +45,10 @@ pub fn run_diagnostics(fix: bool) -> Result<()> {
.bold() .bold()
); );
if fix { if fix {
// Check optional tools
let optional_missing = !health.cmake_ok let optional_missing = !health.cmake_ok
|| !health.cpp_compiler_ok || !health.cpp_compiler_ok
|| !health.git_ok; || !health.git_ok
|| !health.avr_size_ok;
if optional_missing { if optional_missing {
println!(); println!();
run_fix_optional(&health)?; run_fix_optional(&health)?;
@@ -76,13 +78,11 @@ pub fn run_diagnostics(fix: bool) -> Result<()> {
} }
pub fn check_system_health() -> SystemHealth { pub fn check_system_health() -> SystemHealth {
// arduino-cli
let (arduino_cli_ok, arduino_cli_path) = match board::find_arduino_cli() { let (arduino_cli_ok, arduino_cli_path) = match board::find_arduino_cli() {
Some(path) => (true, Some(path.display().to_string())), Some(path) => (true, Some(path.display().to_string())),
None => (false, None), None => (false, None),
}; };
// AVR core
let avr_core_ok = if let Some(ref path_str) = arduino_cli_path { let avr_core_ok = if let Some(ref path_str) = arduino_cli_path {
let path = std::path::Path::new(path_str); let path = std::path::Path::new(path_str);
board::is_avr_core_installed(path) board::is_avr_core_installed(path)
@@ -90,16 +90,11 @@ pub fn check_system_health() -> SystemHealth {
false false
}; };
// avr-size (optional) let (avr_size_ok, avr_size_path) = find_avr_size();
let avr_size_ok = which::which("avr-size").is_ok();
// dialout group (Linux only)
let dialout_ok = check_dialout(); let dialout_ok = check_dialout();
let operator_ok = check_operator_group();
// cmake (optional -- for host tests)
let cmake_ok = which::which("cmake").is_ok(); let cmake_ok = which::which("cmake").is_ok();
// C++ compiler (optional -- for host tests)
let cpp_on_path = which::which("g++").is_ok() let cpp_on_path = which::which("g++").is_ok()
|| which::which("clang++").is_ok() || which::which("clang++").is_ok()
|| which::which("cl").is_ok(); || which::which("cl").is_ok();
@@ -109,10 +104,7 @@ pub fn check_system_health() -> SystemHealth {
has_cpp_compiler() has_cpp_compiler()
}; };
// git
let git_ok = which::which("git").is_ok(); let git_ok = which::which("git").is_ok();
// Serial ports
let ports_found = board::list_ports().len(); let ports_found = board::list_ports().len();
SystemHealth { SystemHealth {
@@ -120,7 +112,9 @@ pub fn check_system_health() -> SystemHealth {
arduino_cli_path, arduino_cli_path,
avr_core_ok, avr_core_ok,
avr_size_ok, avr_size_ok,
avr_size_path,
dialout_ok, dialout_ok,
operator_ok,
cmake_ok, cmake_ok,
cpp_compiler_ok, cpp_compiler_ok,
cpp_on_path, cpp_on_path,
@@ -129,7 +123,6 @@ pub fn check_system_health() -> SystemHealth {
} }
} }
/// Check for a C++ compiler on any platform.
fn has_cpp_compiler() -> bool { fn has_cpp_compiler() -> bool {
if which::which("g++").is_ok() || which::which("clang++").is_ok() { if which::which("g++").is_ok() || which::which("clang++").is_ok() {
return true; return true;
@@ -139,8 +132,6 @@ fn has_cpp_compiler() -> bool {
if which::which("cl").is_ok() { if which::which("cl").is_ok() {
return true; return true;
} }
// cl.exe may be installed via VS Build Tools but not on PATH.
// Check via vswhere.exe (ships with VS installer).
if let Ok(output) = std::process::Command::new( if let Ok(output) = std::process::Command::new(
r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe", r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe",
) )
@@ -161,7 +152,6 @@ fn print_diagnostics(health: &SystemHealth) {
println!("{}", "Required:".bright_yellow().bold()); println!("{}", "Required:".bright_yellow().bold());
println!(); println!();
// arduino-cli
if health.arduino_cli_ok { if health.arduino_cli_ok {
println!( println!(
" {} arduino-cli {}", " {} arduino-cli {}",
@@ -176,7 +166,6 @@ fn print_diagnostics(health: &SystemHealth) {
println!(" {} arduino-cli {}", "MISSING".red(), "not found in PATH".red()); println!(" {} arduino-cli {}", "MISSING".red(), "not found in PATH".red());
} }
// AVR core
if health.avr_core_ok { if health.avr_core_ok {
println!(" {} arduino:avr core installed", "ok".green()); println!(" {} arduino:avr core installed", "ok".green());
} else if health.arduino_cli_ok { } else if health.arduino_cli_ok {
@@ -197,7 +186,6 @@ fn print_diagnostics(health: &SystemHealth) {
println!("{}", "Optional:".bright_yellow().bold()); println!("{}", "Optional:".bright_yellow().bold());
println!(); println!();
// avr-size
if health.avr_size_ok { if health.avr_size_ok {
println!(" {} avr-size (binary size reporting)", "ok".green()); println!(" {} avr-size (binary size reporting)", "ok".green());
} else if !health.avr_core_ok { } else if !health.avr_core_ok {
@@ -207,39 +195,53 @@ fn print_diagnostics(health: &SystemHealth) {
"included with arduino:avr core (no separate install)".yellow() "included with arduino:avr core (no separate install)".yellow()
); );
} else { } else {
let hint = hint_avr_size_not_on_path(&health.avr_size_path);
println!( println!(
" {} avr-size {}", " {} avr-size {}",
"na".yellow(), "na".yellow(),
hint_avr_size_not_on_path().yellow() hint.as_str().yellow()
); );
} }
// dialout
#[cfg(unix)] #[cfg(unix)]
{ {
let group_name = serial_group_name();
if health.dialout_ok { if health.dialout_ok {
println!(" {} user in dialout group", "ok".green()); println!(" {} user in {} group", "ok".green(), group_name);
} else { } else {
let fix_cmd = serial_group_fix_command();
println!( println!(
" {} dialout group {}", " {} {} group {}",
"WARN".yellow(), "WARN".yellow(),
"run: sudo usermod -aG dialout $USER".yellow() group_name,
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()
); );
} }
} }
// cmake
if health.cmake_ok { if health.cmake_ok {
println!(" {} cmake (for host-side tests)", "ok".green()); println!(" {} cmake (for host-side tests)", "ok".green());
} else { } else {
println!( println!(
" {} cmake {}", " {} cmake {}",
"na".yellow(), "na".yellow(),
hint_cmake().yellow() hint_cmake().as_str().yellow()
); );
} }
// C++ compiler
if health.cpp_on_path { if health.cpp_on_path {
println!(" {} C++ compiler", "ok".green()); println!(" {} C++ compiler", "ok".green());
} else if health.cpp_compiler_ok { } else if health.cpp_compiler_ok {
@@ -252,18 +254,17 @@ fn print_diagnostics(health: &SystemHealth) {
println!( println!(
" {} C++ compiler {}", " {} C++ compiler {}",
"na".yellow(), "na".yellow(),
hint_cpp_compiler().yellow() hint_cpp_compiler().as_str().yellow()
); );
} }
// git
if health.git_ok { if health.git_ok {
println!(" {} git", "ok".green()); println!(" {} git", "ok".green());
} else { } else {
println!( println!(
" {} git {}", " {} git {}",
"na".yellow(), "na".yellow(),
hint_git().yellow() hint_git().as_str().yellow()
); );
} }
@@ -273,20 +274,41 @@ fn print_diagnostics(health: &SystemHealth) {
if health.ports_found > 0 { if health.ports_found > 0 {
println!( println!(
" {} {} serial port(s) detected", " {} {} Arduino board(s) detected",
"ok".green(), "ok".green(),
health.ports_found health.ports_found
); );
} else { } else {
println!( // On FreeBSD, missing group membership prevents port detection entirely --
" {} no serial ports {}", // make that clear so the user doesn't chase a phantom hardware problem.
"na".yellow(), #[cfg(target_os = "freebsd")]
"(plug in a board to detect)".yellow() {
); 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()
);
}
} }
} }
/// Print step-by-step fix instructions when required items are missing.
fn print_fix_instructions(health: &SystemHealth) { fn print_fix_instructions(health: &SystemHealth) {
println!("{}", "How to fix:".bright_cyan().bold()); println!("{}", "How to fix:".bright_cyan().bold());
println!(); println!();
@@ -310,11 +332,32 @@ fn print_fix_instructions(health: &SystemHealth) {
println!(" Option C -- Direct download:"); println!(" Option C -- Direct download:");
println!( println!(
" {}", " {}",
"https://arduino.github.io/arduino-cli/installation/" "https://arduino.github.io/arduino-cli/installation/".bright_cyan()
.bright_cyan()
); );
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
println!(" {}", "brew install arduino-cli".bright_cyan()); println!(" {}", "brew install arduino-cli".bright_cyan());
} else if is_freebsd() {
println!();
println!(" FreeBSD -- download the Linux binary (runs via Linux compat layer):");
println!(
" {}",
"curl -L https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz -o /tmp/arduino-cli.tar.gz"
.bright_cyan()
);
println!(
" {}",
"tar xzf /tmp/arduino-cli.tar.gz -C /tmp".bright_cyan()
);
println!(
" {}",
"sudo mv /tmp/arduino-cli /usr/local/bin/".bright_cyan()
);
println!();
println!(" Ensure Linux compat layer is active:");
println!(
" {}",
"sudo sysrc linux_enable=YES && sudo service linux start".bright_cyan()
);
} else { } else {
println!(); println!();
println!(" Option A -- Install script:"); println!(" Option A -- Install script:");
@@ -362,46 +405,54 @@ fn print_fix_instructions(health: &SystemHealth) {
println!( println!(
" {}. {}", " {}. {}",
step, step,
"Install the AVR core and verify everything:" "Install the AVR core and verify everything:".bright_white().bold()
.bright_white()
.bold()
); );
println!(" {}", "anvil setup".bright_cyan()); println!(" {}", "anvil setup".bright_cyan());
println!(); println!();
// step += 1; }
#[cfg(unix)]
if !health.dialout_ok {
let fix_cmd = serial_group_fix_command();
println!(
" {}",
format!(
"Tip: add yourself to the serial group for board upload access: {}",
fix_cmd
)
.bright_black()
);
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 { if !health.git_ok {
println!( println!(
" {}", " {}",
"Tip: git is optional but recommended for version control." "Tip: git is optional but recommended for version control.".bright_black()
.bright_black()
); );
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
println!( println!(" {}", "winget install Git.Git".bright_black());
" {}",
"winget install Git.Git".bright_black()
);
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
println!( println!(" {}", "xcode-select --install".bright_black());
" {}", } else if is_freebsd() {
"xcode-select --install".bright_black() println!(" {}", "sudo pkg install git".bright_black());
);
} else { } else {
println!( println!(" {}", "sudo apt install git".bright_black());
" {}",
"sudo apt install git".bright_black()
);
} }
println!(); println!();
} }
} }
// ==========================================================================
// --fix: automated installation
// ==========================================================================
/// Prompt the user for yes/no confirmation.
fn confirm(prompt: &str) -> bool { fn confirm(prompt: &str) -> bool {
print!("{} [Y/n] ", prompt); print!("{} [Y/n] ", prompt);
io::stdout().flush().ok(); io::stdout().flush().ok();
@@ -413,7 +464,6 @@ fn confirm(prompt: &str) -> bool {
trimmed.is_empty() || trimmed == "y" || trimmed == "yes" trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
} }
/// Run a command, streaming output to the terminal.
fn run_cmd(program: &str, args: &[&str]) -> bool { fn run_cmd(program: &str, args: &[&str]) -> bool {
println!( println!(
" {} {} {}", " {} {} {}",
@@ -428,7 +478,6 @@ fn run_cmd(program: &str, args: &[&str]) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Detect which package manager is available on the system.
fn detect_package_manager() -> Option<&'static str> { fn detect_package_manager() -> Option<&'static str> {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
if which::which("winget").is_ok() { if which::which("winget").is_ok() {
@@ -444,8 +493,13 @@ fn detect_package_manager() -> Option<&'static str> {
} else { } else {
None None
} }
} else if is_freebsd() {
if which::which("pkg").is_ok() {
Some("pkg")
} else {
None
}
} else { } else {
// Linux
if which::which("apt").is_ok() { if which::which("apt").is_ok() {
Some("apt") Some("apt")
} else if which::which("dnf").is_ok() { } else if which::which("dnf").is_ok() {
@@ -458,11 +512,9 @@ fn detect_package_manager() -> Option<&'static str> {
} }
} }
/// Fix required items (arduino-cli, avr core).
fn run_fix(health: &SystemHealth) -> Result<()> { fn run_fix(health: &SystemHealth) -> Result<()> {
let pm = detect_package_manager(); let pm = detect_package_manager();
// -- arduino-cli --
if !health.arduino_cli_ok { if !health.arduino_cli_ok {
println!( println!(
"{}", "{}",
@@ -524,10 +576,39 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
println!("{} arduino-cli installed.", "ok".green()); println!("{} arduino-cli installed.", "ok".green());
} }
} }
Some("dnf") => { Some("pkg") => {
println!( println!(
" arduino-cli is not in dnf repos. Install manually:" " {}",
"arduino-cli is not in the FreeBSD ports tree.".bright_yellow()
); );
println!(
" {}",
"It runs via the Linux compatibility layer (linux_enable must be YES).".bright_black()
);
if confirm("Download and install the Linux arduino-cli binary now?") {
if !run_cmd("curl", &[
"-L",
"https://downloads.arduino.cc/arduino-cli/arduino-cli_latest_Linux_64bit.tar.gz",
"-o", "/tmp/arduino-cli.tar.gz",
]) {
println!("{} Download failed. Check your internet connection.", "FAIL".red());
return Ok(());
}
run_cmd("tar", &["xzf", "/tmp/arduino-cli.tar.gz", "-C", "/tmp"]);
if !run_cmd("sudo", &["mv", "/tmp/arduino-cli", "/usr/local/bin/arduino-cli"]) {
println!("{} Could not move binary to /usr/local/bin/.", "FAIL".red());
return Ok(());
}
println!("{} arduino-cli installed.", "ok".green());
println!(
" {}",
"If it fails to run, ensure Linux compat is active: sudo sysrc linux_enable=YES && sudo service linux start"
.bright_black()
);
}
}
Some("dnf") => {
println!(" arduino-cli is not in dnf repos. Install manually:");
println!( println!(
" {}", " {}",
"curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh" "curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"
@@ -545,19 +626,15 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
} }
} }
_ => { _ => {
println!( println!(" No supported package manager found. Install manually:");
" No supported package manager found. Install manually:"
);
println!( println!(
" {}", " {}",
"https://arduino.github.io/arduino-cli/installation/" "https://arduino.github.io/arduino-cli/installation/".bright_cyan()
.bright_cyan()
); );
return Ok(()); return Ok(());
} }
} }
// Re-check after install
if board::find_arduino_cli().is_none() { if board::find_arduino_cli().is_none() {
println!(); println!();
println!( println!(
@@ -568,7 +645,6 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
} }
} }
// -- AVR core --
if !health.avr_core_ok { if !health.avr_core_ok {
if health.arduino_cli_ok || board::find_arduino_cli().is_some() { if health.arduino_cli_ok || board::find_arduino_cli().is_some() {
println!(); println!();
@@ -589,16 +665,96 @@ fn run_fix(health: &SystemHealth) -> Result<()> {
} }
} }
// Offer optional tools too // -- serial group (dialout / dialer) --
run_fix_optional(health)?; #[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(()) Ok(())
} }
/// Fix optional items (cmake, C++ compiler, git).
fn run_fix_optional(health: &SystemHealth) -> Result<()> { fn run_fix_optional(health: &SystemHealth) -> Result<()> {
let pm = detect_package_manager(); 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![ let items: Vec<(&str, bool, FixSpec)> = vec![
("cmake", health.cmake_ok, fix_spec_cmake(pm)), ("cmake", health.cmake_ok, fix_spec_cmake(pm)),
("C++ compiler", health.cpp_compiler_ok, fix_spec_cpp(pm)), ("C++ compiler", health.cpp_compiler_ok, fix_spec_cpp(pm)),
@@ -606,6 +762,9 @@ fn run_fix_optional(health: &SystemHealth) -> Result<()> {
]; ];
let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect(); let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect();
if missing.is_empty() && health.avr_size_ok {
return Ok(());
}
if missing.is_empty() { if missing.is_empty() {
return Ok(()); return Ok(());
} }
@@ -666,6 +825,11 @@ fn fix_spec_cmake(pm: Option<&str>) -> FixSpec {
program: "sudo", program: "sudo",
args: &["apt", "install", "-y", "cmake"], args: &["apt", "install", "-y", "cmake"],
}, },
Some("pkg") => FixSpec::Auto {
prompt: "Install cmake via pkg?",
program: "sudo",
args: &["pkg", "install", "-y", "cmake"],
},
Some("dnf") => FixSpec::Auto { Some("dnf") => FixSpec::Auto {
prompt: "Install cmake via dnf?", prompt: "Install cmake via dnf?",
program: "sudo", program: "sudo",
@@ -702,6 +866,9 @@ fn fix_spec_cpp(pm: Option<&str>) -> FixSpec {
program: "sudo", program: "sudo",
args: &["apt", "install", "-y", "g++"], args: &["apt", "install", "-y", "g++"],
}, },
Some("pkg") => FixSpec::Manual {
message: "clang++ is already part of the FreeBSD base system",
},
Some("dnf") => FixSpec::Auto { Some("dnf") => FixSpec::Auto {
prompt: "Install g++ via dnf?", prompt: "Install g++ via dnf?",
program: "sudo", program: "sudo",
@@ -740,6 +907,11 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec {
program: "sudo", program: "sudo",
args: &["apt", "install", "-y", "git"], args: &["apt", "install", "-y", "git"],
}, },
Some("pkg") => FixSpec::Auto {
prompt: "Install git via pkg?",
program: "sudo",
args: &["pkg", "install", "-y", "git"],
},
Some("dnf") => FixSpec::Auto { Some("dnf") => FixSpec::Auto {
prompt: "Install git via dnf?", prompt: "Install git via dnf?",
program: "sudo", program: "sudo",
@@ -756,57 +928,121 @@ fn fix_spec_git(pm: Option<&str>) -> FixSpec {
} }
} }
// --------------------------------------------------------------------------- fn hint_avr_size_not_on_path(found_path: &Option<String>) -> String {
// Platform-aware install hints (one-liners for the diagnostics table) 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(),
fn hint_avr_size_not_on_path() -> &'static str { };
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
"installed but not on PATH (binary size reports will be skipped)" format!("installed but not on PATH ({})", location)
} else { } else {
"installed but not on PATH" format!("not on PATH ({})", location)
} }
} }
fn hint_cmake() -> &'static str { /// Search common locations for avr-size and return (on_path, found_path).
fn find_avr_size() -> (bool, Option<String>) {
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") { if cfg!(target_os = "windows") {
"install: winget install Kitware.CMake (or choco install cmake)" "install: winget install Kitware.CMake (or choco install cmake)".into()
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
"install: brew install cmake" "install: brew install cmake".into()
} else if is_freebsd() {
"install: sudo pkg install cmake".into()
} else { } else {
"install: sudo apt install cmake" match detect_package_manager() {
Some("dnf") => "install: sudo dnf install cmake".into(),
Some("pacman") => "install: sudo pacman -S cmake".into(),
_ => "install: sudo apt install cmake".into(),
}
} }
} }
fn hint_cpp_compiler() -> &'static str { fn hint_cpp_compiler() -> String {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
"install: choco install mingw (or open Developer Command Prompt for MSVC)" "install: choco install mingw (or open Developer Command Prompt for MSVC)".into()
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
"install: xcode-select --install" "install: xcode-select --install".into()
} else if is_freebsd() {
"clang++ is part of the FreeBSD base system -- check your PATH".into()
} else { } else {
"install: sudo apt install g++" match detect_package_manager() {
Some("dnf") => "install: sudo dnf install gcc-c++".into(),
Some("pacman") => "install: sudo pacman -S gcc".into(),
_ => "install: sudo apt install g++".into(),
}
} }
} }
fn hint_git() -> &'static str { fn hint_git() -> String {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
"install: winget install Git.Git (or https://git-scm.com)" "install: winget install Git.Git (or https://git-scm.com)".into()
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
"install: xcode-select --install (or brew install git)" "install: xcode-select --install (or brew install git)".into()
} else if is_freebsd() {
"install: sudo pkg install git".into()
} else { } else {
"install: sudo apt install git" match detect_package_manager() {
Some("dnf") => "install: sudo dnf install git".into(),
Some("pacman") => "install: sudo pacman -S git".into(),
_ => "install: sudo apt install git".into(),
}
}
}
fn is_freebsd() -> bool {
cfg!(target_os = "freebsd")
}
fn serial_group_name() -> &'static str {
if is_freebsd() {
"dialer"
} else {
"dialout"
}
}
fn serial_group_fix_command() -> String {
if is_freebsd() {
"sudo pw groupmod dialer -m $USER".to_string()
} else {
"sudo usermod -aG dialout $USER".to_string()
} }
} }
fn check_dialout() -> bool { fn check_dialout() -> bool {
#[cfg(unix)] #[cfg(unix)]
{ {
let output = std::process::Command::new("groups") let group = serial_group_name();
.output(); match std::process::Command::new("groups").output() {
match output {
Ok(out) => { Ok(out) => {
let groups = String::from_utf8_lossy(&out.stdout); let groups = String::from_utf8_lossy(&out.stdout);
groups.contains("dialout") groups.contains(group)
} }
Err(_) => false, Err(_) => false,
} }
@@ -814,6 +1050,49 @@ fn check_dialout() -> bool {
#[cfg(not(unix))] #[cfg(not(unix))]
{ {
true // Not applicable on Windows 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)
}

View File

@@ -38,8 +38,29 @@ toml_get() {
} }
toml_array() { toml_array() {
(grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \ # Handles both single-line: key = ["a", "b"]
| sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' # and multiline: key = [
# "a",
# "b",
# ]
awk -v key="$1" '
$0 ~ ("^" key " *= *\\[") {
collecting = 1
buf = $0
if (index($0, "]") > 0) { collecting = 0 }
}
collecting && NR > 1 { buf = buf " " $0 }
collecting && index($0, "]") > 0 && NR > 1 { collecting = 0 }
!collecting && buf != "" {
gsub(/.*\[/, "", buf)
gsub(/\].*/, "", buf)
gsub(/"/, "", buf)
gsub(/,/, " ", buf)
print buf
buf = ""
exit
}
' "$CONFIG" | tr -s " "
} }
toml_section_get() { toml_section_get() {

View File

@@ -24,6 +24,17 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEST_DIR="$SCRIPT_DIR/test" TEST_DIR="$SCRIPT_DIR/test"
BUILD_DIR="$TEST_DIR/build" BUILD_DIR="$TEST_DIR/build"
# Portable CPU count -- BSD make requires a number after -j, GNU make does not
cpu_count() {
if command -v nproc &>/dev/null; then
nproc
elif command -v sysctl &>/dev/null; then
sysctl -n hw.ncpu
else
echo 4
fi
}
# Color output # Color output
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m' RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'
@@ -67,6 +78,8 @@ if ! command -v cmake &>/dev/null; then
echo " Install:" >&2 echo " Install:" >&2
if [[ "$(uname)" == "Darwin" ]]; then if [[ "$(uname)" == "Darwin" ]]; then
echo " brew install cmake" >&2 echo " brew install cmake" >&2
elif [[ "$(uname)" == "FreeBSD" ]]; then
echo " sudo pkg install cmake" >&2
else else
echo " sudo apt install cmake (Debian/Ubuntu)" >&2 echo " sudo apt install cmake (Debian/Ubuntu)" >&2
echo " sudo dnf install cmake (Fedora)" >&2 echo " sudo dnf install cmake (Fedora)" >&2
@@ -102,7 +115,7 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
fi fi
info "Building tests..." info "Building tests..."
cmake --build "$BUILD_DIR" --parallel 2>&1 | \ cmake --build "$BUILD_DIR" --parallel "$(cpu_count)" 2>&1 | \
while IFS= read -r line; do echo " $line"; done while IFS= read -r line; do echo " $line"; done
echo "" echo ""

View File

@@ -17,6 +17,17 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build" BUILD_DIR="$SCRIPT_DIR/build"
# Portable CPU count -- BSD make requires a number after -j, GNU make does not
cpu_count() {
if command -v nproc &>/dev/null; then
nproc
elif command -v sysctl &>/dev/null; then
sysctl -n hw.ncpu
else
echo 4
fi
}
# Color output # Color output
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m' RED=$'\033[0;31m'; GRN=$'\033[0;32m'; CYN=$'\033[0;36m'
@@ -40,7 +51,7 @@ for arg in "$@"; do
esac esac
done done
command -v cmake &>/dev/null || die "cmake not found. Install: sudo apt install cmake" command -v cmake &>/dev/null || die "cmake not found. Install: pkg install cmake (FreeBSD), apt install cmake (Debian/Ubuntu), dnf install cmake (Fedora)"
command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found" command -v g++ &>/dev/null || command -v clang++ &>/dev/null || die "No C++ compiler found"
command -v git &>/dev/null || die "git not found (needed to fetch Google Test)" command -v git &>/dev/null || die "git not found (needed to fetch Google Test)"
@@ -55,7 +66,7 @@ if [[ ! -f "$BUILD_DIR/CMakeCache.txt" ]]; then
fi fi
info "Building tests..." info "Building tests..."
cmake --build "$BUILD_DIR" --parallel cmake --build "$BUILD_DIR" --parallel "$(cpu_count)"
echo "" echo ""
info "${BLD}Running tests...${RST}" info "${BLD}Running tests...${RST}"

View File

@@ -39,8 +39,29 @@ toml_get() {
} }
toml_array() { toml_array() {
(grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \ # Handles both single-line: key = ["a", "b"]
| sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' # and multiline: key = [
# "a",
# "b",
# ]
awk -v key="$1" '
$0 ~ ("^" key " *= *\\[") {
collecting = 1
buf = $0
if (index($0, "]") > 0) { collecting = 0 }
}
collecting && NR > 1 { buf = buf " " $0 }
collecting && index($0, "]") > 0 && NR > 1 { collecting = 0 }
!collecting && buf != "" {
gsub(/.*\[/, "", buf)
gsub(/\].*/, "", buf)
gsub(/"/, "", buf)
gsub(/,/, " ", buf)
print buf
buf = ""
exit
}
' "$CONFIG" | tr -s " "
} }
toml_section_get() { toml_section_get() {
@@ -195,7 +216,7 @@ if [[ -z "$PORT" ]]; then
PORT=$(arduino-cli board list 2>/dev/null \ PORT=$(arduino-cli board list 2>/dev/null \
| grep -i "serial" \ | grep -i "serial" \
| awk '{print $1}' \ | awk '{print $1}' \
| grep -E 'ttyUSB|ttyACM|COM' \ | grep -E 'ttyUSB|ttyACM|cuaU|COM' \
| head -1) | head -1)
if [[ -z "$PORT" ]]; then if [[ -z "$PORT" ]]; then