From b909da298e92e281ca74f0699d630b7900f7d62a Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Thu, 19 Feb 2026 10:12:33 -0600 Subject: [PATCH] Supporting multiple boards --- src/commands/board.rs | 505 +++++++++++++++++++++++++++ src/commands/doctor.rs | 386 +++++++++++++++++++- src/commands/mod.rs | 3 +- src/commands/new.rs | 10 +- src/commands/refresh.rs | 3 +- src/main.rs | 72 +++- src/project/config.rs | 175 +++++++++- src/templates/mod.rs | 9 +- templates/basic/_dot_anvil.toml.tmpl | 17 +- templates/basic/build.bat | 68 +++- templates/basic/build.sh | 54 ++- templates/basic/monitor.bat | 52 ++- templates/basic/monitor.sh | 36 +- templates/basic/upload.bat | 70 +++- templates/basic/upload.sh | 48 ++- tests/integration_test.rs | 140 +++++++- 16 files changed, 1554 insertions(+), 94 deletions(-) create mode 100644 src/commands/board.rs diff --git a/src/commands/board.rs b/src/commands/board.rs new file mode 100644 index 0000000..6ca76ea --- /dev/null +++ b/src/commands/board.rs @@ -0,0 +1,505 @@ +use anyhow::{Result, Context, bail}; +use colored::*; +use std::fs; + +use crate::board::presets; +use crate::project::config::{ProjectConfig, CONFIG_FILENAME}; + +/// Resolve a human-readable name for a board identifier by checking presets. +fn board_label(fqbn: &str) -> String { + for p in presets::PRESETS { + if p.fqbn == fqbn { + return p.description.to_string(); + } + } + fqbn.to_string() +} + +/// List all boards configured for this project. +pub fn list_boards(project_dir: Option<&str>) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + + println!( + "{}", + format!("Boards for: {}", config.project.name) + .bright_cyan() + .bold() + ); + println!(); + + let mut names: Vec<&String> = config.boards.keys().collect(); + names.sort(); + + if names.is_empty() { + println!( + " {}", + "No boards configured.".bright_black() + ); + } else { + for name in &names { + let profile = &config.boards[*name]; + let baud_str = match profile.baud { + Some(b) => format!("baud={}", b), + None => format!("baud={}", config.monitor.baud), + }; + let default_marker = if *name == &config.build.default { + " (default)" + } else { + "" + }; + println!( + " {:9} {}{} {}", + name.bright_white().bold(), + board_label(&profile.fqbn).bright_cyan(), + default_marker.green(), + baud_str.bright_black() + ); + } + if names.len() > 1 { + println!(); + if cfg!(target_os = "windows") { + println!( + " {}", + "Use: build.bat --board ".bright_black() + ); + } else { + println!( + " {}", + "Use: ./build.sh --board ".bright_black() + ); + } + } + } + + println!(); + println!("{}", "Next steps:".bright_yellow().bold()); + + println!( + " Add a board: {}", + "anvil board --add mega".bright_cyan() + ); + if config.boards.len() > 1 { + println!( + " Remove a board: {}", + format!( + "anvil board --remove {}", + config.boards.keys() + .find(|k| *k != &config.build.default) + .unwrap_or(&config.build.default) + ).bright_cyan() + ); + } + println!( + " Browse boards: {}", + "anvil board --listall".bright_cyan() + ); + println!(); + + Ok(()) +} + +/// Show all available boards in a single unified list. +pub fn listall_boards(search: Option<&str>) -> Result<()> { + println!( + "{}", + "Boards you can add:".bright_cyan().bold() + ); + println!(); + + // Filter presets by search term + let presets_to_show: Vec<_> = match search { + Some(term) => { + let lower = term.to_lowercase(); + presets::PRESETS.iter().filter(|p| { + p.name.contains(&lower) + || p.description.to_lowercase().contains(&lower) + || p.also_known_as.to_lowercase().contains(&lower) + }).collect() + } + None => presets::PRESETS.iter().collect(), + }; + + // Show presets + if !presets_to_show.is_empty() { + println!( + "{}", + "Common boards:".bright_cyan().bold() + ); + println!(); + + for p in &presets_to_show { + println!( + " {}", + p.name.bright_white().bold() + ); + let mut board_names = vec![p.description.to_string()]; + if !p.also_known_as.is_empty() { + for alias in p.also_known_as.split(", ") { + board_names.push(alias.to_string()); + } + } + println!( + " Works with: {}", + board_names.join(", ").bright_black() + ); + println!( + " Add: {}", + format!("anvil board --add {}", p.name).bright_cyan() + ); + println!(); + } + } + + // Full catalog from arduino-cli + let cli = crate::board::find_arduino_cli(); + let cli_path = match cli { + Some(p) => p, + None => { + if presets_to_show.is_empty() { + println!( + " {}", + "No boards match your search.".bright_black() + ); + } + println!( + "{}", + "Install arduino-cli to see more boards: anvil setup" + .bright_yellow() + ); + println!(); + return Ok(()); + } + }; + + let mut args = vec!["board", "listall"]; + if let Some(term) = search { + args.push(term); + } + + let output = std::process::Command::new(&cli_path) + .args(&args) + .output() + .context("Failed to run arduino-cli board listall")?; + + if !output.status.success() { + bail!("arduino-cli board listall failed"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + // Collect non-preset boards + let mut other_boards: Vec<(&str, &str)> = Vec::new(); + for line in lines.iter().skip(1) { + let fqbn = line.split_whitespace().last().unwrap_or(""); + if fqbn.is_empty() { + continue; + } + let board_name = line.trim_end_matches(fqbn).trim(); + if board_name.is_empty() { + continue; + } + let is_preset = presets::PRESETS.iter().any(|p| p.fqbn == fqbn); + if !is_preset { + other_boards.push((board_name, fqbn)); + } + } + + if !other_boards.is_empty() { + println!( + "{}", + "More boards:".bright_cyan().bold() + ); + println!(); + + for (board_name, fqbn) in &other_boards { + let suggested = suggest_short_name(board_name); + println!( + " {}", + board_name.bright_white() + ); + println!( + " Add: {}", + format!( + "anvil board --add {} --id {}", + suggested, fqbn + ).bright_cyan() + ); + } + + println!(); + } + + if presets_to_show.is_empty() && other_boards.is_empty() { + println!( + " {}", + "No boards match your search.".bright_black() + ); + println!(); + } + + if search.is_some() { + println!( + " {}", + "See all: anvil board --listall".bright_black() + ); + } else { + println!( + " {}", + "Search: anvil board --listall ".bright_black() + ); + } + println!( + " {}", + "Remove: anvil board --remove ".bright_black() + ); + if cfg!(target_os = "windows") { + println!( + " {}", + "Use: upload.bat --board ".bright_black() + ); + } else { + println!( + " {}", + "Use: ./upload.sh --board ".bright_black() + ); + } + println!(); + + Ok(()) +} + +/// Suggest a short project name from a board's display name. +fn suggest_short_name(board_name: &str) -> String { + let lower = board_name.to_lowercase(); + + let stripped = lower + .trim_start_matches("arduino ") + .trim_start_matches("adafruit ") + .trim_start_matches("sparkfun ") + .trim_start_matches("seeed "); + + let meaningful = if let Some(pos) = stripped.find(" or ") { + stripped[pos + 4..].trim() + } else { + stripped.trim() + }; + + let words: Vec<&str> = meaningful + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| { + !w.is_empty() + && !matches!(*w, "the" | "a" | "arduino" | "board") + }) + .collect(); + + if words.is_empty() { + let fallback: Vec<&str> = stripped + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| !w.is_empty() && *w != "arduino") + .take(2) + .collect(); + if fallback.is_empty() { + "board".to_string() + } else { + fallback.join("-") + } + } else if words.len() == 1 { + words[0].to_string() + } else { + words.iter().take(2).copied().collect::>().join("-") + } +} + +/// Add a board to .anvil.toml. +pub fn add_board( + name: &str, + id: Option<&str>, + baud: Option, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + + if config.boards.contains_key(name) { + bail!( + "Board '{}' already exists.\n \ + Remove it first: anvil board --remove {}", + name, name + ); + } + + // Resolve the board identifier: explicit --id > preset lookup > error + let resolved_id = match id { + Some(f) => f.to_string(), + None => { + match presets::find_preset(name) { + Some(preset) => { + preset.fqbn.to_string() + } + None => { + println!( + "{}", + format!( + "'{}' is not a recognized board name.", + name + ).bright_yellow() + ); + println!(); + println!( + " {}", + "Find your board:" + ); + println!( + " {}", + "anvil board --listall".bright_cyan() + ); + println!( + " {}", + format!("anvil board --listall {}", name).bright_cyan() + ); + bail!("Run anvil board --listall to find the right command."); + } + } + } + }; + + // Resolve baud: explicit > preset default > None (inherit) + let resolved_baud = match baud { + Some(b) => Some(b), + None => { + if let Some(preset) = presets::find_preset(name) { + if preset.baud != config.monitor.baud { + Some(preset.baud) + } else { + None + } + } else { + None + } + } + }; + + // Append to .anvil.toml as text to preserve formatting and comments + let config_path = project_path.join(CONFIG_FILENAME); + let mut content = fs::read_to_string(&config_path) + .context("Failed to read .anvil.toml")?; + + let mut section = format!("\n[boards.{}]\n", name); + section.push_str(&format!("fqbn = \"{}\"\n", resolved_id)); + if let Some(b) = resolved_baud { + section.push_str(&format!("baud = {}\n", b)); + } + + content.push_str(§ion); + fs::write(&config_path, content) + .context("Failed to write .anvil.toml")?; + + let label = board_label(&resolved_id); + println!( + "{} Added board: {} ({})", + "ok".green(), + name.bright_white().bold(), + label.bright_cyan() + ); + if let Some(b) = resolved_baud { + println!(" baud = {}", b.to_string().bright_cyan()); + } + println!(); + + if cfg!(target_os = "windows") { + println!( + " {}", + format!("Use: build.bat --board {}", name).bright_black() + ); + } else { + println!( + " {}", + format!("Use: ./build.sh --board {}", name).bright_black() + ); + } + println!(); + + Ok(()) +} + +/// Remove a board from .anvil.toml. +pub fn remove_board(name: &str, project_dir: Option<&str>) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + + if !config.boards.contains_key(name) { + let available: Vec<&String> = config.boards.keys().collect(); + if available.is_empty() { + bail!("No boards configured."); + } else { + bail!( + "No board '{}' found.\n Available: {}", + name, + available.iter().map(|s| s.as_str()).collect::>().join(", ") + ); + } + } + + // Don't allow removing the default board + if name == config.build.default { + bail!( + "Cannot remove '{}' because it is the default board.\n \ + Change the default in .anvil.toml first, or remove a different board.", + name + ); + } + + let config_path = project_path.join(CONFIG_FILENAME); + let content = fs::read_to_string(&config_path) + .context("Failed to read .anvil.toml")?; + + let section_header = format!("[boards.{}]", name); + let mut output = String::new(); + let mut skipping = false; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed == section_header { + skipping = true; + continue; + } + if skipping && trimmed.starts_with('[') { + skipping = false; + } + if skipping { + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.contains('=') { + continue; + } + skipping = false; + } + output.push_str(line); + output.push('\n'); + } + + while output.ends_with("\n\n\n") { + output.pop(); + } + + fs::write(&config_path, &output) + .context("Failed to write .anvil.toml")?; + + println!( + "{} Removed board: {}", + "ok".green(), + name.bright_white().bold() + ); + println!(); + + Ok(()) +} + +fn resolve_project_dir(project_dir: Option<&str>) -> Result { + let start = match project_dir { + Some(dir) => std::path::PathBuf::from(dir), + None => std::env::current_dir() + .context("Could not determine current directory")?, + }; + ProjectConfig::find_project_root(&start) +} \ No newline at end of file diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index e9eca79..e2fd5b4 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -1,5 +1,6 @@ use anyhow::Result; use colored::*; +use std::io::{self, Write}; use crate::board; @@ -22,7 +23,7 @@ impl SystemHealth { } } -pub fn run_diagnostics() -> Result<()> { +pub fn run_diagnostics(fix: bool) -> Result<()> { println!( "{}", "Checking system health...".bright_yellow().bold() @@ -40,6 +41,19 @@ pub fn run_diagnostics() -> Result<()> { .bright_green() .bold() ); + if fix { + // Check optional tools + let optional_missing = !health.cmake_ok + || !health.cpp_compiler_ok + || !health.git_ok; + if optional_missing { + println!(); + run_fix_optional(&health)?; + } + } + } else if fix { + println!(); + run_fix(&health)?; } else { println!( "{}", @@ -49,6 +63,11 @@ pub fn run_diagnostics() -> Result<()> { ); println!(); print_fix_instructions(&health); + println!( + " {}", + "Or run: anvil doctor --fix (to install missing tools automatically)" + .bright_cyan() + ); } println!(); @@ -155,7 +174,7 @@ fn print_diagnostics(health: &SystemHealth) { println!("{}", "Optional:".bright_yellow().bold()); println!(); - // avr-size -- installed as part of the avr core, not a separate step + // avr-size if health.avr_size_ok { println!(" {} avr-size (binary size reporting)", "ok".green()); } else if !health.avr_core_ok { @@ -165,9 +184,6 @@ fn print_diagnostics(health: &SystemHealth) { "included with arduino:avr core (no separate install)".yellow() ); } else { - // Core is installed but avr-size is not on PATH -- - // this can happen on Windows where the tool is buried - // inside the Arduino15 packages directory. println!( " {} avr-size {}", "na".yellow(), @@ -300,7 +316,6 @@ fn print_fix_instructions(health: &SystemHealth) { } if !health.arduino_cli_ok { - // They need to open a new terminal after installing arduino-cli println!( " {}. {}", step, @@ -353,6 +368,365 @@ fn print_fix_instructions(health: &SystemHealth) { } } +// ========================================================================== +// --fix: automated installation +// ========================================================================== + +/// Prompt the user for yes/no confirmation. +fn confirm(prompt: &str) -> bool { + print!("{} [Y/n] ", prompt); + io::stdout().flush().ok(); + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + return false; + } + let trimmed = input.trim().to_lowercase(); + trimmed.is_empty() || trimmed == "y" || trimmed == "yes" +} + +/// Run a command, streaming output to the terminal. +fn run_cmd(program: &str, args: &[&str]) -> bool { + println!( + " {} {} {}", + "$".bright_black(), + program.bright_cyan(), + args.join(" ").bright_cyan() + ); + std::process::Command::new(program) + .args(args) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Detect which package manager is available on the system. +fn detect_package_manager() -> Option<&'static str> { + if cfg!(target_os = "windows") { + if which::which("winget").is_ok() { + Some("winget") + } else if which::which("choco").is_ok() { + Some("choco") + } else { + None + } + } else if cfg!(target_os = "macos") { + if which::which("brew").is_ok() { + Some("brew") + } else { + None + } + } else { + // Linux + if which::which("apt").is_ok() { + Some("apt") + } else if which::which("dnf").is_ok() { + Some("dnf") + } else if which::which("pacman").is_ok() { + Some("pacman") + } else { + None + } + } +} + +/// Fix required items (arduino-cli, avr core). +fn run_fix(health: &SystemHealth) -> Result<()> { + let pm = detect_package_manager(); + + // -- arduino-cli -- + if !health.arduino_cli_ok { + println!( + "{}", + "arduino-cli is required but not installed.".bright_yellow() + ); + + match pm { + Some("winget") => { + if confirm("Install arduino-cli via winget?") { + if !run_cmd("winget", &["install", "--id", "ArduinoSA.CLI", "-e"]) { + println!("{} winget install failed. Try installing manually.", "FAIL".red()); + return Ok(()); + } + println!( + "{} arduino-cli installed. {} to update PATH.", + "ok".green(), + "Restart your terminal".bright_yellow() + ); + return Ok(()); + } + } + Some("choco") => { + if confirm("Install arduino-cli via Chocolatey?") { + if !run_cmd("choco", &["install", "arduino-cli", "-y"]) { + println!("{} choco install failed. Try installing manually.", "FAIL".red()); + return Ok(()); + } + println!( + "{} arduino-cli installed. {} to update PATH.", + "ok".green(), + "Restart your terminal".bright_yellow() + ); + return Ok(()); + } + } + Some("brew") => { + if confirm("Install arduino-cli via Homebrew?") { + if !run_cmd("brew", &["install", "arduino-cli"]) { + println!("{} brew install failed.", "FAIL".red()); + return Ok(()); + } + println!("{} arduino-cli installed.", "ok".green()); + } + } + Some("apt") => { + if confirm("Install arduino-cli via apt?") { + if !run_cmd("sudo", &["apt", "install", "-y", "arduino-cli"]) { + println!( + "{} apt install failed. Try the curl installer instead:", + "FAIL".red() + ); + println!( + " {}", + "curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh" + .bright_cyan() + ); + return Ok(()); + } + println!("{} arduino-cli installed.", "ok".green()); + } + } + Some("dnf") => { + println!( + " arduino-cli is not in dnf repos. Install manually:" + ); + println!( + " {}", + "curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh" + .bright_cyan() + ); + return Ok(()); + } + Some("pacman") => { + if confirm("Install arduino-cli via pacman (AUR)?") { + if !run_cmd("yay", &["-S", "--noconfirm", "arduino-cli"]) { + println!("{} AUR install failed. Try the curl installer.", "FAIL".red()); + return Ok(()); + } + println!("{} arduino-cli installed.", "ok".green()); + } + } + _ => { + println!( + " No supported package manager found. Install manually:" + ); + println!( + " {}", + "https://arduino.github.io/arduino-cli/installation/" + .bright_cyan() + ); + return Ok(()); + } + } + + // Re-check after install + if board::find_arduino_cli().is_none() { + println!(); + println!( + "{} arduino-cli not found after install. You may need to restart your terminal.", + "warn".yellow() + ); + return Ok(()); + } + } + + // -- AVR core -- + if !health.avr_core_ok { + if health.arduino_cli_ok || board::find_arduino_cli().is_some() { + println!(); + println!( + "{}", + "arduino:avr core is required but not installed.".bright_yellow() + ); + if confirm("Install arduino:avr core now?") { + let cli = board::find_arduino_cli() + .unwrap_or_else(|| std::path::PathBuf::from("arduino-cli")); + let cli_str = cli.to_string_lossy(); + if !run_cmd(&cli_str, &["core", "install", "arduino:avr"]) { + println!("{} Core installation failed.", "FAIL".red()); + return Ok(()); + } + println!("{} arduino:avr core installed.", "ok".green()); + } + } + } + + // Offer optional tools too + run_fix_optional(health)?; + + Ok(()) +} + +/// Fix optional items (cmake, C++ compiler, git). +fn run_fix_optional(health: &SystemHealth) -> Result<()> { + let pm = detect_package_manager(); + + 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)), + ("git", health.git_ok, fix_spec_git(pm)), + ]; + + let missing: Vec<_> = items.iter().filter(|(_, ok, _)| !ok).collect(); + if missing.is_empty() { + return Ok(()); + } + + println!(); + println!("{}", "Optional tools:".bright_yellow().bold()); + + for (name, _, spec) in &missing { + match spec { + FixSpec::Auto { prompt, program, args } => { + if confirm(prompt) { + if run_cmd(program, args) { + println!("{} {} installed.", "ok".green(), name); + } else { + println!("{} Failed to install {}.", "FAIL".red(), name); + } + } + } + FixSpec::Manual { message } => { + println!(" {}: {}", name, message.bright_black()); + } + } + } + + Ok(()) +} + +enum FixSpec { + Auto { + prompt: &'static str, + program: &'static str, + args: &'static [&'static str], + }, + Manual { + message: &'static str, + }, +} + +fn fix_spec_cmake(pm: Option<&str>) -> FixSpec { + match pm { + Some("winget") => FixSpec::Auto { + prompt: "Install cmake via winget?", + program: "winget", + args: &["install", "--id", "Kitware.CMake", "-e"], + }, + Some("choco") => FixSpec::Auto { + prompt: "Install cmake via Chocolatey?", + program: "choco", + args: &["install", "cmake", "-y"], + }, + Some("brew") => FixSpec::Auto { + prompt: "Install cmake via Homebrew?", + program: "brew", + args: &["install", "cmake"], + }, + Some("apt") => FixSpec::Auto { + prompt: "Install cmake via apt?", + program: "sudo", + args: &["apt", "install", "-y", "cmake"], + }, + Some("dnf") => FixSpec::Auto { + prompt: "Install cmake via dnf?", + program: "sudo", + args: &["dnf", "install", "-y", "cmake"], + }, + Some("pacman") => FixSpec::Auto { + prompt: "Install cmake via pacman?", + program: "sudo", + args: &["pacman", "-S", "--noconfirm", "cmake"], + }, + _ => FixSpec::Manual { + message: "install from https://cmake.org/download/", + }, + } +} + +fn fix_spec_cpp(pm: Option<&str>) -> FixSpec { + match pm { + Some("winget") => FixSpec::Auto { + prompt: "Install Visual Studio Build Tools via winget?", + program: "winget", + args: &["install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e"], + }, + Some("choco") => FixSpec::Auto { + prompt: "Install MinGW g++ via Chocolatey?", + program: "choco", + args: &["install", "mingw", "-y"], + }, + Some("brew") => FixSpec::Manual { + message: "run: xcode-select --install", + }, + Some("apt") => FixSpec::Auto { + prompt: "Install g++ via apt?", + program: "sudo", + args: &["apt", "install", "-y", "g++"], + }, + Some("dnf") => FixSpec::Auto { + prompt: "Install g++ via dnf?", + program: "sudo", + args: &["dnf", "install", "-y", "gcc-c++"], + }, + Some("pacman") => FixSpec::Auto { + prompt: "Install g++ via pacman?", + program: "sudo", + args: &["pacman", "-S", "--noconfirm", "gcc"], + }, + _ => FixSpec::Manual { + message: "install a C++ compiler (g++, clang++, or MSVC)", + }, + } +} + +fn fix_spec_git(pm: Option<&str>) -> FixSpec { + match pm { + Some("winget") => FixSpec::Auto { + prompt: "Install git via winget?", + program: "winget", + args: &["install", "--id", "Git.Git", "-e"], + }, + Some("choco") => FixSpec::Auto { + prompt: "Install git via Chocolatey?", + program: "choco", + args: &["install", "git", "-y"], + }, + Some("brew") => FixSpec::Auto { + prompt: "Install git via Homebrew?", + program: "brew", + args: &["install", "git"], + }, + Some("apt") => FixSpec::Auto { + prompt: "Install git via apt?", + program: "sudo", + args: &["apt", "install", "-y", "git"], + }, + Some("dnf") => FixSpec::Auto { + prompt: "Install git via dnf?", + program: "sudo", + args: &["dnf", "install", "-y", "git"], + }, + Some("pacman") => FixSpec::Auto { + prompt: "Install git via pacman?", + program: "sudo", + args: &["pacman", "-S", "--noconfirm", "git"], + }, + _ => FixSpec::Manual { + message: "install from https://git-scm.com", + }, + } +} + // --------------------------------------------------------------------------- // Platform-aware install hints (one-liners for the diagnostics table) // --------------------------------------------------------------------------- diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e808bac..03f420b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,4 +2,5 @@ pub mod new; pub mod doctor; pub mod setup; pub mod devices; -pub mod refresh; \ No newline at end of file +pub mod refresh; +pub mod board; \ No newline at end of file diff --git a/src/commands/new.rs b/src/commands/new.rs index 104e5d3..8bb1efa 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -48,7 +48,6 @@ pub fn list_boards() -> Result<()> { if !preset.also_known_as.is_empty() { println!(" Also: {}", preset.also_known_as.bright_black()); } - println!(" FQBN: {}", preset.fqbn.bright_black()); println!(); } @@ -59,11 +58,11 @@ pub fn list_boards() -> Result<()> { println!(); println!( " {}", - "For boards not listed here, create a project and edit the".bright_black() + "For boards not listed here, create a project and then:".bright_black() ); println!( " {}", - "fqbn value in .anvil.toml to any valid arduino-cli FQBN.".bright_black() + " anvil board --listall".bright_black() ); println!(); @@ -128,10 +127,6 @@ pub fn create_project( "{}", format!("Board: {} ({})", preset.name, preset.description).bright_cyan() ); - println!( - "{}", - format!("FQBN: {}", preset.fqbn).bright_black() - ); println!(); // Create project directory @@ -142,6 +137,7 @@ pub fn create_project( let context = TemplateContext { project_name: name.to_string(), anvil_version: ANVIL_VERSION.to_string(), + board_name: preset.name.to_string(), fqbn: preset.fqbn.to_string(), baud: preset.baud, }; diff --git a/src/commands/refresh.rs b/src/commands/refresh.rs index 0c07819..458aef3 100644 --- a/src/commands/refresh.rs +++ b/src/commands/refresh.rs @@ -47,7 +47,8 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> { let context = TemplateContext { project_name: config.project.name.clone(), anvil_version: ANVIL_VERSION.to_string(), - fqbn: config.build.fqbn.clone(), + board_name: config.build.default.clone(), + fqbn: config.default_fqbn().unwrap_or_else(|_| "arduino:avr:uno".to_string()), baud: config.monitor.baud, }; diff --git a/src/main.rs b/src/main.rs index d6e233e..c7f2d54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,11 @@ enum Commands { }, /// Check system health and diagnose issues - Doctor, + Doctor { + /// Automatically install missing tools + #[arg(long)] + fix: bool, + }, /// Install arduino-cli and required cores Setup, @@ -81,6 +85,36 @@ enum Commands { #[arg(long)] force: bool, }, + + /// Manage board profiles in .anvil.toml + Board { + /// Board name (e.g. mega, nano) + name: Option, + + /// Add a board to the project + #[arg(long, conflicts_with_all = ["remove", "listall"])] + add: bool, + + /// Remove a board from the project + #[arg(long, conflicts_with_all = ["add", "listall"])] + remove: bool, + + /// Browse all available boards + #[arg(long, conflicts_with_all = ["add", "remove"])] + listall: bool, + + /// Board identifier (from anvil board --listall) + #[arg(long, value_name = "ID")] + id: Option, + + /// Baud rate override + #[arg(long, value_name = "RATE")] + baud: Option, + + /// Path to project directory (defaults to current directory) + #[arg(long, short = 'd', value_name = "DIR")] + dir: Option, + }, } fn main() -> Result<()> { @@ -113,8 +147,8 @@ fn main() -> Result<()> { ); } } - Commands::Doctor => { - commands::doctor::run_diagnostics() + Commands::Doctor { fix } => { + commands::doctor::run_diagnostics(fix) } Commands::Setup => { commands::setup::run_setup() @@ -143,6 +177,38 @@ fn main() -> Result<()> { force, ) } + Commands::Board { name, add, remove, listall, id, baud, dir } => { + if listall { + commands::board::listall_boards(name.as_deref()) + } else if add { + let board_name = name.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Board name required.\n\ + Usage: anvil board --add mega\n\ + Browse available boards: anvil board --listall" + ) + })?; + commands::board::add_board( + board_name, + id.as_deref(), + baud, + dir.as_deref(), + ) + } else if remove { + let board_name = name.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Board name required.\n\ + Usage: anvil board --remove mega" + ) + })?; + commands::board::remove_board( + board_name, + dir.as_deref(), + ) + } else { + commands::board::list_boards(dir.as_deref()) + } + } } } diff --git a/src/project/config.rs b/src/project/config.rs index 8691edf..69832fa 100644 --- a/src/project/config.rs +++ b/src/project/config.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::fs; use anyhow::{Result, Context, bail}; @@ -12,6 +13,8 @@ pub struct ProjectConfig { pub project: ProjectMeta, pub build: BuildConfig, pub monitor: MonitorConfig, + #[serde(default)] + pub boards: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -22,10 +25,15 @@ pub struct ProjectMeta { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct BuildConfig { - pub fqbn: String, + /// Name of the default board from [boards.*] + #[serde(default)] + pub default: String, pub warnings: String, pub include_dirs: Vec, pub extra_flags: Vec, + /// Legacy: FQBN used to live here directly. Now lives in [boards.*]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fqbn: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -35,16 +43,30 @@ pub struct MonitorConfig { pub port: Option, } +/// A named board with its FQBN and optional baud override. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BoardProfile { + pub fqbn: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baud: Option, +} + impl ProjectConfig { /// Create a new project config with sensible defaults. pub fn new(name: &str) -> Self { + let mut boards = HashMap::new(); + boards.insert("uno".to_string(), BoardProfile { + fqbn: "arduino:avr:uno".to_string(), + baud: None, + }); Self { project: ProjectMeta { name: name.to_string(), anvil_version: ANVIL_VERSION.to_string(), }, build: BuildConfig { - fqbn: "arduino:avr:uno".to_string(), + default: "uno".to_string(), + fqbn: None, warnings: "more".to_string(), include_dirs: vec!["lib/hal".to_string(), "lib/app".to_string()], extra_flags: vec!["-Werror".to_string()], @@ -53,6 +75,61 @@ impl ProjectConfig { baud: 115200, port: None, }, + boards, + } + } + + /// Create a new project config with a specific board preset. + pub fn new_with_board(name: &str, board_name: &str, fqbn: &str, baud: u32) -> Self { + let mut config = Self::new(name); + config.build.default = board_name.to_string(); + config.boards.clear(); + config.boards.insert(board_name.to_string(), BoardProfile { + fqbn: fqbn.to_string(), + baud: None, + }); + config.monitor.baud = baud; + config + } + + /// Get the default board's FQBN and baud. + pub fn default_board(&self) -> Result<(String, u32)> { + self.resolve_board(&self.build.default) + } + + /// Get the default board's FQBN. + pub fn default_fqbn(&self) -> Result { + let (fqbn, _) = self.default_board()?; + Ok(fqbn) + } + + /// Resolve the FQBN and baud for a named board. + /// Returns (fqbn, baud). + pub fn resolve_board(&self, board_name: &str) -> Result<(String, u32)> { + match self.boards.get(board_name) { + Some(profile) => { + let baud = profile.baud.unwrap_or(self.monitor.baud); + Ok((profile.fqbn.clone(), baud)) + } + None => { + let available: Vec<&String> = self.boards.keys().collect(); + if available.is_empty() { + bail!( + "No board '{}' found in .anvil.toml.\n \ + No boards are defined. Add one: anvil board --add {}", + board_name, board_name + ); + } else { + bail!( + "No board '{}' found in .anvil.toml.\n \ + Available: {}\n \ + Add it: anvil board --add {}", + board_name, + available.iter().map(|s| s.as_str()).collect::>().join(", "), + board_name + ); + } + } } } @@ -68,8 +145,24 @@ impl ProjectConfig { } let contents = fs::read_to_string(&config_path) .context(format!("Failed to read {}", config_path.display()))?; - let config: ProjectConfig = toml::from_str(&contents) + let mut config: ProjectConfig = toml::from_str(&contents) .context(format!("Failed to parse {}", config_path.display()))?; + + // Migrate old format: fqbn in [build] -> [boards.X] + default + if config.build.default.is_empty() { + if let Some(legacy_fqbn) = config.build.fqbn.take() { + let board_name = crate::board::presets::PRESETS.iter() + .find(|p| p.fqbn == legacy_fqbn) + .map(|p| p.name.to_string()) + .unwrap_or_else(|| "default".to_string()); + config.boards.entry(board_name.clone()).or_insert(BoardProfile { + fqbn: legacy_fqbn, + baud: None, + }); + config.build.default = board_name; + } + } + Ok(config) } @@ -155,9 +248,11 @@ mod tests { fn test_new_config_defaults() { let config = ProjectConfig::new("test_project"); assert_eq!(config.project.name, "test_project"); - assert_eq!(config.build.fqbn, "arduino:avr:uno"); + assert_eq!(config.build.default, "uno"); assert_eq!(config.monitor.baud, 115200); assert!(config.build.include_dirs.contains(&"lib/hal".to_string())); + assert!(config.boards.contains_key("uno")); + assert_eq!(config.boards["uno"].fqbn, "arduino:avr:uno"); } #[test] @@ -168,8 +263,20 @@ mod tests { let loaded = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(loaded.project.name, "roundtrip"); - assert_eq!(loaded.build.fqbn, config.build.fqbn); + assert_eq!(loaded.build.default, "uno"); assert_eq!(loaded.monitor.baud, config.monitor.baud); + assert!(loaded.boards.contains_key("uno")); + } + + #[test] + fn test_new_with_board() { + let config = ProjectConfig::new_with_board( + "test", "mega", "arduino:avr:mega:cpu=atmega2560", 115200 + ); + assert_eq!(config.build.default, "mega"); + assert!(config.boards.contains_key("mega")); + assert_eq!(config.boards["mega"].fqbn, "arduino:avr:mega:cpu=atmega2560"); + assert!(!config.boards.contains_key("uno")); } #[test] @@ -178,7 +285,6 @@ mod tests { let config = ProjectConfig::new("finder"); config.save(tmp.path()).unwrap(); - // Create a subdirectory and search from there let sub = tmp.path().join("sketch").join("deep"); fs::create_dir_all(&sub).unwrap(); @@ -219,4 +325,61 @@ mod tests { assert!(flags.contains("-Werror")); assert!(flags.contains("-I")); } + + #[test] + fn test_default_board() { + let config = ProjectConfig::new("test"); + let (fqbn, baud) = config.default_board().unwrap(); + assert_eq!(fqbn, "arduino:avr:uno"); + assert_eq!(baud, 115200); + } + + #[test] + fn test_resolve_board_named() { + let mut config = ProjectConfig::new("test"); + config.boards.insert("mega".to_string(), BoardProfile { + fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), + baud: Some(9600), + }); + + let (fqbn, baud) = config.resolve_board("mega").unwrap(); + assert_eq!(fqbn, "arduino:avr:mega:cpu=atmega2560"); + assert_eq!(baud, 9600); + } + + #[test] + fn test_resolve_board_inherits_baud() { + let mut config = ProjectConfig::new("test"); + config.boards.insert("nano".to_string(), BoardProfile { + fqbn: "arduino:avr:nano:cpu=atmega328".to_string(), + baud: None, + }); + + let (fqbn, baud) = config.resolve_board("nano").unwrap(); + assert_eq!(fqbn, "arduino:avr:nano:cpu=atmega328"); + assert_eq!(baud, 115200); + } + + #[test] + fn test_resolve_board_unknown() { + let config = ProjectConfig::new("test"); + assert!(config.resolve_board("esp32").is_err()); + } + + #[test] + fn test_board_roundtrip() { + let tmp = TempDir::new().unwrap(); + let mut config = ProjectConfig::new("multi"); + config.boards.insert("mega".to_string(), BoardProfile { + fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), + baud: Some(57600), + }); + config.save(tmp.path()).unwrap(); + + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + assert!(loaded.boards.contains_key("mega")); + let mega = &loaded.boards["mega"]; + assert_eq!(mega.fqbn, "arduino:avr:mega:cpu=atmega2560"); + assert_eq!(mega.baud, Some(57600)); + } } \ No newline at end of file diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 036827a..7952ac7 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -10,6 +10,7 @@ static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic") pub struct TemplateContext { pub project_name: String, pub anvil_version: String, + pub board_name: String, pub fqbn: String, pub baud: u32, } @@ -142,6 +143,7 @@ fn substitute_variables(text: &str, context: &TemplateContext) -> String { text.replace("{{PROJECT_NAME}}", &context.project_name) .replace("{{ANVIL_VERSION}}", &context.anvil_version) .replace("{{ANVIL_VERSION_CURRENT}}", ANVIL_VERSION) + .replace("{{BOARD_NAME}}", &context.board_name) .replace("{{FQBN}}", &context.fqbn) .replace("{{BAUD}}", &context.baud.to_string()) } @@ -176,14 +178,15 @@ mod tests { let ctx = TemplateContext { project_name: "my_project".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "mega".to_string(), fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), baud: 9600, }; - let input = "Name: {{PROJECT_NAME}}, Version: {{ANVIL_VERSION}}, FQBN: {{FQBN}}, Baud: {{BAUD}}"; + let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, FQBN: {{FQBN}}, Baud: {{BAUD}}"; let output = substitute_variables(input, &ctx); assert_eq!( output, - "Name: my_project, Version: 1.0.0, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600" + "Name: my_project, Board: mega, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600" ); } @@ -199,6 +202,7 @@ mod tests { let ctx = TemplateContext { project_name: "test_proj".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -231,6 +235,7 @@ mod tests { let ctx = TemplateContext { project_name: "my_sensor".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; diff --git a/templates/basic/_dot_anvil.toml.tmpl b/templates/basic/_dot_anvil.toml.tmpl index 98324c0..e942310 100644 --- a/templates/basic/_dot_anvil.toml.tmpl +++ b/templates/basic/_dot_anvil.toml.tmpl @@ -3,10 +3,25 @@ name = "{{PROJECT_NAME}}" anvil_version = "{{ANVIL_VERSION}}" [build] -fqbn = "{{FQBN}}" +default = "{{BOARD_NAME}}" warnings = "more" include_dirs = ["lib/hal", "lib/app"] extra_flags = ["-Werror"] [monitor] baud = {{BAUD}} + +[boards.{{BOARD_NAME}}] +fqbn = "{{FQBN}}" + +# -- Additional boards ----------------------------------------------------- +# Add more boards here. Use --board NAME with any script: +# upload --board mega +# build.bat --board nano +# +# [boards.mega] +# fqbn = "arduino:avr:mega:cpu=atmega2560" +# +# [boards.nano] +# fqbn = "arduino:avr:nano:cpu=atmega328" +# baud = 9600 diff --git a/templates/basic/build.bat b/templates/basic/build.bat index 26db5f3..aab7814 100644 --- a/templates/basic/build.bat +++ b/templates/basic/build.bat @@ -6,20 +6,21 @@ setlocal enabledelayedexpansion :: Reads all settings from .anvil.toml. No Anvil binary required. :: :: Usage: -:: build.bat Compile (verify only) -:: build.bat --clean Delete build cache first -:: build.bat --verbose Show full compiler output +:: build.bat Compile (verify only) +:: build.bat --board mega Use a named board +:: build.bat --clean Delete build cache first +:: build.bat --verbose Show full compiler output set "SCRIPT_DIR=%~dp0" -set "CONFIG=%SCRIPT_DIR%.anvil.toml" +set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" +set "CONFIG=%SCRIPT_DIR%\.anvil.toml" if not exist "%CONFIG%" ( echo FAIL: No .anvil.toml found in %SCRIPT_DIR% exit /b 1 ) -:: -- Parse .anvil.toml ---------------------------------------------------- -:: Read file directly, skip comments and section headers +:: -- Parse .anvil.toml (flat keys) ---------------------------------------- for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( set "_K=%%a" if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" ( @@ -30,7 +31,7 @@ for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( set "_V=!_V:"=!" ) if "!_K!"=="name" set "SKETCH_NAME=!_V!" - if "!_K!"=="fqbn" set "FQBN=!_V!" + if "!_K!"=="default" set "DEFAULT_BOARD=!_V!" if "!_K!"=="warnings" set "WARNINGS=!_V!" ) ) @@ -40,15 +41,17 @@ if "%SKETCH_NAME%"=="" ( exit /b 1 ) -set "SKETCH_DIR=%SCRIPT_DIR%%SKETCH_NAME%" -set "BUILD_DIR=%SCRIPT_DIR%.build" +set "SKETCH_DIR=%SCRIPT_DIR%\%SKETCH_NAME%" +set "BUILD_DIR=%SCRIPT_DIR%\.build" :: -- Parse arguments ------------------------------------------------------ set "DO_CLEAN=0" set "VERBOSE=" +set "BOARD_NAME=" :parse_args if "%~1"=="" goto done_args +if "%~1"=="--board" set "BOARD_NAME=%~2" & shift & shift & goto parse_args if "%~1"=="--clean" set "DO_CLEAN=1" & shift & goto parse_args if "%~1"=="--verbose" set "VERBOSE=--verbose" & shift & goto parse_args if "%~1"=="--help" goto show_help @@ -57,12 +60,51 @@ echo FAIL: Unknown option: %~1 exit /b 1 :show_help -echo Usage: build.bat [--clean] [--verbose] +echo Usage: build.bat [--board NAME] [--clean] [--verbose] echo Compiles the sketch. Settings from .anvil.toml. +echo --board NAME selects a board from [boards.NAME]. exit /b 0 :done_args +:: -- Resolve board -------------------------------------------------------- +if "%BOARD_NAME%"=="" set "BOARD_NAME=%DEFAULT_BOARD%" + +set "BOARD_SECTION=[boards.%BOARD_NAME%]" +set "IN_SECTION=0" +set "FQBN=" +for /f "usebackq tokens=*" %%L in ("%CONFIG%") do ( + set "_LINE=%%L" + if "!_LINE!"=="!BOARD_SECTION!" ( + set "IN_SECTION=1" + ) else if "!IN_SECTION!"=="1" ( + if "!_LINE:~0,1!"=="[" ( + set "IN_SECTION=0" + ) else if not "!_LINE:~0,1!"=="#" ( + for /f "tokens=1,* delims==" %%a in ("!_LINE!") do ( + set "_BK=%%a" + set "_BK=!_BK: =!" + set "_BV=%%b" + if defined _BV ( + set "_BV=!_BV: =!" + set "_BV=!_BV:"=!" + ) + if "!_BK!"=="fqbn" set "FQBN=!_BV!" + ) + ) + ) +) + +if "!FQBN!"=="" ( + echo FAIL: No board '%BOARD_NAME%' in .anvil.toml. + echo Add it: anvil board --add %BOARD_NAME% + exit /b 1 +) + +if not "%BOARD_NAME%"=="%DEFAULT_BOARD%" ( + echo ok Using board: %BOARD_NAME% -- %FQBN% +) + :: -- Preflight ------------------------------------------------------------ where arduino-cli >nul 2>nul if errorlevel 1 ( @@ -86,8 +128,8 @@ if "%DO_CLEAN%"=="1" ( :: -- Build include flags -------------------------------------------------- set "BUILD_FLAGS=" for %%d in (lib\hal lib\app) do ( - if exist "%SCRIPT_DIR%%%d" ( - set "BUILD_FLAGS=!BUILD_FLAGS! -I%SCRIPT_DIR%%%d" + if exist "%SCRIPT_DIR%\%%d" ( + set "BUILD_FLAGS=!BUILD_FLAGS! -I%SCRIPT_DIR%\%%d" ) ) set "BUILD_FLAGS=!BUILD_FLAGS! -Werror" @@ -100,7 +142,7 @@ echo. if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%" -arduino-cli compile --fqbn %FQBN% --build-path "%BUILD_DIR%" --warnings %WARNINGS% --build-property "build.extra_flags=%BUILD_FLAGS%" %VERBOSE% "%SKETCH_DIR%" +arduino-cli compile --fqbn %FQBN% --build-path "%BUILD_DIR%" --warnings %WARNINGS% --build-property "compiler.cpp.extra_flags=%BUILD_FLAGS%" --build-property "compiler.c.extra_flags=%BUILD_FLAGS%" %VERBOSE% "%SKETCH_DIR%" if errorlevel 1 ( echo. echo FAIL: Compilation failed. diff --git a/templates/basic/build.sh b/templates/basic/build.sh index e796bd5..f940bee 100644 --- a/templates/basic/build.sh +++ b/templates/basic/build.sh @@ -6,6 +6,7 @@ # # Usage: # ./build.sh Compile (verify only) +# ./build.sh --board mega Use a named board # ./build.sh --clean Delete build cache first # ./build.sh --verbose Show full compiler output # @@ -32,27 +33,34 @@ die() { echo "${RED}FAIL${RST} $*" >&2; exit 1; } # -- Parse .anvil.toml ----------------------------------------------------- [[ -f "$CONFIG" ]] || die "No .anvil.toml found in $SCRIPT_DIR" -# Extract a simple string value: toml_get "key" -# Searches the whole file; for sectioned keys, grep is specific enough -# given our small, flat schema. toml_get() { (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' } -# Extract a TOML array as space-separated values: toml_array "key" toml_array() { (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \ | sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' } +toml_section_get() { + local section="$1" key="$2" + awk -v section="[$section]" -v key="$key" ' + $0 == section { found=1; next } + /^\[/ { found=0 } + found && $1 == key && /=/ { + sub(/^[^=]*= *"?/, ""); sub(/"? *$/, ""); print; exit + } + ' "$CONFIG" +} + SKETCH_NAME="$(toml_get 'name')" -FQBN="$(toml_get 'fqbn')" +DEFAULT_BOARD="$(toml_get 'default')" WARNINGS="$(toml_get 'warnings')" INCLUDE_DIRS="$(toml_array 'include_dirs')" EXTRA_FLAGS="$(toml_array 'extra_flags')" -[[ -n "$SKETCH_NAME" ]] || die "Could not read project name from .anvil.toml" -[[ -n "$FQBN" ]] || die "Could not read fqbn from .anvil.toml" +[[ -n "$SKETCH_NAME" ]] || die "Could not read project name from .anvil.toml" +[[ -n "$DEFAULT_BOARD" ]] || die "Could not read default board from .anvil.toml" SKETCH_DIR="$SCRIPT_DIR/$SKETCH_NAME" BUILD_DIR="$SCRIPT_DIR/.build" @@ -60,20 +68,35 @@ BUILD_DIR="$SCRIPT_DIR/.build" # -- Parse arguments ------------------------------------------------------- DO_CLEAN=0 VERBOSE="" +BOARD_NAME="" -for arg in "$@"; do - case "$arg" in - --clean) DO_CLEAN=1 ;; - --verbose) VERBOSE="--verbose" ;; +while [[ $# -gt 0 ]]; do + case "$1" in + --board) BOARD_NAME="$2"; shift 2 ;; + --clean) DO_CLEAN=1; shift ;; + --verbose) VERBOSE="--verbose"; shift ;; -h|--help) - echo "Usage: ./build.sh [--clean] [--verbose]" + echo "Usage: ./build.sh [--board NAME] [--clean] [--verbose]" echo " Compiles the sketch. Settings from .anvil.toml." + echo " --board NAME selects a board from [boards.NAME]." exit 0 ;; - *) die "Unknown option: $arg" ;; + *) die "Unknown option: $1" ;; esac done +# -- Resolve board --------------------------------------------------------- +ACTIVE_BOARD="${BOARD_NAME:-$DEFAULT_BOARD}" +FQBN="$(toml_section_get "boards.$ACTIVE_BOARD" "fqbn")" + +if [[ -z "$FQBN" ]]; then + die "No board '$ACTIVE_BOARD' in .anvil.toml.\n Add it: anvil board --add $ACTIVE_BOARD" +fi + +if [[ -n "$BOARD_NAME" ]]; then + ok "Using board: $BOARD_NAME ($FQBN)" +fi + # -- Preflight ------------------------------------------------------------- command -v arduino-cli &>/dev/null \ || die "arduino-cli not found in PATH. Install it first." @@ -121,7 +144,8 @@ COMPILE_ARGS=( ) if [[ -n "$BUILD_FLAGS" ]]; then - COMPILE_ARGS+=(--build-property "build.extra_flags=$BUILD_FLAGS") + COMPILE_ARGS+=(--build-property "compiler.cpp.extra_flags=$BUILD_FLAGS") + COMPILE_ARGS+=(--build-property "compiler.c.extra_flags=$BUILD_FLAGS") fi if [[ -n "$VERBOSE" ]]; then @@ -142,4 +166,4 @@ if [[ -f "$ELF" ]] && command -v avr-size &>/dev/null; then avr-size --mcu=atmega328p -C "$ELF" fi -echo "" +echo "" \ No newline at end of file diff --git a/templates/basic/monitor.bat b/templates/basic/monitor.bat index 19b257f..a1ee10a 100644 --- a/templates/basic/monitor.bat +++ b/templates/basic/monitor.bat @@ -9,17 +9,19 @@ setlocal enabledelayedexpansion :: monitor.bat Open monitor (auto-detect port) :: monitor.bat -p COM3 Specify port :: monitor.bat -b 9600 Override baud rate +:: monitor.bat --board mega Use baud from a named board set "SCRIPT_DIR=%~dp0" -set "CONFIG=%SCRIPT_DIR%.anvil.toml" -set "LOCAL_CONFIG=%SCRIPT_DIR%.anvil.local" +set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" +set "CONFIG=%SCRIPT_DIR%\.anvil.toml" +set "LOCAL_CONFIG=%SCRIPT_DIR%\.anvil.local" if not exist "%CONFIG%" ( echo FAIL: No .anvil.toml found in %SCRIPT_DIR% exit /b 1 ) -:: -- Parse .anvil.toml ---------------------------------------------------- +:: -- Parse .anvil.toml (flat keys) ---------------------------------------- for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( set "_K=%%a" if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" ( @@ -29,11 +31,12 @@ for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( set "_V=!_V: =!" set "_V=!_V:"=!" ) + if "!_K!"=="default" set "DEFAULT_BOARD=!_V!" if "!_K!"=="baud" set "BAUD=!_V!" ) ) -:: -- Parse .anvil.local (machine-specific, not in git) -------------------- +:: -- Parse .anvil.local --------------------------------------------------- set "LOCAL_PORT=" set "LOCAL_VID_PID=" if exist "%LOCAL_CONFIG%" ( @@ -56,6 +59,7 @@ if "%BAUD%"=="" set "BAUD=115200" :: -- Parse arguments ------------------------------------------------------ set "PORT=" +set "BOARD_NAME=" :parse_args if "%~1"=="" goto done_args @@ -63,18 +67,54 @@ if "%~1"=="-p" set "PORT=%~2" & shift & shift & goto parse_args if "%~1"=="--port" set "PORT=%~2" & shift & shift & goto parse_args if "%~1"=="-b" set "BAUD=%~2" & shift & shift & goto parse_args if "%~1"=="--baud" set "BAUD=%~2" & shift & shift & goto parse_args +if "%~1"=="--board" set "BOARD_NAME=%~2" & shift & shift & goto parse_args if "%~1"=="--help" goto show_help if "%~1"=="-h" goto show_help echo FAIL: Unknown option: %~1 exit /b 1 :show_help -echo Usage: monitor.bat [-p PORT] [-b BAUD] +echo Usage: monitor.bat [-p PORT] [-b BAUD] [--board NAME] echo Opens serial monitor. Baud rate from .anvil.toml. +echo --board NAME selects a board from [boards.NAME]. exit /b 0 :done_args +:: -- Resolve board -------------------------------------------------------- +if "%BOARD_NAME%"=="" set "BOARD_NAME=%DEFAULT_BOARD%" + +set "BOARD_SECTION=[boards.%BOARD_NAME%]" +set "IN_SECTION=0" +set "BOARD_BAUD=" +for /f "usebackq tokens=*" %%L in ("%CONFIG%") do ( + set "_LINE=%%L" + if "!_LINE!"=="!BOARD_SECTION!" ( + set "IN_SECTION=1" + ) else if "!IN_SECTION!"=="1" ( + if "!_LINE:~0,1!"=="[" ( + set "IN_SECTION=0" + ) else if not "!_LINE:~0,1!"=="#" ( + for /f "tokens=1,* delims==" %%a in ("!_LINE!") do ( + set "_BK=%%a" + set "_BK=!_BK: =!" + set "_BV=%%b" + if defined _BV ( + set "_BV=!_BV: =!" + set "_BV=!_BV:"=!" + ) + if "!_BK!"=="baud" set "BOARD_BAUD=!_BV!" + ) + ) + ) +) + +if not "!BOARD_BAUD!"=="" set "BAUD=!BOARD_BAUD!" + +if not "%BOARD_NAME%"=="%DEFAULT_BOARD%" ( + echo ok Using board: %BOARD_NAME% -- baud: !BAUD! +) + :: -- Preflight ------------------------------------------------------------ where arduino-cli >nul 2>nul if errorlevel 1 ( @@ -85,7 +125,7 @@ if errorlevel 1 ( :: -- Resolve port --------------------------------------------------------- :: Priority: -p flag > VID:PID resolve > saved port > auto-detect if "%PORT%"=="" ( - for /f "delims=" %%p in ('powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%_detect_port.ps1" -VidPid "%LOCAL_VID_PID%" -SavedPort "%LOCAL_PORT%"') do ( + for /f "delims=" %%p in ('powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%\_detect_port.ps1" -VidPid "%LOCAL_VID_PID%" -SavedPort "%LOCAL_PORT%"') do ( if "!PORT!"=="" set "PORT=%%p" ) if "!PORT!"=="" ( diff --git a/templates/basic/monitor.sh b/templates/basic/monitor.sh index ebcc2bd..6660bf2 100644 --- a/templates/basic/monitor.sh +++ b/templates/basic/monitor.sh @@ -8,6 +8,7 @@ # ./monitor.sh Auto-detect port # ./monitor.sh -p /dev/ttyUSB0 Specify port # ./monitor.sh -b 9600 Override baud rate +# ./monitor.sh --board mega Use baud from a named board # ./monitor.sh --watch Reconnect after reset/replug # # Prerequisites: arduino-cli in PATH @@ -25,6 +26,7 @@ else RED=''; GRN=''; YLW=''; CYN=''; BLD=''; RST='' fi +ok() { echo "${GRN}ok${RST} $*"; } warn() { echo "${YLW}warn${RST} $*"; } die() { echo "${RED}FAIL${RST} $*" >&2; exit 1; } @@ -35,6 +37,18 @@ toml_get() { (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' } +toml_section_get() { + local section="$1" key="$2" + awk -v section="[$section]" -v key="$key" ' + $0 == section { found=1; next } + /^\[/ { found=0 } + found && $1 == key && /=/ { + sub(/^[^=]*= *"?/, ""); sub(/"? *$/, ""); print; exit + } + ' "$CONFIG" +} + +DEFAULT_BOARD="$(toml_get 'default')" BAUD="$(toml_get 'baud')" BAUD="${BAUD:-115200}" LOCAL_CONFIG="$SCRIPT_DIR/.anvil.local" @@ -48,28 +62,41 @@ fi # -- Parse arguments ------------------------------------------------------- PORT="" DO_WATCH=0 +BOARD_NAME="" while [[ $# -gt 0 ]]; do case "$1" in -p|--port) PORT="$2"; shift 2 ;; -b|--baud) BAUD="$2"; shift 2 ;; + --board) BOARD_NAME="$2"; shift 2 ;; --watch) DO_WATCH=1; shift ;; -h|--help) - echo "Usage: ./monitor.sh [-p PORT] [-b BAUD] [--watch]" + echo "Usage: ./monitor.sh [-p PORT] [-b BAUD] [--board NAME] [--watch]" echo " Opens serial monitor. Baud rate from .anvil.toml." + echo " --board NAME selects a board from [boards.NAME]." exit 0 ;; *) die "Unknown option: $1" ;; esac done +# -- Resolve board --------------------------------------------------------- +ACTIVE_BOARD="${BOARD_NAME:-$DEFAULT_BOARD}" +BOARD_BAUD="$(toml_section_get "boards.$ACTIVE_BOARD" "baud")" +if [[ -n "$BOARD_BAUD" ]]; then + BAUD="$BOARD_BAUD" +fi + +if [[ -n "$BOARD_NAME" ]]; then + ok "Using board: $BOARD_NAME (baud: $BAUD)" +fi + # -- Preflight ------------------------------------------------------------- command -v arduino-cli &>/dev/null \ || die "arduino-cli not found in PATH." # -- Auto-detect port ------------------------------------------------------ auto_detect() { - # Prefer ttyUSB/ttyACM (real USB devices) over ttyS (hardware UART) local port port=$(arduino-cli board list 2>/dev/null \ | grep -i "serial" \ @@ -77,7 +104,6 @@ auto_detect() { | grep -E 'ttyUSB|ttyACM|COM' \ | head -1) - # Fallback: any serial port if [[ -z "$port" ]]; then port=$(arduino-cli board list 2>/dev/null \ | grep -i "serial" \ @@ -88,7 +114,6 @@ auto_detect() { echo "$port" } -# resolve_vid_pid VID:PID -- search arduino-cli JSON for matching device resolve_vid_pid() { local target_vid target_pid json target_vid="$(echo "$1" | cut -d: -f1 | tr '[:upper:]' '[:lower:]')" @@ -113,7 +138,6 @@ except: pass } if [[ -z "$PORT" ]]; then - # Try VID:PID resolution first if [[ -n "$LOCAL_VID_PID" ]]; then PORT="$(resolve_vid_pid "$LOCAL_VID_PID")" if [[ -n "$PORT" ]]; then @@ -125,13 +149,11 @@ if [[ -z "$PORT" ]]; then fi fi - # Fall back to saved port if [[ -z "$PORT" ]] && [[ -n "$LOCAL_PORT" ]]; then PORT="$LOCAL_PORT" warn "Using port $PORT (from .anvil.local)" fi - # Fall back to auto-detect if [[ -z "$PORT" ]]; then PORT="$(auto_detect)" diff --git a/templates/basic/upload.bat b/templates/basic/upload.bat index 98d045c..36e352f 100644 --- a/templates/basic/upload.bat +++ b/templates/basic/upload.bat @@ -8,20 +8,22 @@ setlocal enabledelayedexpansion :: Usage: :: upload.bat Auto-detect port, compile + upload :: upload.bat -p COM3 Specify port +:: upload.bat --board mega Use a named board :: upload.bat --monitor Open serial monitor after upload :: upload.bat --clean Clean build cache first :: upload.bat --verbose Full compiler + avrdude output set "SCRIPT_DIR=%~dp0" -set "CONFIG=%SCRIPT_DIR%.anvil.toml" -set "LOCAL_CONFIG=%SCRIPT_DIR%.anvil.local" +set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" +set "CONFIG=%SCRIPT_DIR%\.anvil.toml" +set "LOCAL_CONFIG=%SCRIPT_DIR%\.anvil.local" if not exist "%CONFIG%" ( echo FAIL: No .anvil.toml found in %SCRIPT_DIR% exit /b 1 ) -:: -- Parse .anvil.toml ---------------------------------------------------- +:: -- Parse .anvil.toml (flat keys) ---------------------------------------- for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( set "_K=%%a" if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" ( @@ -32,13 +34,13 @@ for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( set "_V=!_V:"=!" ) if "!_K!"=="name" set "SKETCH_NAME=!_V!" - if "!_K!"=="fqbn" set "FQBN=!_V!" + if "!_K!"=="default" set "DEFAULT_BOARD=!_V!" if "!_K!"=="warnings" set "WARNINGS=!_V!" if "!_K!"=="baud" set "BAUD=!_V!" ) ) -:: -- Parse .anvil.local (machine-specific, not in git) -------------------- +:: -- Parse .anvil.local --------------------------------------------------- set "LOCAL_PORT=" set "LOCAL_VID_PID=" if exist "%LOCAL_CONFIG%" ( @@ -63,19 +65,21 @@ if "%SKETCH_NAME%"=="" ( ) if "%BAUD%"=="" set "BAUD=115200" -set "SKETCH_DIR=%SCRIPT_DIR%%SKETCH_NAME%" -set "BUILD_DIR=%SCRIPT_DIR%.build" +set "SKETCH_DIR=%SCRIPT_DIR%\%SKETCH_NAME%" +set "BUILD_DIR=%SCRIPT_DIR%\.build" :: -- Parse arguments ------------------------------------------------------ set "PORT=" set "DO_MONITOR=0" set "DO_CLEAN=0" set "VERBOSE=" +set "BOARD_NAME=" :parse_args if "%~1"=="" goto done_args if "%~1"=="-p" set "PORT=%~2" & shift & shift & goto parse_args if "%~1"=="--port" set "PORT=%~2" & shift & shift & goto parse_args +if "%~1"=="--board" set "BOARD_NAME=%~2" & shift & shift & goto parse_args if "%~1"=="--monitor" set "DO_MONITOR=1" & shift & goto parse_args if "%~1"=="--clean" set "DO_CLEAN=1" & shift & goto parse_args if "%~1"=="--verbose" set "VERBOSE=--verbose" & shift & goto parse_args @@ -85,12 +89,54 @@ echo FAIL: Unknown option: %~1 exit /b 1 :show_help -echo Usage: upload.bat [-p PORT] [--monitor] [--clean] [--verbose] +echo Usage: upload.bat [-p PORT] [--board NAME] [--monitor] [--clean] [--verbose] echo Compiles and uploads the sketch. Settings from .anvil.toml. +echo --board NAME selects a board from [boards.NAME]. exit /b 0 :done_args +:: -- Resolve board -------------------------------------------------------- +if "%BOARD_NAME%"=="" set "BOARD_NAME=%DEFAULT_BOARD%" + +set "BOARD_SECTION=[boards.%BOARD_NAME%]" +set "IN_SECTION=0" +set "FQBN=" +set "BOARD_BAUD=" +for /f "usebackq tokens=*" %%L in ("%CONFIG%") do ( + set "_LINE=%%L" + if "!_LINE!"=="!BOARD_SECTION!" ( + set "IN_SECTION=1" + ) else if "!IN_SECTION!"=="1" ( + if "!_LINE:~0,1!"=="[" ( + set "IN_SECTION=0" + ) else if not "!_LINE:~0,1!"=="#" ( + for /f "tokens=1,* delims==" %%a in ("!_LINE!") do ( + set "_BK=%%a" + set "_BK=!_BK: =!" + set "_BV=%%b" + if defined _BV ( + set "_BV=!_BV: =!" + set "_BV=!_BV:"=!" + ) + if "!_BK!"=="fqbn" set "FQBN=!_BV!" + if "!_BK!"=="baud" set "BOARD_BAUD=!_BV!" + ) + ) + ) +) + +if "!FQBN!"=="" ( + echo FAIL: No board '%BOARD_NAME%' in .anvil.toml. + echo Add it: anvil board --add %BOARD_NAME% + exit /b 1 +) +if not "!BOARD_BAUD!"=="" set "BAUD=!BOARD_BAUD!" + +if not "%BOARD_NAME%"=="%DEFAULT_BOARD%" ( + echo ok Using board: %BOARD_NAME% -- !FQBN! +) + :: -- Preflight ------------------------------------------------------------ where arduino-cli >nul 2>nul if errorlevel 1 ( @@ -101,7 +147,7 @@ if errorlevel 1 ( :: -- Resolve port --------------------------------------------------------- :: Priority: -p flag > VID:PID resolve > saved port > auto-detect if "%PORT%"=="" ( - for /f "delims=" %%p in ('powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%_detect_port.ps1" -VidPid "%LOCAL_VID_PID%" -SavedPort "%LOCAL_PORT%"') do ( + for /f "delims=" %%p in ('powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%\_detect_port.ps1" -VidPid "%LOCAL_VID_PID%" -SavedPort "%LOCAL_PORT%"') do ( if "!PORT!"=="" set "PORT=%%p" ) if "!PORT!"=="" ( @@ -131,8 +177,8 @@ if "%DO_CLEAN%"=="1" ( :: -- Build include flags -------------------------------------------------- set "BUILD_FLAGS=" for %%d in (lib\hal lib\app) do ( - if exist "%SCRIPT_DIR%%%d" ( - set "BUILD_FLAGS=!BUILD_FLAGS! -I%SCRIPT_DIR%%%d" + if exist "%SCRIPT_DIR%\%%d" ( + set "BUILD_FLAGS=!BUILD_FLAGS! -I%SCRIPT_DIR%\%%d" ) ) set "BUILD_FLAGS=!BUILD_FLAGS! -Werror" @@ -141,7 +187,7 @@ set "BUILD_FLAGS=!BUILD_FLAGS! -Werror" echo Compiling %SKETCH_NAME%... if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%" -arduino-cli compile --fqbn %FQBN% --build-path "%BUILD_DIR%" --warnings %WARNINGS% --build-property "build.extra_flags=%BUILD_FLAGS%" %VERBOSE% "%SKETCH_DIR%" +arduino-cli compile --fqbn %FQBN% --build-path "%BUILD_DIR%" --warnings %WARNINGS% --build-property "compiler.cpp.extra_flags=%BUILD_FLAGS%" --build-property "compiler.c.extra_flags=%BUILD_FLAGS%" %VERBOSE% "%SKETCH_DIR%" if errorlevel 1 ( echo FAIL: Compilation failed. exit /b 1 diff --git a/templates/basic/upload.sh b/templates/basic/upload.sh index 7ca238c..c66c929 100644 --- a/templates/basic/upload.sh +++ b/templates/basic/upload.sh @@ -7,6 +7,7 @@ # Usage: # ./upload.sh Auto-detect port, compile + upload # ./upload.sh -p /dev/ttyUSB0 Specify port +# ./upload.sh --board mega Use a named board # ./upload.sh --monitor Open serial monitor after upload # ./upload.sh --clean Clean build cache first # ./upload.sh --verbose Full compiler + avrdude output @@ -42,15 +43,26 @@ toml_array() { | sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' } +toml_section_get() { + local section="$1" key="$2" + awk -v section="[$section]" -v key="$key" ' + $0 == section { found=1; next } + /^\[/ { found=0 } + found && $1 == key && /=/ { + sub(/^[^=]*= *"?/, ""); sub(/"? *$/, ""); print; exit + } + ' "$CONFIG" +} + SKETCH_NAME="$(toml_get 'name')" -FQBN="$(toml_get 'fqbn')" +DEFAULT_BOARD="$(toml_get 'default')" WARNINGS="$(toml_get 'warnings')" INCLUDE_DIRS="$(toml_array 'include_dirs')" EXTRA_FLAGS="$(toml_array 'extra_flags')" BAUD="$(toml_get 'baud')" -[[ -n "$SKETCH_NAME" ]] || die "Could not read project name from .anvil.toml" -[[ -n "$FQBN" ]] || die "Could not read fqbn from .anvil.toml" +[[ -n "$SKETCH_NAME" ]] || die "Could not read project name from .anvil.toml" +[[ -n "$DEFAULT_BOARD" ]] || die "Could not read default board from .anvil.toml" BAUD="${BAUD:-115200}" LOCAL_CONFIG="$SCRIPT_DIR/.anvil.local" @@ -68,22 +80,42 @@ PORT="" DO_MONITOR=0 DO_CLEAN=0 VERBOSE="" +BOARD_NAME="" while [[ $# -gt 0 ]]; do case "$1" in -p|--port) PORT="$2"; shift 2 ;; + --board) BOARD_NAME="$2"; shift 2 ;; --monitor) DO_MONITOR=1; shift ;; --clean) DO_CLEAN=1; shift ;; --verbose) VERBOSE="--verbose"; shift ;; -h|--help) - echo "Usage: ./upload.sh [-p PORT] [--monitor] [--clean] [--verbose]" + echo "Usage: ./upload.sh [-p PORT] [--board NAME] [--monitor] [--clean] [--verbose]" echo " Compiles and uploads the sketch. Settings from .anvil.toml." + echo " --board NAME selects a board from [boards.NAME]." exit 0 ;; *) die "Unknown option: $1" ;; esac done +# -- Resolve board --------------------------------------------------------- +ACTIVE_BOARD="${BOARD_NAME:-$DEFAULT_BOARD}" +FQBN="$(toml_section_get "boards.$ACTIVE_BOARD" "fqbn")" + +if [[ -z "$FQBN" ]]; then + die "No board '$ACTIVE_BOARD' in .anvil.toml.\n Add it: anvil board --add $ACTIVE_BOARD" +fi + +BOARD_BAUD="$(toml_section_get "boards.$ACTIVE_BOARD" "baud")" +if [[ -n "$BOARD_BAUD" ]]; then + BAUD="$BOARD_BAUD" +fi + +if [[ -n "$BOARD_NAME" ]]; then + ok "Using board: $BOARD_NAME ($FQBN)" +fi + # -- Preflight ------------------------------------------------------------- command -v arduino-cli &>/dev/null \ || die "arduino-cli not found in PATH." @@ -94,13 +126,11 @@ command -v arduino-cli &>/dev/null \ # -- Resolve port ---------------------------------------------------------- # Priority: -p flag > VID:PID resolve > saved port > auto-detect -# resolve_vid_pid VID:PID -- search arduino-cli JSON for matching device resolve_vid_pid() { local target_vid target_pid json target_vid="$(echo "$1" | cut -d: -f1 | tr '[:upper:]' '[:lower:]')" target_pid="$(echo "$1" | cut -d: -f2 | tr '[:upper:]' '[:lower:]')" json="$(arduino-cli board list --format json 2>/dev/null)" || return - # Walk through JSON looking for matching vid/pid on serial ports echo "$json" | python3 -c " import sys, json try: @@ -120,7 +150,6 @@ except: pass } if [[ -z "$PORT" ]]; then - # Try VID:PID resolution first if [[ -n "$LOCAL_VID_PID" ]]; then PORT="$(resolve_vid_pid "$LOCAL_VID_PID")" if [[ -n "$PORT" ]]; then @@ -132,13 +161,11 @@ if [[ -z "$PORT" ]]; then fi fi - # Fall back to saved port if [[ -z "$PORT" ]] && [[ -n "$LOCAL_PORT" ]]; then PORT="$LOCAL_PORT" warn "Using port $PORT (from .anvil.local)" fi - # Fall back to auto-detect if [[ -z "$PORT" ]]; then PORT=$(arduino-cli board list 2>/dev/null \ | grep -i "serial" \ @@ -191,7 +218,8 @@ COMPILE_ARGS=( ) if [[ -n "$BUILD_FLAGS" ]]; then - COMPILE_ARGS+=(--build-property "build.extra_flags=$BUILD_FLAGS") + COMPILE_ARGS+=(--build-property "compiler.cpp.extra_flags=$BUILD_FLAGS") + COMPILE_ARGS+=(--build-property "compiler.c.extra_flags=$BUILD_FLAGS") fi [[ -n "$VERBOSE" ]] && COMPILE_ARGS+=("$VERBOSE") diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 0d7c9cc..0501216 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -15,6 +15,7 @@ fn test_basic_template_extracts_all_expected_files() { let ctx = TemplateContext { project_name: "test_proj".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -29,6 +30,7 @@ fn test_template_creates_sketch_directory() { let ctx = TemplateContext { project_name: "blink".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -59,6 +61,7 @@ fn test_template_creates_hal_files() { let ctx = TemplateContext { project_name: "sensor".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -92,6 +95,7 @@ fn test_template_creates_app_header() { let ctx = TemplateContext { project_name: "my_sensor".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -118,6 +122,7 @@ fn test_template_creates_test_infrastructure() { let ctx = TemplateContext { project_name: "blink".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -156,6 +161,7 @@ fn test_template_test_file_references_correct_app() { let ctx = TemplateContext { project_name: "motor_ctrl".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -177,6 +183,7 @@ fn test_template_cmake_references_correct_project() { let ctx = TemplateContext { project_name: "my_bot".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -198,6 +205,7 @@ fn test_template_creates_dot_files() { let ctx = TemplateContext { project_name: "blink".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -228,6 +236,7 @@ fn test_template_creates_readme() { let ctx = TemplateContext { project_name: "blink".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -251,6 +260,7 @@ fn test_template_creates_valid_config() { let ctx = TemplateContext { project_name: "blink".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -260,7 +270,7 @@ fn test_template_creates_valid_config() { // Should be loadable by ProjectConfig let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(config.project.name, "blink"); - assert_eq!(config.build.fqbn, "arduino:avr:uno"); + assert_eq!(config.build.default, "uno"); assert_eq!(config.monitor.baud, 115200); assert!(config.build.extra_flags.contains(&"-Werror".to_string())); } @@ -273,7 +283,7 @@ fn test_config_roundtrip() { let loaded = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!(loaded.project.name, "roundtrip_test"); - assert_eq!(loaded.build.fqbn, config.build.fqbn); + assert_eq!(loaded.build.default, config.build.default); assert_eq!(loaded.monitor.baud, config.monitor.baud); assert_eq!(loaded.build.include_dirs, config.build.include_dirs); } @@ -332,6 +342,7 @@ fn test_full_project_structure() { let ctx = TemplateContext { project_name: "full_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -383,6 +394,7 @@ fn test_no_unicode_in_template_output() { let ctx = TemplateContext { project_name: "ascii_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -432,6 +444,7 @@ fn test_unknown_template_fails() { let ctx = TemplateContext { project_name: "test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -457,6 +470,7 @@ fn test_template_creates_self_contained_scripts() { let ctx = TemplateContext { project_name: "standalone".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -481,6 +495,7 @@ fn test_build_sh_reads_anvil_toml() { let ctx = TemplateContext { project_name: "toml_reader".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -508,6 +523,7 @@ fn test_upload_sh_reads_anvil_toml() { let ctx = TemplateContext { project_name: "uploader".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -543,6 +559,7 @@ fn test_monitor_sh_reads_anvil_toml() { let ctx = TemplateContext { project_name: "serial_mon".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -570,6 +587,7 @@ fn test_scripts_have_shebangs() { let ctx = TemplateContext { project_name: "shebangs".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -594,6 +612,7 @@ fn test_scripts_no_anvil_binary_dependency() { let ctx = TemplateContext { project_name: "no_anvil_dep".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -643,6 +662,7 @@ fn test_gitignore_excludes_build_cache() { let ctx = TemplateContext { project_name: "gitcheck".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -670,6 +690,7 @@ fn test_readme_documents_self_contained_workflow() { let ctx = TemplateContext { project_name: "docs_check".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -705,6 +726,7 @@ fn test_scripts_tolerate_missing_toml_keys() { let ctx = TemplateContext { project_name: "grep_safe".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -742,6 +764,7 @@ fn test_bat_scripts_no_unescaped_parens_in_echo() { let ctx = TemplateContext { project_name: "parens_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -803,6 +826,7 @@ fn test_scripts_read_anvil_local_for_port() { let ctx = TemplateContext { project_name: "local_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -825,6 +849,7 @@ fn test_anvil_toml_template_has_no_port() { let ctx = TemplateContext { project_name: "no_port".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -856,6 +881,7 @@ fn test_bat_scripts_call_detect_port_ps1() { let ctx = TemplateContext { project_name: "ps1_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -878,6 +904,7 @@ fn test_detect_port_ps1_is_valid() { let ctx = TemplateContext { project_name: "ps1_valid".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -910,6 +937,7 @@ fn test_refresh_freshly_extracted_is_up_to_date() { let ctx = TemplateContext { project_name: "fresh_proj".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -940,6 +968,7 @@ fn test_refresh_detects_modified_script() { let ctx = TemplateContext { project_name: "mod_proj".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -1009,6 +1038,7 @@ fn test_scripts_read_vid_pid_from_anvil_local() { let ctx = TemplateContext { project_name: "vidpid_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; @@ -1035,6 +1065,7 @@ fn test_board_preset_fqbn_in_config() { let ctx = TemplateContext { project_name: "mega_test".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "mega".to_string(), fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), baud: 115200, }; @@ -1042,7 +1073,7 @@ fn test_board_preset_fqbn_in_config() { let config = ProjectConfig::load(tmp.path()).unwrap(); assert_eq!( - config.build.fqbn, "arduino:avr:mega:cpu=atmega2560", + config.build.default, "mega", ".anvil.toml should contain mega FQBN" ); } @@ -1054,12 +1085,113 @@ fn test_board_preset_custom_fqbn_in_config() { let ctx = TemplateContext { project_name: "custom_board".to_string(), anvil_version: "1.0.0".to_string(), + board_name: "esp".to_string(), fqbn: "esp32:esp32:esp32".to_string(), baud: 9600, }; TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap(); - assert_eq!(config.build.fqbn, "esp32:esp32:esp32"); + assert_eq!(config.build.default, "esp"); assert_eq!(config.monitor.baud, 9600); +} + +// ========================================================================== +// Multi-board profile tests +// ========================================================================== + +#[test] +fn test_scripts_accept_board_flag() { + // All build/upload/monitor scripts should accept --board + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "multi_test".to_string(), + anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + baud: 115200, + }; + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let scripts = vec![ + "build.sh", "build.bat", + "upload.sh", "upload.bat", + "monitor.sh", "monitor.bat", + ]; + + for script in &scripts { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.contains("--board"), + "{} should accept --board flag", + script + ); + } +} + +#[test] +fn test_sh_scripts_have_toml_section_get() { + // Shell scripts need section-aware TOML parsing for board profiles + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "section_test".to_string(), + anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + baud: 115200, + }; + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + for script in &["build.sh", "upload.sh", "monitor.sh"] { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.contains("toml_section_get"), + "{} should have toml_section_get function for board profiles", + script + ); + } +} + +#[test] +fn test_bat_scripts_have_section_parser() { + // Batch scripts need section-aware TOML parsing for board profiles + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "bat_section".to_string(), + anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + baud: 115200, + }; + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + for bat in &["build.bat", "upload.bat", "monitor.bat"] { + let content = fs::read_to_string(tmp.path().join(bat)).unwrap(); + assert!( + content.contains("BOARD_SECTION") || content.contains("IN_SECTION"), + "{} should have section parser for board profiles", + bat + ); + } +} + +#[test] +fn test_toml_template_has_board_profile_comments() { + // The generated .anvil.toml should include commented examples + // showing how to add board profiles + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "comment_test".to_string(), + anvil_version: "1.0.0".to_string(), + board_name: "uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + baud: 115200, + }; + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap(); + assert!( + content.contains("[boards.mega]") || content.contains("boards.mega"), + ".anvil.toml should show board profile examples in comments" + ); } \ No newline at end of file