diff --git a/Cargo.lock b/Cargo.lock index 04178eb..adb7283 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -69,7 +69,6 @@ dependencies = [ "assert_cmd", "clap", "colored", - "ctrlc", "dirs", "home", "include_dir", @@ -115,15 +114,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "bstr" version = "1.12.1" @@ -141,12 +131,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "clap" version = "4.5.58" @@ -203,17 +187,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix", - "windows-sys 0.61.2", -] - [[package]] name = "difflib" version = "0.4.0" @@ -241,18 +214,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "dispatch2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - [[package]] name = "either" version = "1.15.0" @@ -415,18 +376,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "nix" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -442,21 +391,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "objc2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - [[package]] name = "once_cell" version = "1.21.3" diff --git a/Cargo.toml b/Cargo.toml index 05bf241..97ef13b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,11 +40,10 @@ colored = "2.1" which = "5.0" home = "=0.5.9" -# Signal handling -ctrlc = "3.4" +# Temp dirs (for refresh command) +tempfile = "3.13" [dev-dependencies] -tempfile = "3.13" assert_cmd = "2.0" predicates = "3.1" @@ -52,4 +51,4 @@ predicates = "3.1" opt-level = 3 lto = true codegen-units = 1 -strip = true +strip = true \ No newline at end of file diff --git a/README.md b/README.md index 15835a6..ab5cc94 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ **Arduino project generator and build tool -- forges clean embedded projects.** -A single binary that scaffolds testable Arduino projects with hardware abstraction, -Google Mock infrastructure, and a streamlined build/upload/monitor workflow. Works on -Linux and Windows. +A single binary that scaffolds self-contained Arduino projects with hardware +abstraction, Google Mock infrastructure, and a streamlined build/upload/monitor +workflow. Works on Linux and Windows. + +Generated projects are fully standalone -- they only need `arduino-cli` in +PATH. The Anvil binary is a scaffolding and diagnostic tool, not a runtime +dependency. Anvil is a [Nexus Workshops](https://nxlearn.net) project. @@ -36,20 +40,37 @@ your system is ready. # Create a new project anvil new blink -# Check system health -anvil doctor +# Enter the project +cd blink -# Find your board -anvil devices +# Compile (verify only) +./build.sh + +# Compile and upload to board +./upload.sh # Compile, upload, and open serial monitor -cd blink -anvil build --monitor blink +./upload.sh --monitor # Run host-side tests (no board needed) -cd test && ./run_tests.sh +./test/run_tests.sh ``` +On Windows, use `build.bat`, `upload.bat`, `monitor.bat`, and +`test\run_tests.bat`. + +## What Anvil Does vs. What the Project Does + +| Need Anvil for | Don't need Anvil for | +|-------------------------------|-------------------------------| +| `anvil new` (create project) | `./build.sh` (compile) | +| `anvil doctor` (diagnose) | `./upload.sh` (flash) | +| `anvil setup` (install core) | `./monitor.sh` (serial) | +| `anvil devices` (port scan) | `./test/run_tests.sh` (test) | + +Once a project is created, Anvil is optional. Students clone the repo, +plug in a board, and run `./upload.sh`. + ## Commands | Command | Description | @@ -58,9 +79,12 @@ cd test && ./run_tests.sh | `anvil doctor` | Check system prerequisites | | `anvil setup` | Install arduino-cli and AVR core | | `anvil devices` | List connected boards and serial ports | -| `anvil build DIR` | Compile and upload a sketch | -| `anvil upload DIR`| Upload cached build (no recompile) | -| `anvil monitor` | Open serial monitor (`--watch` for persistent) | +| `anvil build DIR` | Compile and upload a sketch (convenience) | +| `anvil upload DIR`| Upload cached build (convenience) | +| `anvil monitor` | Open serial monitor (convenience) | + +The `build`, `upload`, and `monitor` commands are convenience wrappers. +They do the same thing as the generated scripts. ## Project Architecture @@ -74,6 +98,9 @@ your-project/ lib/app/your-project_app.h -- app logic (testable) test/mocks/mock_hal.h -- Google Mock HAL test/test_unit.cpp -- unit tests + build.sh / build.bat -- compile + upload.sh / upload.bat -- compile + flash + monitor.sh / monitor.bat -- serial monitor .anvil.toml -- project config ``` diff --git a/anvil_src.zip b/anvil_src.zip deleted file mode 100644 index 86df857..0000000 Binary files a/anvil_src.zip and /dev/null differ diff --git a/src/board/mod.rs b/src/board/mod.rs index 0d36aa8..1a552c7 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -11,6 +11,40 @@ pub struct PortInfo { pub protocol: String, pub board_name: String, pub fqbn: String, + pub vid: String, + pub pid: String, + pub serial_number: String, +} + +impl PortInfo { + /// Returns true if this looks like a USB serial port rather than a + /// legacy motherboard COM port. + pub fn is_usb(&self) -> bool { + // Has USB identifiers + if !self.vid.is_empty() && !self.pid.is_empty() { + return true; + } + // arduino-cli labels USB ports "Serial Port (USB)" + if self.protocol.contains("USB") { + return true; + } + // Unix: ttyUSB* and ttyACM* are always USB + if self.port_name.contains("ttyUSB") || self.port_name.contains("ttyACM") { + return true; + } + false + } + + /// Returns VID:PID string like "2341:0043", or empty if unknown. + pub fn vid_pid(&self) -> String { + if self.vid.is_empty() || self.pid.is_empty() { + return String::new(); + } + // Normalize: strip 0x prefix, lowercase, 4-digit padded + let vid = self.vid.trim_start_matches("0x").trim_start_matches("0X"); + let pid = self.pid.trim_start_matches("0x").trim_start_matches("0X"); + format!("{}:{}", vid.to_lowercase(), pid.to_lowercase()) + } } /// JSON schema for `arduino-cli board list --format json` @@ -90,11 +124,34 @@ fn list_ports_via_cli(cli: &Path) -> Result> { _ => ("Unknown".to_string(), String::new()), }; + // Extract VID, PID, serial number from properties + let (vid, pid, serial_number) = match &port.properties { + Some(props) => { + let v = props.get("vid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let p = props.get("pid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let sn = props.get("serialNumber") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + (v, p, sn) + } + None => (String::new(), String::new(), String::new()), + }; + result.push(PortInfo { port_name: port.address, protocol: port.protocol_label, board_name, fqbn, + vid, + pid, + serial_number, }); } @@ -123,6 +180,9 @@ fn list_ports_fallback() -> Vec { protocol: "serial".to_string(), board_name: board.to_string(), fqbn: String::new(), + vid: String::new(), + pid: String::new(), + serial_number: String::new(), }); } } @@ -149,6 +209,9 @@ fn list_ports_fallback() -> Vec { protocol: "serial".to_string(), board_name: "Detected via WMI".to_string(), fqbn: String::new(), + vid: String::new(), + pid: String::new(), + serial_number: String::new(), }); } } @@ -160,6 +223,50 @@ fn list_ports_fallback() -> Vec { result } +/// Find a port by VID:PID. Returns the port name if found. +pub fn resolve_vid_pid(vid_pid: &str) -> Option { + let ports = list_ports(); + let needle = vid_pid.to_lowercase(); + + for p in &ports { + if p.vid_pid() == needle { + return Some(p.port_name.clone()); + } + } + + None +} + +/// Pick the best default port from a list. Returns the index into the +/// slice, or None if the list is empty. +/// +/// Priority: +/// 1. A port with a recognized board (non-empty FQBN) +/// 2. A USB serial port (skip legacy motherboard COM ports) +/// 3. First port in the list +pub fn pick_default_port(ports: &[PortInfo]) -> Option { + if ports.is_empty() { + return None; + } + + // Prefer a port with a recognized FQBN + for (i, p) in ports.iter().enumerate() { + if !p.fqbn.is_empty() { + return Some(i); + } + } + + // Prefer a USB port over a legacy serial port + for (i, p) in ports.iter().enumerate() { + if p.is_usb() { + return Some(i); + } + } + + // Fall back to the first port + Some(0) +} + /// Auto-detect a single serial port. pub fn auto_detect_port() -> Result { let ports = list_ports(); @@ -171,35 +278,23 @@ pub fn auto_detect_port() -> Result { ); } - if ports.len() == 1 { - return Ok(ports[0].port_name.clone()); - } + let idx = pick_default_port(&ports).unwrap_or(0); - eprintln!("{}", "Multiple serial ports detected:".yellow()); - for p in &ports { - eprintln!(" {} ({})", p.port_name, p.board_name); - } - - // Prefer a port with a recognized board - for p in &ports { - if !p.fqbn.is_empty() { - eprintln!( - "{}", - format!( - "Auto-selected {} ({}). Use -p to override.", - p.port_name, p.board_name - ).yellow() - ); - return Ok(p.port_name.clone()); + if ports.len() > 1 { + eprintln!("{}", "Multiple serial ports detected:".yellow()); + for p in &ports { + eprintln!(" {} ({})", p.port_name, p.board_name); } + eprintln!( + "{}", + format!( + "Auto-selected {}. Use -p to override.", + ports[idx].port_name + ).yellow() + ); } - let selected = ports[0].port_name.clone(); - eprintln!( - "{}", - format!("Auto-selected {}. Use -p to override.", selected).yellow() - ); - Ok(selected) + Ok(ports[idx].port_name.clone()) } /// Print detailed port information. @@ -219,8 +314,18 @@ pub fn print_port_details(ports: &[PortInfo]) { return; } - for port in ports { - println!(" {}", port.port_name.green().bold()); + let default_idx = pick_default_port(ports); + + for (i, port) in ports.iter().enumerate() { + let is_default = default_idx == Some(i); + + if is_default { + print!(" {}", port.port_name.green().bold()); + println!(" {}", "<-- default".bright_green()); + } else { + println!(" {}", port.port_name.green().bold()); + } + println!(" Board: {}", port.board_name); if !port.fqbn.is_empty() { println!(" FQBN: {}", port.fqbn); @@ -229,6 +334,14 @@ pub fn print_port_details(ports: &[PortInfo]) { println!(" Protocol: {}", port.protocol); } + let vp = port.vid_pid(); + if !vp.is_empty() { + println!(" VID:PID: {}", vp.bright_cyan()); + } + if !port.serial_number.is_empty() { + println!(" Serial: {}", port.serial_number.bright_black()); + } + #[cfg(unix)] { use std::fs::OpenOptions; @@ -254,6 +367,85 @@ pub fn print_port_details(ports: &[PortInfo]) { println!(); } + + // Show help section + if let Some(idx) = default_idx { + if ports.len() > 1 { + println!( + " Anvil will use {} when no port is specified.", + ports[idx].port_name.bright_white() + ); + println!(); + } + + println!( + " {}", + "Save a default port for your project:".bright_white() + ); + println!(); + println!( + " {}", + format!("anvil devices --set {}", ports[idx].port_name).bright_cyan(), + ); + println!( + " {}", + "anvil devices --set".bright_cyan(), + ); + println!(); + println!( + " {}", + "Both forms save the port AND VID:PID to .anvil.local automatically.".bright_black() + ); + println!( + " {}", + "Use --get to see what's saved, --set to change it.".bright_black() + ); + println!(); + + // Show VID:PID explanation if any USB devices are present + let has_usb = ports.iter().any(|p| !p.vid_pid().is_empty()); + if has_usb { + println!( + " {}", + "VID:PID identifies the USB device, not the port number.".bright_white() + ); + println!( + " {}", + "If the device moves to a different port after replug,".bright_black() + ); + println!( + " {}", + "Anvil will find it automatically by VID:PID.".bright_black() + ); + println!(); + } + + println!( + " {}", + "Port resolution priority:".bright_white() + ); + println!( + " {} {}", + "1.".bright_white(), + "-p flag (upload.bat -p COM3)".bright_black() + ); + println!( + " {} {}", + "2.".bright_white(), + "VID:PID from .anvil.local (tracks device across port changes)".bright_black() + ); + println!( + " {} {}", + "3.".bright_white(), + "Saved port from .anvil.local".bright_black() + ); + println!( + " {} {}", + "4.".bright_white(), + "Auto-detect (prefers USB over legacy COM)".bright_black() + ); + println!(); + } } /// Find arduino-cli in PATH or in ~/.anvil/bin. @@ -308,11 +500,70 @@ mod tests { protocol: "serial".to_string(), board_name: "Test".to_string(), fqbn: "arduino:avr:uno".to_string(), + vid: "0x2341".to_string(), + pid: "0x0043".to_string(), + serial_number: String::new(), }; let cloned = info.clone(); assert_eq!(cloned.port_name, info.port_name); } + #[test] + fn test_vid_pid_formatting() { + let info = PortInfo { + port_name: "COM3".to_string(), + protocol: "Serial Port (USB)".to_string(), + board_name: "Arduino Uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + vid: "0x2341".to_string(), + pid: "0x0043".to_string(), + serial_number: String::new(), + }; + assert_eq!(info.vid_pid(), "2341:0043"); + } + + #[test] + fn test_vid_pid_empty() { + let info = PortInfo { + port_name: "COM1".to_string(), + protocol: "Serial Port".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: String::new(), + pid: String::new(), + serial_number: String::new(), + }; + assert_eq!(info.vid_pid(), ""); + } + + #[test] + fn test_vid_pid_no_prefix() { + let info = PortInfo { + port_name: "COM3".to_string(), + protocol: "Serial Port (USB)".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: "2341".to_string(), + pid: "0043".to_string(), + serial_number: String::new(), + }; + assert_eq!(info.vid_pid(), "2341:0043"); + } + + #[test] + fn test_is_usb_from_vid_pid() { + let info = PortInfo { + port_name: "COM5".to_string(), + protocol: "Serial Port".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: "1a86".to_string(), + pid: "7523".to_string(), + serial_number: String::new(), + }; + assert!(info.is_usb()); + } + #[test] fn test_parse_empty_board_list() { let json = r#"{"detected_ports": []}"#; @@ -327,7 +578,12 @@ mod tests { "port": { "address": "/dev/ttyUSB0", "protocol": "serial", - "protocol_label": "Serial Port (USB)" + "protocol_label": "Serial Port (USB)", + "properties": { + "vid": "0x2341", + "pid": "0x0043", + "serialNumber": "ABC123" + } }, "matching_boards": [{ "name": "Arduino Uno", @@ -338,8 +594,96 @@ mod tests { let parsed: BoardListOutput = serde_json::from_str(json).unwrap(); assert_eq!(parsed.detected_ports.len(), 1); let dp = &parsed.detected_ports[0]; - assert_eq!(dp.port.as_ref().unwrap().address, "/dev/ttyUSB0"); - let boards = dp.matching_boards.as_ref().unwrap(); - assert_eq!(boards[0].name, "Arduino Uno"); + let port = dp.port.as_ref().unwrap(); + assert_eq!(port.address, "/dev/ttyUSB0"); + let props = port.properties.as_ref().unwrap(); + assert_eq!(props["vid"].as_str().unwrap(), "0x2341"); } -} + + #[test] + fn test_is_usb_from_protocol_label() { + let usb = PortInfo { + port_name: "COM3".to_string(), + protocol: "Serial Port (USB)".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: String::new(), + pid: String::new(), + serial_number: String::new(), + }; + assert!(usb.is_usb()); + + let legacy = PortInfo { + port_name: "COM1".to_string(), + protocol: "Serial Port".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: String::new(), + pid: String::new(), + serial_number: String::new(), + }; + assert!(!legacy.is_usb()); + } + + #[test] + fn test_pick_default_prefers_fqbn() { + let ports = vec![ + PortInfo { + port_name: "COM1".to_string(), + protocol: "Serial Port".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: String::new(), pid: String::new(), serial_number: String::new(), + }, + PortInfo { + port_name: "COM3".to_string(), + protocol: "Serial Port (USB)".to_string(), + board_name: "Arduino Uno".to_string(), + fqbn: "arduino:avr:uno".to_string(), + vid: "0x2341".to_string(), pid: "0x0043".to_string(), serial_number: String::new(), + }, + ]; + assert_eq!(pick_default_port(&ports), Some(1)); + } + + #[test] + fn test_pick_default_prefers_usb_over_legacy() { + let ports = vec![ + PortInfo { + port_name: "COM1".to_string(), + protocol: "Serial Port".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: String::new(), pid: String::new(), serial_number: String::new(), + }, + PortInfo { + port_name: "COM3".to_string(), + protocol: "Serial Port (USB)".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: "1a86".to_string(), pid: "7523".to_string(), serial_number: String::new(), + }, + ]; + assert_eq!(pick_default_port(&ports), Some(1)); + } + + #[test] + fn test_pick_default_empty() { + let ports: Vec = vec![]; + assert_eq!(pick_default_port(&ports), None); + } + + #[test] + fn test_pick_default_single() { + let ports = vec![ + PortInfo { + port_name: "COM1".to_string(), + protocol: "Serial Port".to_string(), + board_name: "Unknown".to_string(), + fqbn: String::new(), + vid: String::new(), pid: String::new(), serial_number: String::new(), + }, + ]; + assert_eq!(pick_default_port(&ports), Some(0)); + } +} \ No newline at end of file diff --git a/src/commands/build.rs b/src/commands/build.rs deleted file mode 100644 index 48032bf..0000000 --- a/src/commands/build.rs +++ /dev/null @@ -1,299 +0,0 @@ -use anyhow::{Result, bail, Context}; -use colored::*; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use crate::board; -use crate::project::config::{ProjectConfig, build_cache_dir}; - -/// Full build: compile + upload (+ optional monitor). -pub fn run_build( - sketch: &str, - verify_only: bool, - do_monitor: bool, - do_clean: bool, - verbose: bool, - port: Option<&str>, - baud: Option, - fqbn_override: Option<&str>, -) -> Result<()> { - let sketch_path = resolve_sketch(sketch)?; - let sketch_name = sketch_name(&sketch_path)?; - let project_root = ProjectConfig::find_project_root(&sketch_path) - .ok(); - - // Load project config if available, otherwise use defaults - let config = match &project_root { - Some(root) => ProjectConfig::load(root)?, - None => { - eprintln!( - "{}", - "No .anvil.toml found; using default settings.".yellow() - ); - ProjectConfig::default() - } - }; - - let fqbn = fqbn_override.unwrap_or(&config.build.fqbn); - let monitor_baud = baud.unwrap_or(config.monitor.baud); - - println!("Sketch: {}", sketch_name.bright_white().bold()); - println!("Board: {}", fqbn.bright_white()); - - // Locate arduino-cli - let cli = board::find_arduino_cli() - .context("arduino-cli not found. Run: anvil setup")?; - - // Verify AVR core - if !board::is_avr_core_installed(&cli) { - bail!("arduino:avr core not installed. Run: anvil setup"); - } - - // Build cache directory - let cache_dir = build_cache_dir()?.join(&sketch_name); - - // Clean if requested - if do_clean && cache_dir.exists() { - println!("{}", "Cleaning build cache...".bright_yellow()); - std::fs::remove_dir_all(&cache_dir)?; - println!(" {} Cache cleared.", "ok".green()); - } - - // Compile - println!("{}", "Compiling...".bright_yellow()); - std::fs::create_dir_all(&cache_dir)?; - - let mut compile_args: Vec = vec![ - "compile".to_string(), - "--fqbn".to_string(), - fqbn.to_string(), - "--build-path".to_string(), - cache_dir.display().to_string(), - "--warnings".to_string(), - config.build.warnings.clone(), - ]; - - if verbose { - compile_args.push("--verbose".to_string()); - } - - // Inject project-level build flags (include paths, -Werror, etc.) - if let Some(ref root) = project_root { - let extra = config.extra_flags_string(root); - if !extra.is_empty() { - compile_args.push("--build-property".to_string()); - compile_args.push(format!("build.extra_flags={}", extra)); - } - } - - compile_args.push(sketch_path.display().to_string()); - - let status = Command::new(&cli) - .args(&compile_args) - .status() - .context("Failed to execute arduino-cli compile")?; - - if !status.success() { - bail!("Compilation failed."); - } - println!(" {} Compile succeeded.", "ok".green()); - - // Report binary size - report_binary_size(&cache_dir, &sketch_name); - - // Verify-only: stop here - if verify_only { - println!(); - println!(" {} Verify-only mode. Done.", "ok".green()); - return Ok(()); - } - - // Upload - let port = match port { - Some(p) => p.to_string(), - None => board::auto_detect_port()?, - }; - - upload_to_board(&cli, fqbn, &port, &cache_dir, verbose)?; - - // Monitor - if do_monitor { - println!(); - println!( - "Opening serial monitor on {} at {} baud...", - port.bright_white(), - monitor_baud - ); - println!("Press Ctrl+C to exit."); - println!(); - - let _ = Command::new(&cli) - .args([ - "monitor", - "-p", &port, - "-c", &format!("baudrate={}", monitor_baud), - ]) - .status(); - } else { - println!(); - println!("To open serial monitor:"); - println!( - " anvil monitor -p {} -b {}", - port, monitor_baud - ); - } - - Ok(()) -} - -/// Upload cached build artifacts without recompiling. -pub fn run_upload_only( - sketch: &str, - port: Option<&str>, - verbose: bool, - fqbn_override: Option<&str>, -) -> Result<()> { - let sketch_path = resolve_sketch(sketch)?; - let sketch_name = sketch_name(&sketch_path)?; - let project_root = ProjectConfig::find_project_root(&sketch_path) - .ok(); - - let config = match &project_root { - Some(root) => ProjectConfig::load(root)?, - None => ProjectConfig::default(), - }; - - let fqbn = fqbn_override.unwrap_or(&config.build.fqbn); - - // Verify cached build exists - let cache_dir = build_cache_dir()?.join(&sketch_name); - if !cache_dir.exists() { - bail!( - "No cached build found for '{}'.\n\ - Run a compile first: anvil build --verify {}", - sketch_name, - sketch - ); - } - - let hex_name = format!("{}.ino.hex", sketch_name); - if !cache_dir.join(&hex_name).exists() { - bail!( - "Build cache exists but no .hex file found.\n\ - Try a clean rebuild: anvil build --clean {}", - sketch - ); - } - - println!(" {} Using cached build.", "ok".green()); - report_binary_size(&cache_dir, &sketch_name); - - let cli = board::find_arduino_cli() - .context("arduino-cli not found. Run: anvil setup")?; - - let port = match port { - Some(p) => p.to_string(), - None => board::auto_detect_port()?, - }; - - upload_to_board(&cli, fqbn, &port, &cache_dir, verbose)?; - - Ok(()) -} - -/// Upload compiled artifacts to the board. -fn upload_to_board( - cli: &Path, - fqbn: &str, - port: &str, - input_dir: &Path, - verbose: bool, -) -> Result<()> { - println!( - "Uploading to {}...", - port.bright_white().bold() - ); - - let mut upload_args = vec![ - "upload".to_string(), - "--fqbn".to_string(), - fqbn.to_string(), - "--port".to_string(), - port.to_string(), - "--input-dir".to_string(), - input_dir.display().to_string(), - ]; - - if verbose { - upload_args.push("--verbose".to_string()); - } - - let status = Command::new(cli) - .args(&upload_args) - .status() - .context("Failed to execute arduino-cli upload")?; - - if !status.success() { - bail!( - "Upload failed. Run with --verbose for details.\n\ - Also try: anvil devices" - ); - } - - println!(" {} Upload complete!", "ok".green()); - Ok(()) -} - -/// Resolve sketch argument to an absolute path. -fn resolve_sketch(sketch: &str) -> Result { - let path = PathBuf::from(sketch); - let abs = if path.is_absolute() { - path - } else { - std::env::current_dir()?.join(&path) - }; - - // Canonicalize if it exists - let resolved = if abs.exists() { - abs.canonicalize().unwrap_or(abs) - } else { - abs - }; - - if !resolved.is_dir() { - bail!("Not a directory: {}", resolved.display()); - } - - Ok(resolved) -} - -/// Extract the sketch name from a path (basename of the directory). -fn sketch_name(sketch_path: &Path) -> Result { - let name = sketch_path - .file_name() - .context("Could not determine sketch name")? - .to_string_lossy() - .to_string(); - Ok(name) -} - -/// Report binary size using avr-size if available. -fn report_binary_size(cache_dir: &Path, sketch_name: &str) { - let elf_name = format!("{}.ino.elf", sketch_name); - let elf_path = cache_dir.join(&elf_name); - - if !elf_path.exists() { - return; - } - - if which::which("avr-size").is_err() { - return; - } - - println!(); - let _ = Command::new("avr-size") - .args(["--mcu=atmega328p", "-C"]) - .arg(&elf_path) - .status(); - println!(); -} diff --git a/src/commands/devices.rs b/src/commands/devices.rs index 53f121b..1186a19 100644 --- a/src/commands/devices.rs +++ b/src/commands/devices.rs @@ -1,5 +1,7 @@ -use anyhow::Result; +use anyhow::{Result, Context}; use colored::*; +use std::path::{Path, PathBuf}; +use std::fs; use crate::board; @@ -60,8 +62,261 @@ pub fn scan_devices() -> Result<()> { println!(" - Check kernel log: dmesg | tail -20"); println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); } + #[cfg(target_os = "windows")] + { + println!(" - Open Device Manager and check Ports (COM & LPT)"); + println!(" - Install CH340 driver if needed: https://www.wch-ic.com/downloads/CH341SER_EXE.html"); + println!(" - Check if the board appears under \"Other devices\" with a warning icon"); + } println!(); } Ok(()) } + +/// Read and display the saved port from .anvil.local. +pub fn get_port(project_dir: Option<&str>) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + require_anvil_project(&project_path)?; + + let local_file = project_path.join(".anvil.local"); + if !local_file.exists() { + println!( + "{} No saved port (no .anvil.local file).", + "--".bright_black() + ); + println!(); + println!(" To save a default port for this machine, run:"); + println!(); + println!(" {} {}", "anvil devices --set".bright_cyan(), + "auto-detect and save".bright_black()); + println!(" {} {}", + "anvil devices --set COM3".bright_cyan(), + "save a specific port".bright_black()); + return Ok(()); + } + + let (saved_port, saved_vid_pid) = read_anvil_local(&local_file)?; + + if saved_port.is_empty() && saved_vid_pid.is_empty() { + println!( + "{} .anvil.local exists but no port is set.", + "--".bright_black() + ); + println!(); + println!(" To save a default port, run:"); + println!(); + println!(" {}", "anvil devices --set COM3".bright_cyan()); + return Ok(()); + } + + // Try to resolve VID:PID to current port + if !saved_vid_pid.is_empty() { + match board::resolve_vid_pid(&saved_vid_pid) { + Some(current_port) => { + println!( + "{} Device {} is on {}", + "ok".green(), + saved_vid_pid.bright_cyan(), + current_port.bright_white().bold() + ); + if !saved_port.is_empty() && saved_port != current_port { + println!( + " {}", + format!( + "Note: saved port was {}, device has moved", + saved_port + ).bright_yellow() + ); + } + } + None => { + println!( + "{} Device {} is not connected", + "!!".bright_red(), + saved_vid_pid.bright_cyan() + ); + if !saved_port.is_empty() { + println!( + " Last known port: {}", + saved_port.bright_black() + ); + } + println!(); + println!(" Is the board plugged in?"); + } + } + } else { + println!( + "{} Saved port: {}", + "ok".green(), + saved_port.bright_white().bold() + ); + println!( + " {}", + "No VID:PID saved -- port won't track if reassigned.".bright_black() + ); + println!( + " {}", + "Re-run 'anvil devices --set' to save the device identity.".bright_black() + ); + } + + println!(); + println!( + " {}", + format!("Source: {}", local_file.display()).bright_black() + ); + println!(" To change: {}", "anvil devices --set ".bright_cyan()); + println!(" To remove: {}", "delete .anvil.local".bright_cyan()); + + Ok(()) +} + +/// Write a port to .anvil.local in the given project directory. +pub fn set_port(port: Option<&str>, project_dir: Option<&str>) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + require_anvil_project(&project_path)?; + + // Resolve the port and find its VID:PID + let ports = board::list_ports(); + + let (resolved_port, vid_pid) = match port { + Some(p) => { + // User specified a port -- find it in the list to get VID:PID + let port_name = if cfg!(target_os = "windows") { + p.to_uppercase() + } else { + p.to_string() + }; + + let vp = ports.iter() + .find(|pi| pi.port_name.eq_ignore_ascii_case(&port_name)) + .map(|pi| pi.vid_pid()) + .unwrap_or_default(); + + (port_name, vp) + } + None => { + // Auto-detect the best port + println!("Detecting best port..."); + println!(); + + if ports.is_empty() { + anyhow::bail!( + "No serial ports detected. Is the board plugged in?\n \ + Specify a port explicitly: anvil devices --set COM3" + ); + } + + let idx = board::pick_default_port(&ports).unwrap_or(0); + let selected = &ports[idx]; + + println!( + " Found {} port(s), best match: {}", + ports.len(), + selected.port_name.bright_white().bold() + ); + if !selected.board_name.is_empty() && selected.board_name != "Unknown" { + println!(" Board: {}", selected.board_name); + } + if selected.is_usb() { + println!(" Type: USB serial"); + } + let vp = selected.vid_pid(); + if !vp.is_empty() { + println!(" ID: {}", vp.bright_cyan()); + } + println!(); + + (selected.port_name.clone(), vp) + } + }; + + // Write .anvil.local + let local_file = project_path.join(".anvil.local"); + let mut content = String::new(); + content.push_str("# Machine-specific Anvil config (not tracked by git)\n"); + content.push_str("# Created by: anvil devices --set\n"); + content.push_str("# To change: anvil devices --set \n"); + content.push_str("# To remove: delete this file\n"); + content.push_str(&format!("port = \"{}\"\n", resolved_port)); + + if !vid_pid.is_empty() { + content.push_str(&format!("vid_pid = \"{}\"\n", vid_pid)); + } + + fs::write(&local_file, &content) + .context(format!("Failed to write {}", local_file.display()))?; + + println!( + "{} Saved port {} to {}", + "ok".green(), + resolved_port.bright_white().bold(), + ".anvil.local".bright_cyan() + ); + + if !vid_pid.is_empty() { + println!( + " Device ID: {} -- port will be tracked even if COM number changes.", + vid_pid.bright_cyan() + ); + } + + println!( + " {}", + "This file is gitignored -- each machine keeps its own." + .bright_black() + ); + + Ok(()) +} + +// -- Helpers -------------------------------------------------------------- + +fn resolve_project_dir(project_dir: Option<&str>) -> Result { + match project_dir { + Some(dir) => Ok(PathBuf::from(dir)), + None => std::env::current_dir() + .context("Could not determine current directory"), + } +} + +fn require_anvil_project(path: &Path) -> Result<()> { + let config_file = path.join(".anvil.toml"); + if !config_file.exists() { + anyhow::bail!( + "No .anvil.toml found in {}\n \ + Run this from inside an Anvil project, or use -d ", + path.display() + ); + } + Ok(()) +} + +/// Read port and vid_pid from .anvil.local +fn read_anvil_local(path: &Path) -> Result<(String, String)> { + let content = fs::read_to_string(path) + .context("Failed to read .anvil.local")?; + + let mut port = String::new(); + let mut vid_pid = String::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || !trimmed.contains('=') { + continue; + } + if let Some((key, val)) = trimmed.split_once('=') { + let k = key.trim(); + let v = val.trim().trim_matches('"'); + if k == "port" { + port = v.to_string(); + } else if k == "vid_pid" { + vid_pid = v.to_string(); + } + } + } + + Ok((port, vid_pid)) +} \ No newline at end of file diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index f25aa12..0e93039 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -43,10 +43,12 @@ pub fn run_diagnostics() -> Result<()> { } else { println!( "{}", - "Issues found. Run 'anvil setup' to fix." + "Issues found. See instructions below." .bright_yellow() .bold() ); + println!(); + print_fix_instructions(&health); } println!(); @@ -78,7 +80,7 @@ pub fn check_system_health() -> SystemHealth { let cmake_ok = which::which("cmake").is_ok(); // C++ compiler (optional -- for host tests) - let cpp_compiler_ok = which::which("g++").is_ok() || which::which("clang++").is_ok(); + let cpp_compiler_ok = has_cpp_compiler(); // git let git_ok = which::which("git").is_ok(); @@ -99,6 +101,20 @@ pub fn check_system_health() -> SystemHealth { } } +/// Check for a C++ compiler on any platform. +fn has_cpp_compiler() -> bool { + if which::which("g++").is_ok() || which::which("clang++").is_ok() { + return true; + } + #[cfg(windows)] + { + if which::which("cl").is_ok() { + return true; + } + } + false +} + fn print_diagnostics(health: &SystemHealth) { println!("{}", "Required:".bright_yellow().bold()); println!(); @@ -139,14 +155,23 @@ fn print_diagnostics(health: &SystemHealth) { println!("{}", "Optional:".bright_yellow().bold()); println!(); - // avr-size + // avr-size -- installed as part of the avr core, not a separate step if health.avr_size_ok { println!(" {} avr-size (binary size reporting)", "ok".green()); - } else { + } else if !health.avr_core_ok { println!( " {} avr-size {}", "--".bright_black(), - "install: sudo apt install gcc-avr".bright_black() + "included with arduino:avr core (no separate install)".bright_black() + ); + } 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 {}", + "--".bright_black(), + hint_avr_size_not_on_path().bright_black() ); } @@ -171,18 +196,18 @@ fn print_diagnostics(health: &SystemHealth) { println!( " {} cmake {}", "--".bright_black(), - "install: sudo apt install cmake".bright_black() + hint_cmake().bright_black() ); } // C++ compiler if health.cpp_compiler_ok { - println!(" {} C++ compiler (g++/clang++)", "ok".green()); + println!(" {} C++ compiler", "ok".green()); } else { println!( " {} C++ compiler {}", "--".bright_black(), - "install: sudo apt install g++".bright_black() + hint_cpp_compiler().bright_black() ); } @@ -193,7 +218,7 @@ fn print_diagnostics(health: &SystemHealth) { println!( " {} git {}", "--".bright_black(), - "install: sudo apt install git".bright_black() + hint_git().bright_black() ); } @@ -216,6 +241,160 @@ fn print_diagnostics(health: &SystemHealth) { } } +/// Print step-by-step fix instructions when required items are missing. +fn print_fix_instructions(health: &SystemHealth) { + println!("{}", "How to fix:".bright_cyan().bold()); + println!(); + + let mut step = 1u32; + + if !health.arduino_cli_ok { + println!( + " {}. {}", + step, + "Install arduino-cli:".bright_white().bold() + ); + if cfg!(target_os = "windows") { + println!(); + println!(" Option A -- WinGet (recommended):"); + println!(" {}", "winget install ArduinoSA.CLI".bright_cyan()); + println!(); + println!(" Option B -- Chocolatey:"); + println!(" {}", "choco install arduino-cli".bright_cyan()); + println!(); + println!(" Option C -- Direct download:"); + println!( + " {}", + "https://arduino.github.io/arduino-cli/installation/" + .bright_cyan() + ); + } else if cfg!(target_os = "macos") { + println!(" {}", "brew install arduino-cli".bright_cyan()); + } else { + println!(); + println!(" Option A -- Install script:"); + println!( + " {}", + "curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh" + .bright_cyan() + ); + println!( + " {}", + "sudo mv bin/arduino-cli /usr/local/bin/".bright_cyan() + ); + println!(); + println!(" Option B -- Package manager:"); + println!( + " {} {}", + "sudo apt install arduino-cli".bright_cyan(), + "(Debian/Ubuntu)".bright_black() + ); + println!( + " {} {}", + "yay -S arduino-cli".bright_cyan(), + "(Arch)".bright_black() + ); + } + println!(); + step += 1; + } + + if !health.arduino_cli_ok { + // They need to open a new terminal after installing arduino-cli + println!( + " {}. {}", + step, + "Close and reopen your terminal".bright_white().bold() + ); + println!( + " {}", + "(so the new PATH takes effect)".bright_black() + ); + println!(); + step += 1; + } + + if !health.avr_core_ok { + println!( + " {}. {}", + step, + "Install the AVR core and verify everything:" + .bright_white() + .bold() + ); + println!(" {}", "anvil setup".bright_cyan()); + println!(); + // step += 1; + } + + if !health.git_ok { + println!( + " {}", + "Tip: git is optional but recommended for version control." + .bright_black() + ); + if cfg!(target_os = "windows") { + println!( + " {}", + "winget install Git.Git".bright_black() + ); + } else if cfg!(target_os = "macos") { + println!( + " {}", + "xcode-select --install".bright_black() + ); + } else { + println!( + " {}", + "sudo apt install git".bright_black() + ); + } + println!(); + } +} + +// --------------------------------------------------------------------------- +// Platform-aware install hints (one-liners for the diagnostics table) +// --------------------------------------------------------------------------- + +fn hint_avr_size_not_on_path() -> &'static str { + if cfg!(target_os = "windows") { + "installed but not on PATH (binary size reports will be skipped)" + } else { + "installed but not on PATH" + } +} + +fn hint_cmake() -> &'static str { + if cfg!(target_os = "windows") { + "install: winget install Kitware.CMake (or choco install cmake)" + } else if cfg!(target_os = "macos") { + "install: brew install cmake" + } else { + "install: sudo apt install cmake" + } +} + +fn hint_cpp_compiler() -> &'static str { + if cfg!(target_os = "windows") { + "install: winget install Microsoft.VisualStudio.2022.BuildTools (or MinGW g++)" + } else if cfg!(target_os = "macos") { + "install: xcode-select --install" + } else { + "install: sudo apt install g++" + } +} + +fn hint_git() -> &'static str { + if cfg!(target_os = "windows") { + "install: winget install Git.Git (or https://git-scm.com)" + } else if cfg!(target_os = "macos") { + "install: xcode-select --install (or brew install git)" + } else { + "install: sudo apt install git" + } +} + fn check_dialout() -> bool { #[cfg(unix)] { @@ -234,4 +413,4 @@ fn check_dialout() -> bool { { true // Not applicable on Windows } -} +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d016a5a..e808bac 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,5 +2,4 @@ pub mod new; pub mod doctor; pub mod setup; pub mod devices; -pub mod build; -pub mod monitor; +pub mod refresh; \ No newline at end of file diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs deleted file mode 100644 index e1d420a..0000000 --- a/src/commands/monitor.rs +++ /dev/null @@ -1,167 +0,0 @@ -use anyhow::{Result, Context}; -use colored::*; -use std::process::Command; -use std::time::Duration; -use std::thread; - -use crate::board; - -const DEFAULT_BAUD: u32 = 115200; - -pub fn run_monitor( - port: Option<&str>, - baud: Option, - watch: bool, -) -> Result<()> { - let cli = board::find_arduino_cli() - .context("arduino-cli not found. Run: anvil setup")?; - - let baud = baud.unwrap_or(DEFAULT_BAUD); - - if watch { - run_watch(&cli, port, baud) - } else { - run_single(&cli, port, baud) - } -} - -/// Open serial monitor once. -fn run_single( - cli: &std::path::Path, - port: Option<&str>, - baud: u32, -) -> Result<()> { - let port = match port { - Some(p) => p.to_string(), - None => board::auto_detect_port()?, - }; - - println!( - "Opening serial monitor on {} at {} baud...", - port.bright_white().bold(), - baud - ); - println!("Press Ctrl+C to exit."); - println!(); - - let status = Command::new(cli) - .args([ - "monitor", - "-p", &port, - "-c", &format!("baudrate={}", baud), - ]) - .status() - .context("Failed to start serial monitor")?; - - if !status.success() { - anyhow::bail!("Serial monitor exited with error."); - } - - Ok(()) -} - -/// Persistent watch mode: reconnect after upload/reset/replug. -fn run_watch( - cli: &std::path::Path, - port_hint: Option<&str>, - baud: u32, -) -> Result<()> { - let port = match port_hint { - Some(p) => p.to_string(), - None => { - match board::auto_detect_port() { - Ok(p) => p, - Err(_) => { - let default = default_port(); - println!( - "No port detected yet. Waiting for {}...", - default - ); - default - } - } - } - }; - - println!( - "Persistent monitor on {} at {} baud", - port.bright_white().bold(), - baud - ); - println!("Reconnects automatically after upload / reset / replug."); - println!("Press Ctrl+C to exit."); - println!(); - - let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); - let r = running.clone(); - let _ = ctrlc::set_handler(move || { - r.store(false, std::sync::atomic::Ordering::Relaxed); - }); - - while running.load(std::sync::atomic::Ordering::Relaxed) { - if !port_exists(&port) { - println!( - "{}", - format!("--- Waiting for {} ...", port).bright_black() - ); - while !port_exists(&port) - && running.load(std::sync::atomic::Ordering::Relaxed) - { - thread::sleep(Duration::from_millis(500)); - } - - if !running.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - - // Settle time - thread::sleep(Duration::from_secs(1)); - println!("{}", format!("--- {} connected ---", port).green()); - } - - let _ = Command::new(cli.as_os_str()) - .args([ - "monitor", - "-p", &port, - "-c", &format!("baudrate={}", baud), - ]) - .status(); - - if !running.load(std::sync::atomic::Ordering::Relaxed) { - break; - } - - println!( - "{}", - format!("--- {} disconnected ---", port).yellow() - ); - thread::sleep(Duration::from_millis(500)); - } - - println!(); - println!("Monitor stopped."); - Ok(()) -} - -fn port_exists(port: &str) -> bool { - #[cfg(unix)] - { - std::path::Path::new(port).exists() - } - - #[cfg(windows)] - { - // On Windows, check if the port appears in current device list - board::list_ports() - .iter() - .any(|p| p.port_name == port) - } -} - -fn default_port() -> String { - if cfg!(target_os = "windows") { - "COM3".to_string() - } else { - "/dev/ttyUSB0".to_string() - } -} diff --git a/src/commands/new.rs b/src/commands/new.rs index 338a938..aa05a42 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -177,7 +177,12 @@ fn init_git(project_dir: &PathBuf, template_name: &str) { fn make_executable(project_dir: &PathBuf) { use std::os::unix::fs::PermissionsExt; - let scripts = ["test/run_tests.sh"]; + let scripts = [ + "build.sh", + "upload.sh", + "monitor.sh", + "test/run_tests.sh", + ]; for script in &scripts { let path = project_dir.join(script); if path.exists() { @@ -196,23 +201,67 @@ fn print_next_steps(project_name: &str) { " 1. {}", format!("cd {}", project_name).bright_cyan() ); - println!(" 2. Check your system: {}", "anvil doctor".bright_cyan()); + + if cfg!(target_os = "windows") { + println!( + " 2. Compile: {}", + "build.bat".bright_cyan() + ); + println!( + " 3. Upload to board: {}", + "upload.bat".bright_cyan() + ); + println!( + " 4. Upload + monitor: {}", + "upload.bat --monitor".bright_cyan() + ); + println!( + " 5. Serial monitor: {}", + "monitor.bat".bright_cyan() + ); + println!( + " 6. Run host tests: {}", + "test\\run_tests.bat".bright_cyan() + ); + println!(); + println!( + " {}", + "On Linux/macOS: ./build.sh, ./upload.sh, ./monitor.sh" + .bright_black() + ); + } else { + println!( + " 2. Compile: {}", + "./build.sh".bright_cyan() + ); + println!( + " 3. Upload to board: {}", + "./upload.sh".bright_cyan() + ); + println!( + " 4. Upload + monitor: {}", + "./upload.sh --monitor".bright_cyan() + ); + println!( + " 5. Serial monitor: {}", + "./monitor.sh".bright_cyan() + ); + println!( + " 6. Run host tests: {}", + "./test/run_tests.sh".bright_cyan() + ); + println!(); + println!( + " {}", + "On Windows: build.bat, upload.bat, monitor.bat, test\\run_tests.bat" + .bright_black() + ); + } + println!( - " 3. Find your board: {}", - "anvil devices".bright_cyan() - ); - println!( - " 4. Build and upload: {}", - format!("anvil build {}", project_name).bright_cyan() - ); - println!( - " 5. Build + monitor: {}", - format!("anvil build --monitor {}", project_name).bright_cyan() - ); - println!(); - println!( - " Run host tests: {}", - "cd test && ./run_tests.sh".bright_cyan() + " {}", + "System check: anvil doctor | Port scan: anvil devices" + .bright_black() ); println!(); } @@ -249,4 +298,4 @@ mod tests { let long_name = "a".repeat(51); assert!(validate_project_name(&long_name).is_err()); } -} +} \ No newline at end of file diff --git a/src/commands/refresh.rs b/src/commands/refresh.rs new file mode 100644 index 0000000..e3e25ff --- /dev/null +++ b/src/commands/refresh.rs @@ -0,0 +1,191 @@ +use anyhow::{Result, Context}; +use colored::*; +use std::path::PathBuf; +use std::fs; + +use crate::project::config::ProjectConfig; +use crate::templates::{TemplateManager, TemplateContext}; +use crate::version::ANVIL_VERSION; + +/// Files that anvil owns and can safely refresh. +/// These are build/deploy infrastructure -- not user source code. +const REFRESHABLE_FILES: &[&str] = &[ + "build.sh", + "build.bat", + "upload.sh", + "upload.bat", + "monitor.sh", + "monitor.bat", + "_detect_port.ps1", + "test/run_tests.sh", + "test/run_tests.bat", +]; + +pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> { + // Resolve project directory + let project_path = match project_dir { + Some(dir) => PathBuf::from(dir), + None => std::env::current_dir() + .context("Could not determine current directory")?, + }; + + let project_root = ProjectConfig::find_project_root(&project_path)?; + let config = ProjectConfig::load(&project_root)?; + + println!( + "Refreshing project: {}", + config.project.name.bright_white().bold() + ); + println!( + "Project directory: {}", + project_root.display().to_string().bright_black() + ); + println!(); + + // Generate fresh copies of all refreshable files from the template + let template_name = "basic"; + let context = TemplateContext { + project_name: config.project.name.clone(), + anvil_version: ANVIL_VERSION.to_string(), + }; + + // Extract template into a temp directory so we can compare + let temp_dir = tempfile::tempdir() + .context("Failed to create temp directory")?; + TemplateManager::extract(template_name, temp_dir.path(), &context)?; + + // Compare each refreshable file + let mut up_to_date = Vec::new(); + let mut will_create = Vec::new(); + let mut has_changes = Vec::new(); + + for &filename in REFRESHABLE_FILES { + let existing = project_root.join(filename); + let fresh = temp_dir.path().join(filename); + + if !fresh.exists() { + // Template doesn't produce this file (shouldn't happen) + continue; + } + + let fresh_content = fs::read(&fresh) + .context(format!("Failed to read template file: {}", filename))?; + + if !existing.exists() { + will_create.push(filename); + continue; + } + + let existing_content = fs::read(&existing) + .context(format!("Failed to read project file: {}", filename))?; + + if existing_content == fresh_content { + up_to_date.push(filename); + } else { + has_changes.push(filename); + } + } + + // Report status + if !up_to_date.is_empty() { + println!( + "{} {} file(s) already up to date", + "ok".green(), + up_to_date.len() + ); + } + + if !will_create.is_empty() { + for f in &will_create { + println!(" {} {} (new)", "+".bright_green(), f.bright_white()); + } + } + + if !has_changes.is_empty() { + for f in &has_changes { + println!( + " {} {} (differs from latest)", + "~".bright_yellow(), + f.bright_white() + ); + } + } + + // Decide what to do + if has_changes.is_empty() && will_create.is_empty() { + println!(); + println!( + "{}", + "All scripts are up to date. Nothing to do." + .bright_green() + .bold() + ); + return Ok(()); + } + + if !has_changes.is_empty() && !force { + println!(); + println!( + "{} {} script(s) differ from the latest Anvil templates.", + "!".bright_yellow(), + has_changes.len() + ); + println!( + "This is normal after upgrading Anvil. To update them, run:" + ); + println!(); + println!(" {}", "anvil refresh --force".bright_cyan()); + println!(); + println!( + " {}", + "Only build scripts are replaced. Your .anvil.toml and source code are never touched." + .bright_black() + ); + return Ok(()); + } + + // Apply updates + let files_to_write: Vec<&str> = if force { + will_create.iter().chain(has_changes.iter()).copied().collect() + } else { + will_create.to_vec() + }; + + for filename in &files_to_write { + let fresh = temp_dir.path().join(filename); + let dest = project_root.join(filename); + + // Ensure parent directory exists + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + + fs::copy(&fresh, &dest) + .context(format!("Failed to write: {}", filename))?; + } + + // Make shell scripts executable on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for filename in &files_to_write { + if filename.ends_with(".sh") { + let path = project_root.join(filename); + if let Ok(meta) = fs::metadata(&path) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = fs::set_permissions(&path, perms); + } + } + } + } + + println!(); + println!( + "{} Updated {} file(s).", + "ok".green(), + files_to_write.len() + ); + + Ok(()) +} \ No newline at end of file diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 26cfd3b..be333ab 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -78,11 +78,22 @@ pub fn run_setup() -> Result<()> { if which::which("avr-size").is_ok() { println!(" {} avr-size (binary size reporting)", "ok".green()); } else { - println!( - " {} avr-size not found. Install for binary size details:", - "info".bright_black() - ); - println!(" sudo apt install gcc-avr"); + print_optional_hint("avr-size", hint_avr_size()); + } + + if which::which("cmake").is_ok() { + println!(" {} cmake (for host-side tests)", "ok".green()); + } else { + print_optional_hint("cmake", hint_cmake()); + } + + if which::which("g++").is_ok() + || which::which("clang++").is_ok() + || cfg!(windows) && which::which("cl").is_ok() + { + println!(" {} C++ compiler", "ok".green()); + } else { + print_optional_hint("C++ compiler", hint_cpp_compiler()); } #[cfg(unix)] @@ -128,15 +139,31 @@ pub fn run_setup() -> Result<()> { println!(" 1. Plug in your RedBoard"); println!(" 2. {}", "anvil devices".bright_cyan()); println!(" 3. {}", "anvil new blink".bright_cyan()); - println!( - " 4. {}", - "cd blink && anvil build blink".bright_cyan() - ); + if cfg!(target_os = "windows") { + println!( + " 4. {}", + "cd blink && build.bat".bright_cyan() + ); + } else { + println!( + " 4. {}", + "cd blink && ./build.sh".bright_cyan() + ); + } println!(); Ok(()) } +fn print_optional_hint(name: &str, hint: &str) { + println!( + " {} {} not found. Install for full functionality:", + "info".bright_black(), + name + ); + println!(" {}", hint); +} + fn print_install_instructions() { println!("{}", "Install arduino-cli:".bright_yellow().bold()); println!(); @@ -161,3 +188,37 @@ fn print_install_instructions() { println!(); println!(" Then re-run: anvil setup"); } + +// --------------------------------------------------------------------------- +// Platform-aware install hints +// --------------------------------------------------------------------------- + +fn hint_avr_size() -> &'static str { + if cfg!(target_os = "windows") { + "bundled with arduino:avr core (avr-size.exe in Arduino15 packages)" + } else if cfg!(target_os = "macos") { + "brew install avr-gcc" + } else { + "sudo apt install gcc-avr" + } +} + +fn hint_cmake() -> &'static str { + if cfg!(target_os = "windows") { + "winget install Kitware.CMake (or choco install cmake)" + } else if cfg!(target_os = "macos") { + "brew install cmake" + } else { + "sudo apt install cmake" + } +} + +fn hint_cpp_compiler() -> &'static str { + if cfg!(target_os = "windows") { + "winget install Microsoft.VisualStudio.2022.BuildTools (or MinGW g++)" + } else if cfg!(target_os = "macos") { + "xcode-select --install" + } else { + "sudo apt install g++" + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 48d52fe..9875859 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,73 +43,31 @@ enum Commands { Setup, /// List connected boards and serial ports - Devices, + Devices { + /// Save a port to .anvil.local for this project + #[arg(long, conflicts_with = "get")] + set: bool, - /// Compile a sketch (and optionally upload) - Build { - /// Path to sketch directory - sketch: String, + /// Show the saved port for this project + #[arg(long, conflicts_with = "set")] + get: bool, - /// Compile only -- do not upload - #[arg(long)] - verify: bool, + /// Port name (e.g. COM3, /dev/ttyUSB0). Auto-detects if omitted with --set. + port_or_dir: Option, - /// Open serial monitor after upload - #[arg(long)] - monitor: bool, - - /// Delete cached build artifacts first - #[arg(long)] - clean: bool, - - /// Show full compiler output - #[arg(long)] - verbose: bool, - - /// Serial port (auto-detected if omitted) - #[arg(short, long)] - port: Option, - - /// Serial monitor baud rate - #[arg(short, long)] - baud: Option, - - /// Override Fully Qualified Board Name - #[arg(long)] - fqbn: Option, + /// Path to project directory (defaults to current directory) + #[arg(long, short = 'd', value_name = "DIR")] + dir: Option, }, - /// Upload cached build artifacts (no recompile) - Upload { - /// Path to sketch directory - sketch: String, + /// Update project scripts to the latest version + Refresh { + /// Path to project directory (defaults to current directory) + dir: Option, - /// Serial port (auto-detected if omitted) - #[arg(short, long)] - port: Option, - - /// Show full avrdude output + /// Overwrite scripts even if they have been modified #[arg(long)] - verbose: bool, - - /// Override Fully Qualified Board Name - #[arg(long)] - fqbn: Option, - }, - - /// Open serial monitor - Monitor { - /// Serial port (auto-detected if omitted) - #[arg(short, long)] - port: Option, - - /// Baud rate (default: from project config or 115200) - #[arg(short, long)] - baud: Option, - - /// Persistent mode: reconnect after upload/reset/replug - #[arg(long)] - watch: bool, + force: bool, }, } @@ -133,7 +91,7 @@ fn main() -> Result<()> { } else { anyhow::bail!( "Project name required.\n\ - Usage: anvil new \n\ + Usage: anvil new \n\ List templates: anvil new --list-templates" ); } @@ -144,31 +102,24 @@ fn main() -> Result<()> { Commands::Setup => { commands::setup::run_setup() } - Commands::Devices => { - commands::devices::scan_devices() + Commands::Devices { set, get, port_or_dir, dir } => { + if set { + commands::devices::set_port( + port_or_dir.as_deref(), + dir.as_deref(), + ) + } else if get { + commands::devices::get_port( + dir.as_deref().or(port_or_dir.as_deref()), + ) + } else { + commands::devices::scan_devices() + } } - Commands::Build { - sketch, verify, monitor, clean, verbose, - port, baud, fqbn, - } => { - commands::build::run_build( - &sketch, verify, monitor, clean, verbose, - port.as_deref(), baud, fqbn.as_deref(), - ) - } - Commands::Upload { sketch, port, verbose, fqbn } => { - commands::build::run_upload_only( - &sketch, - port.as_deref(), - verbose, - fqbn.as_deref(), - ) - } - Commands::Monitor { port, baud, watch } => { - commands::monitor::run_monitor( - port.as_deref(), - baud, - watch, + Commands::Refresh { dir, force } => { + commands::refresh::run_refresh( + dir.as_deref(), + force, ) } } @@ -193,4 +144,4 @@ fn print_banner() { .bright_cyan() ); println!(); -} +} \ No newline at end of file diff --git a/src/project/config.rs b/src/project/config.rs index f1981eb..8691edf 100644 --- a/src/project/config.rs +++ b/src/project/config.rs @@ -31,6 +31,8 @@ pub struct BuildConfig { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MonitorConfig { pub baud: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, } impl ProjectConfig { @@ -49,6 +51,7 @@ impl ProjectConfig { }, monitor: MonitorConfig { baud: 115200, + port: None, }, } } @@ -143,13 +146,6 @@ pub fn anvil_home() -> Result { Ok(anvil_dir) } -/// Return the build cache directory (~/.anvil/builds). -pub fn build_cache_dir() -> Result { - let dir = anvil_home()?.join("builds"); - fs::create_dir_all(&dir)?; - Ok(dir) -} - #[cfg(test)] mod tests { use super::*; @@ -223,4 +219,4 @@ mod tests { assert!(flags.contains("-Werror")); assert!(flags.contains("-I")); } -} +} \ No newline at end of file diff --git a/templates/basic/README.md.tmpl b/templates/basic/README.md.tmpl index 4bae2aa..ba591aa 100644 --- a/templates/basic/README.md.tmpl +++ b/templates/basic/README.md.tmpl @@ -1,29 +1,38 @@ # {{PROJECT_NAME}} -Arduino project generated by Anvil v{{ANVIL_VERSION}}. +Arduino project generated by [Anvil](https://github.com/nexusworkshops/anvil) v{{ANVIL_VERSION}}. + +This project is self-contained. After creation, it only needs `arduino-cli` +in PATH -- the Anvil binary is not required for day-to-day work. ## Quick Start ```bash -# Check your system -anvil doctor +# Compile only (verify) +./build.sh -# Find connected boards -anvil devices - -# Compile only (no upload) -anvil build --verify {{PROJECT_NAME}} - -# Compile and upload -anvil build {{PROJECT_NAME}} +# Compile and upload to board +./upload.sh # Compile, upload, and open serial monitor -anvil build --monitor {{PROJECT_NAME}} +./upload.sh --monitor + +# Open serial monitor (no compile) +./monitor.sh + +# Persistent monitor (reconnects after reset/replug) +./monitor.sh --watch # Run host-side unit tests (no board needed) -cd test && ./run_tests.sh +./test/run_tests.sh ``` +On Windows, use `build.bat`, `upload.bat`, `monitor.bat`, and +`test\run_tests.bat` instead. + +All scripts read settings from `.anvil.toml` -- edit it to change +the board, baud rate, include paths, or compiler flags. + ## Project Structure ``` @@ -44,6 +53,9 @@ cd test && ./run_tests.sh CMakeLists.txt Test build system run_tests.sh Test runner (Linux/Mac) run_tests.bat Test runner (Windows) + build.sh / build.bat Compile sketch + upload.sh / upload.bat Compile + upload to board + monitor.sh / monitor.bat Serial monitor .anvil.toml Project configuration ``` @@ -52,7 +64,7 @@ cd test && ./run_tests.sh All hardware access goes through the `Hal` interface. The app code (`lib/app/`) depends only on `Hal`, never on `Arduino.h` directly. This means the app can be compiled and tested on the host without -any Arduino SDK. +any Arduino hardware. Two HAL implementations: - `ArduinoHal` -- passthroughs to real hardware (used in the .ino) @@ -72,3 +84,9 @@ extra_flags = ["-Werror"] [monitor] baud = 115200 ``` + +## Prerequisites + +- `arduino-cli` in PATH with `arduino:avr` core installed +- For host tests: `cmake`, `g++` (or `clang++`), `git` +- Install everything at once: `anvil setup` diff --git a/templates/basic/_detect_port.ps1 b/templates/basic/_detect_port.ps1 new file mode 100644 index 0000000..a6f867a --- /dev/null +++ b/templates/basic/_detect_port.ps1 @@ -0,0 +1,78 @@ +# _detect_port.ps1 -- Detect the best serial port via arduino-cli +# +# Called by upload.bat and monitor.bat. Outputs a single port name +# (e.g. COM3) or nothing if no port is found. +# +# If .anvil.local contains a vid_pid, resolves the device to its +# current COM port (handles reassignment after replug). +# Falls back to saved port, then auto-detect. + +param( + [string]$VidPid = "", + [string]$SavedPort = "" +) + +$ErrorActionPreference = 'SilentlyContinue' + +$raw = arduino-cli board list --format json 2>$null +if (-not $raw) { + # No arduino-cli or no output; fall back to saved port + if ($SavedPort) { Write-Output $SavedPort } + exit +} + +$data = $raw | ConvertFrom-Json +if (-not $data.detected_ports) { + if ($SavedPort) { Write-Output $SavedPort } + exit +} + +$serial = $data.detected_ports | Where-Object { $_.port.protocol -eq 'serial' } +if (-not $serial) { + if ($SavedPort) { Write-Output $SavedPort } + exit +} + +# -- Try VID:PID resolution first ------------------------------------------ +if ($VidPid) { + $parts = $VidPid -split ':' + if ($parts.Count -eq 2) { + $targetVid = $parts[0].ToLower() + $targetPid = $parts[1].ToLower() + + foreach ($p in $serial) { + $pVid = ($p.port.properties.vid -replace '^0x','').ToLower() + $pPid = ($p.port.properties.pid -replace '^0x','').ToLower() + if ($pVid -eq $targetVid -and $pPid -eq $targetPid) { + Write-Output $p.port.address + exit + } + } + + # VID:PID not found -- device not connected + # Fall through to saved port / auto-detect + } +} + +# -- Fall back to saved port if it exists on the system -------------------- +if ($SavedPort) { + $found = $serial | Where-Object { $_.port.address -eq $SavedPort } | Select-Object -First 1 + if ($found) { + Write-Output $SavedPort + exit + } + # Saved port not present either; fall through to auto-detect +} + +# -- Auto-detect: prefer USB serial ports ---------------------------------- +$usb = $serial | Where-Object { $_.port.protocol_label -like '*USB*' } | Select-Object -First 1 +if ($usb) { + Write-Output $usb.port.address + exit +} + +# Fall back to any serial port +$any = $serial | Select-Object -First 1 +if ($any) { + Write-Output $any.port.address +} \ No newline at end of file diff --git a/templates/basic/_dot_gitignore b/templates/basic/_dot_gitignore index 36539fa..e90b836 100644 --- a/templates/basic/_dot_gitignore +++ b/templates/basic/_dot_gitignore @@ -1,6 +1,10 @@ # Build artifacts +.build/ test/build/ +# Machine-specific config (created by: anvil devices --set) +.anvil.local + # IDE .vscode/.browse* .vscode/*.log diff --git a/templates/basic/build.bat b/templates/basic/build.bat new file mode 100644 index 0000000..26db5f3 --- /dev/null +++ b/templates/basic/build.bat @@ -0,0 +1,112 @@ +@echo off +setlocal enabledelayedexpansion + +:: build.bat -- Compile the sketch using arduino-cli +:: +:: 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 + +set "SCRIPT_DIR=%~dp0" +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 +for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( + set "_K=%%a" + if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" ( + set "_K=!_K: =!" + set "_V=%%b" + if defined _V ( + set "_V=!_V: =!" + set "_V=!_V:"=!" + ) + if "!_K!"=="name" set "SKETCH_NAME=!_V!" + if "!_K!"=="fqbn" set "FQBN=!_V!" + if "!_K!"=="warnings" set "WARNINGS=!_V!" + ) +) + +if "%SKETCH_NAME%"=="" ( + echo FAIL: Could not read project name from .anvil.toml + exit /b 1 +) + +set "SKETCH_DIR=%SCRIPT_DIR%%SKETCH_NAME%" +set "BUILD_DIR=%SCRIPT_DIR%.build" + +:: -- Parse arguments ------------------------------------------------------ +set "DO_CLEAN=0" +set "VERBOSE=" + +:parse_args +if "%~1"=="" goto done_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 +if "%~1"=="-h" goto show_help +echo FAIL: Unknown option: %~1 +exit /b 1 + +:show_help +echo Usage: build.bat [--clean] [--verbose] +echo Compiles the sketch. Settings from .anvil.toml. +exit /b 0 + +:done_args + +:: -- Preflight ------------------------------------------------------------ +where arduino-cli >nul 2>nul +if errorlevel 1 ( + echo FAIL: arduino-cli not found in PATH. + exit /b 1 +) + +if not exist "%SKETCH_DIR%" ( + echo FAIL: Sketch directory not found: %SKETCH_DIR% + exit /b 1 +) + +:: -- Clean ---------------------------------------------------------------- +if "%DO_CLEAN%"=="1" ( + if exist "%BUILD_DIR%" ( + echo Cleaning build cache... + rmdir /s /q "%BUILD_DIR%" + ) +) + +:: -- 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" + ) +) +set "BUILD_FLAGS=!BUILD_FLAGS! -Werror" + +:: -- Compile -------------------------------------------------------------- +echo Compiling %SKETCH_NAME%... +echo Board: %FQBN% +echo Sketch: %SKETCH_DIR% +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%" +if errorlevel 1 ( + echo. + echo FAIL: Compilation failed. + exit /b 1 +) + +echo. +echo ok Compile succeeded. +echo. \ No newline at end of file diff --git a/templates/basic/build.sh b/templates/basic/build.sh new file mode 100644 index 0000000..e796bd5 --- /dev/null +++ b/templates/basic/build.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# build.sh -- Compile the sketch using arduino-cli +# +# Reads all settings from .anvil.toml. No Anvil binary required. +# +# Usage: +# ./build.sh Compile (verify only) +# ./build.sh --clean Delete build cache first +# ./build.sh --verbose Show full compiler output +# +# Prerequisites: arduino-cli in PATH, arduino:avr core installed +# Install: anvil setup (or manually: arduino-cli core install arduino:avr) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG="$SCRIPT_DIR/.anvil.toml" + +# -- Colors ---------------------------------------------------------------- +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GRN=$'\033[0;32m'; YLW=$'\033[0;33m' + CYN=$'\033[0;36m'; BLD=$'\033[1m'; RST=$'\033[0m' +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; } + +# -- 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 ' ' +} + +SKETCH_NAME="$(toml_get 'name')" +FQBN="$(toml_get 'fqbn')" +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" + +SKETCH_DIR="$SCRIPT_DIR/$SKETCH_NAME" +BUILD_DIR="$SCRIPT_DIR/.build" + +# -- Parse arguments ------------------------------------------------------- +DO_CLEAN=0 +VERBOSE="" + +for arg in "$@"; do + case "$arg" in + --clean) DO_CLEAN=1 ;; + --verbose) VERBOSE="--verbose" ;; + -h|--help) + echo "Usage: ./build.sh [--clean] [--verbose]" + echo " Compiles the sketch. Settings from .anvil.toml." + exit 0 + ;; + *) die "Unknown option: $arg" ;; + esac +done + +# -- Preflight ------------------------------------------------------------- +command -v arduino-cli &>/dev/null \ + || die "arduino-cli not found in PATH. Install it first." + +[[ -d "$SKETCH_DIR" ]] \ + || die "Sketch directory not found: $SKETCH_DIR" + +[[ -f "$SKETCH_DIR/$SKETCH_NAME.ino" ]] \ + || die "Sketch file not found: $SKETCH_DIR/$SKETCH_NAME.ino" + +# -- Clean ----------------------------------------------------------------- +if [[ $DO_CLEAN -eq 1 ]] && [[ -d "$BUILD_DIR" ]]; then + echo "${YLW}Cleaning build cache...${RST}" + rm -rf "$BUILD_DIR" + ok "Cache cleared." +fi + +# -- Build include flags --------------------------------------------------- +BUILD_FLAGS="" +for dir in $INCLUDE_DIRS; do + abs="$SCRIPT_DIR/$dir" + if [[ -d "$abs" ]]; then + BUILD_FLAGS="$BUILD_FLAGS -I$abs" + else + warn "Include directory not found: $dir" + fi +done +for flag in $EXTRA_FLAGS; do + BUILD_FLAGS="$BUILD_FLAGS $flag" +done + +# -- Compile --------------------------------------------------------------- +echo "${CYN}${BLD}Compiling ${SKETCH_NAME}...${RST}" +echo " Board: $FQBN" +echo " Sketch: $SKETCH_DIR" +echo "" + +mkdir -p "$BUILD_DIR" + +COMPILE_ARGS=( + compile + --fqbn "$FQBN" + --build-path "$BUILD_DIR" + --warnings "$WARNINGS" +) + +if [[ -n "$BUILD_FLAGS" ]]; then + COMPILE_ARGS+=(--build-property "build.extra_flags=$BUILD_FLAGS") +fi + +if [[ -n "$VERBOSE" ]]; then + COMPILE_ARGS+=("$VERBOSE") +fi + +COMPILE_ARGS+=("$SKETCH_DIR") + +arduino-cli "${COMPILE_ARGS[@]}" || die "Compilation failed." + +echo "" +ok "Compile succeeded." + +# -- Binary size ----------------------------------------------------------- +ELF="$BUILD_DIR/$SKETCH_NAME.ino.elf" +if [[ -f "$ELF" ]] && command -v avr-size &>/dev/null; then + echo "" + avr-size --mcu=atmega328p -C "$ELF" +fi + +echo "" diff --git a/templates/basic/monitor.bat b/templates/basic/monitor.bat new file mode 100644 index 0000000..19b257f --- /dev/null +++ b/templates/basic/monitor.bat @@ -0,0 +1,113 @@ +@echo off +setlocal enabledelayedexpansion + +:: monitor.bat -- Open the serial monitor +:: +:: Reads baud rate from .anvil.toml. No Anvil binary required. +:: +:: Usage: +:: monitor.bat Open monitor (auto-detect port) +:: monitor.bat -p COM3 Specify port +:: monitor.bat -b 9600 Override baud rate + +set "SCRIPT_DIR=%~dp0" +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 ---------------------------------------------------- +for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( + set "_K=%%a" + if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" ( + set "_K=!_K: =!" + set "_V=%%b" + if defined _V ( + set "_V=!_V: =!" + set "_V=!_V:"=!" + ) + if "!_K!"=="baud" set "BAUD=!_V!" + ) +) + +:: -- Parse .anvil.local (machine-specific, not in git) -------------------- +set "LOCAL_PORT=" +set "LOCAL_VID_PID=" +if exist "%LOCAL_CONFIG%" ( + for /f "usebackq tokens=1,* delims==" %%a in ("%LOCAL_CONFIG%") do ( + set "_K=%%a" + if not "!_K:~0,1!"=="#" ( + set "_K=!_K: =!" + set "_V=%%b" + if defined _V ( + set "_V=!_V: =!" + set "_V=!_V:"=!" + ) + if "!_K!"=="port" set "LOCAL_PORT=!_V!" + if "!_K!"=="vid_pid" set "LOCAL_VID_PID=!_V!" + ) + ) +) + +if "%BAUD%"=="" set "BAUD=115200" + +:: -- Parse arguments ------------------------------------------------------ +set "PORT=" + +: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"=="-b" set "BAUD=%~2" & shift & shift & goto parse_args +if "%~1"=="--baud" set "BAUD=%~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 Opens serial monitor. Baud rate from .anvil.toml. +exit /b 0 + +:done_args + +:: -- Preflight ------------------------------------------------------------ +where arduino-cli >nul 2>nul +if errorlevel 1 ( + echo FAIL: arduino-cli not found in PATH. + exit /b 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 ( + if "!PORT!"=="" set "PORT=%%p" + ) + if "!PORT!"=="" ( + echo FAIL: No serial port detected. Specify with: monitor.bat -p COM3 + echo Or save a default: anvil devices --set + exit /b 1 + ) + if not "%LOCAL_VID_PID%"=="" ( + if not "!PORT!"=="%LOCAL_PORT%" ( + echo info Device %LOCAL_VID_PID% found on !PORT! ^(moved from %LOCAL_PORT%^) + ) else ( + echo info Using port !PORT! ^(from .anvil.local^) + ) + ) else if not "%LOCAL_PORT%"=="" ( + echo info Using port !PORT! ^(from .anvil.local^) + ) else ( + echo warn Auto-detected port: !PORT! ^(use -p to override, or: anvil devices --set^) + ) +) + +:: -- Monitor -------------------------------------------------------------- +echo Opening serial monitor on %PORT% at %BAUD% baud... +echo Press Ctrl+C to exit. +echo. +arduino-cli monitor -p %PORT% -c "baudrate=%BAUD%" \ No newline at end of file diff --git a/templates/basic/monitor.sh b/templates/basic/monitor.sh new file mode 100644 index 0000000..ebcc2bd --- /dev/null +++ b/templates/basic/monitor.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# +# monitor.sh -- Open the serial monitor +# +# Reads baud rate from .anvil.toml. No Anvil binary required. +# +# Usage: +# ./monitor.sh Auto-detect port +# ./monitor.sh -p /dev/ttyUSB0 Specify port +# ./monitor.sh -b 9600 Override baud rate +# ./monitor.sh --watch Reconnect after reset/replug +# +# Prerequisites: arduino-cli in PATH + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG="$SCRIPT_DIR/.anvil.toml" + +# -- Colors ---------------------------------------------------------------- +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GRN=$'\033[0;32m'; YLW=$'\033[0;33m' + CYN=$'\033[0;36m'; BLD=$'\033[1m'; RST=$'\033[0m' +else + RED=''; GRN=''; YLW=''; CYN=''; BLD=''; RST='' +fi + +warn() { echo "${YLW}warn${RST} $*"; } +die() { echo "${RED}FAIL${RST} $*" >&2; exit 1; } + +# -- Parse .anvil.toml ----------------------------------------------------- +[[ -f "$CONFIG" ]] || die "No .anvil.toml found in $SCRIPT_DIR" + +toml_get() { + (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' +} + +BAUD="$(toml_get 'baud')" +BAUD="${BAUD:-115200}" +LOCAL_CONFIG="$SCRIPT_DIR/.anvil.local" +LOCAL_PORT="" +LOCAL_VID_PID="" +if [[ -f "$LOCAL_CONFIG" ]]; then + LOCAL_PORT="$(grep '^port ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')" + LOCAL_VID_PID="$(grep '^vid_pid ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')" +fi + +# -- Parse arguments ------------------------------------------------------- +PORT="" +DO_WATCH=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -p|--port) PORT="$2"; shift 2 ;; + -b|--baud) BAUD="$2"; shift 2 ;; + --watch) DO_WATCH=1; shift ;; + -h|--help) + echo "Usage: ./monitor.sh [-p PORT] [-b BAUD] [--watch]" + echo " Opens serial monitor. Baud rate from .anvil.toml." + exit 0 + ;; + *) die "Unknown option: $1" ;; + esac +done + +# -- 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" \ + | awk '{print $1}' \ + | 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" \ + | head -1 \ + | awk '{print $1}') + fi + + 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:]')" + target_pid="$(echo "$1" | cut -d: -f2 | tr '[:upper:]' '[:lower:]')" + json="$(arduino-cli board list --format json 2>/dev/null)" || return + echo "$json" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + for dp in data.get('detected_ports', []): + port = dp.get('port', {}) + if port.get('protocol') != 'serial': + continue + props = port.get('properties', {}) + vid = props.get('vid', '').lower().replace('0x', '') + pid = props.get('pid', '').lower().replace('0x', '') + if vid == '$target_vid' and pid == '$target_pid': + print(port.get('address', '')) + break +except: pass +" 2>/dev/null +} + +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 + if [[ "$PORT" != "$LOCAL_PORT" ]] && [[ -n "$LOCAL_PORT" ]]; then + warn "Device $LOCAL_VID_PID found on $PORT (moved from $LOCAL_PORT)" + else + warn "Using port $PORT (from .anvil.local)" + fi + 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)" + + if [[ -z "$PORT" ]]; then + die "No serial port detected. Is the board plugged in?\n Specify manually: ./monitor.sh -p /dev/ttyUSB0\n Or save a default: anvil devices --set" + fi + warn "Auto-detected port: $PORT (use -p to override, or: anvil devices --set)" + fi +fi + +# -- Watch mode ------------------------------------------------------------ +if [[ $DO_WATCH -eq 1 ]]; then + echo "${CYN}${BLD}Persistent monitor on ${PORT} at ${BAUD} baud${RST}" + echo "Reconnects after upload / reset / replug." + echo "Press Ctrl+C to exit." + echo "" + + trap "echo ''; echo 'Monitor stopped.'; exit 0" INT + + while true; do + if [[ -e "$PORT" ]]; then + arduino-cli monitor -p "$PORT" -c "baudrate=$BAUD" 2>/dev/null || true + echo "${YLW}--- ${PORT} disconnected ---${RST}" + else + echo "${CYN}--- Waiting for ${PORT} ...${RST}" + while [[ ! -e "$PORT" ]]; do + sleep 0.5 + done + sleep 1 + echo "${GRN}--- ${PORT} connected ---${RST}" + fi + sleep 0.5 + done +else + echo "Opening serial monitor on $PORT at $BAUD baud..." + echo "Press Ctrl+C to exit." + echo "" + arduino-cli monitor -p "$PORT" -c "baudrate=$BAUD" +fi \ No newline at end of file diff --git a/templates/basic/upload.bat b/templates/basic/upload.bat new file mode 100644 index 0000000..98d045c --- /dev/null +++ b/templates/basic/upload.bat @@ -0,0 +1,169 @@ +@echo off +setlocal enabledelayedexpansion + +:: upload.bat -- Compile and upload the sketch to the board +:: +:: Reads all settings from .anvil.toml. No Anvil binary required. +:: +:: Usage: +:: upload.bat Auto-detect port, compile + upload +:: upload.bat -p COM3 Specify port +:: 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" + +if not exist "%CONFIG%" ( + echo FAIL: No .anvil.toml found in %SCRIPT_DIR% + exit /b 1 +) + +:: -- Parse .anvil.toml ---------------------------------------------------- +for /f "usebackq tokens=1,* delims==" %%a in ("%CONFIG%") do ( + set "_K=%%a" + if not "!_K:~0,1!"=="#" if not "!_K:~0,1!"=="[" ( + set "_K=!_K: =!" + set "_V=%%b" + if defined _V ( + set "_V=!_V: =!" + set "_V=!_V:"=!" + ) + if "!_K!"=="name" set "SKETCH_NAME=!_V!" + if "!_K!"=="fqbn" set "FQBN=!_V!" + if "!_K!"=="warnings" set "WARNINGS=!_V!" + if "!_K!"=="baud" set "BAUD=!_V!" + ) +) + +:: -- Parse .anvil.local (machine-specific, not in git) -------------------- +set "LOCAL_PORT=" +set "LOCAL_VID_PID=" +if exist "%LOCAL_CONFIG%" ( + for /f "usebackq tokens=1,* delims==" %%a in ("%LOCAL_CONFIG%") do ( + set "_K=%%a" + if not "!_K:~0,1!"=="#" ( + set "_K=!_K: =!" + set "_V=%%b" + if defined _V ( + set "_V=!_V: =!" + set "_V=!_V:"=!" + ) + if "!_K!"=="port" set "LOCAL_PORT=!_V!" + if "!_K!"=="vid_pid" set "LOCAL_VID_PID=!_V!" + ) + ) +) + +if "%SKETCH_NAME%"=="" ( + echo FAIL: Could not read project name from .anvil.toml + exit /b 1 +) +if "%BAUD%"=="" set "BAUD=115200" + +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=" + +: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"=="--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 +if "%~1"=="--help" goto show_help +if "%~1"=="-h" goto show_help +echo FAIL: Unknown option: %~1 +exit /b 1 + +:show_help +echo Usage: upload.bat [-p PORT] [--monitor] [--clean] [--verbose] +echo Compiles and uploads the sketch. Settings from .anvil.toml. +exit /b 0 + +:done_args + +:: -- Preflight ------------------------------------------------------------ +where arduino-cli >nul 2>nul +if errorlevel 1 ( + echo FAIL: arduino-cli not found in PATH. + exit /b 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 ( + if "!PORT!"=="" set "PORT=%%p" + ) + if "!PORT!"=="" ( + echo FAIL: No serial port detected. Is the board plugged in? + echo Specify manually: upload.bat -p COM3 + echo Or save a default: anvil devices --set + exit /b 1 + ) + if not "%LOCAL_VID_PID%"=="" ( + if not "!PORT!"=="%LOCAL_PORT%" ( + echo info Device %LOCAL_VID_PID% found on !PORT! ^(moved from %LOCAL_PORT%^) + ) else ( + echo info Using port !PORT! ^(from .anvil.local^) + ) + ) else if not "%LOCAL_PORT%"=="" ( + echo info Using port !PORT! ^(from .anvil.local^) + ) else ( + echo warn Auto-detected port: !PORT! ^(use -p to override, or: anvil devices --set^) + ) +) + +:: -- Clean ---------------------------------------------------------------- +if "%DO_CLEAN%"=="1" ( + if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%" +) + +:: -- 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" + ) +) +set "BUILD_FLAGS=!BUILD_FLAGS! -Werror" + +:: -- Compile -------------------------------------------------------------- +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%" +if errorlevel 1 ( + echo FAIL: Compilation failed. + exit /b 1 +) +echo ok Compile succeeded. + +:: -- Upload --------------------------------------------------------------- +echo. +echo Uploading to %PORT%... + +arduino-cli upload --fqbn %FQBN% --port %PORT% --input-dir "%BUILD_DIR%" %VERBOSE% +if errorlevel 1 ( + echo FAIL: Upload failed. + exit /b 1 +) +echo ok Upload complete! + +:: -- Monitor -------------------------------------------------------------- +if "%DO_MONITOR%"=="1" ( + echo. + echo Opening serial monitor on %PORT% at %BAUD% baud... + echo Press Ctrl+C to exit. + echo. + arduino-cli monitor -p %PORT% -c "baudrate=%BAUD%" +) \ No newline at end of file diff --git a/templates/basic/upload.sh b/templates/basic/upload.sh new file mode 100644 index 0000000..7ca238c --- /dev/null +++ b/templates/basic/upload.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# +# upload.sh -- Compile and upload the sketch to the board +# +# Reads all settings from .anvil.toml. No Anvil binary required. +# +# Usage: +# ./upload.sh Auto-detect port, compile + upload +# ./upload.sh -p /dev/ttyUSB0 Specify port +# ./upload.sh --monitor Open serial monitor after upload +# ./upload.sh --clean Clean build cache first +# ./upload.sh --verbose Full compiler + avrdude output +# +# Prerequisites: arduino-cli in PATH, arduino:avr core installed + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG="$SCRIPT_DIR/.anvil.toml" + +# -- Colors ---------------------------------------------------------------- +if [[ -t 1 ]]; then + RED=$'\033[0;31m'; GRN=$'\033[0;32m'; YLW=$'\033[0;33m' + CYN=$'\033[0;36m'; BLD=$'\033[1m'; RST=$'\033[0m' +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; } + +# -- Parse .anvil.toml ----------------------------------------------------- +[[ -f "$CONFIG" ]] || die "No .anvil.toml found in $SCRIPT_DIR" + +toml_get() { + (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ' +} + +toml_array() { + (grep "^$1 " "$CONFIG" 2>/dev/null || true) | head -1 \ + | sed 's/.*\[//; s/\].*//; s/"//g; s/,/ /g' | tr -s ' ' +} + +SKETCH_NAME="$(toml_get 'name')" +FQBN="$(toml_get 'fqbn')" +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" + +BAUD="${BAUD:-115200}" +LOCAL_CONFIG="$SCRIPT_DIR/.anvil.local" +LOCAL_PORT="" +LOCAL_VID_PID="" +if [[ -f "$LOCAL_CONFIG" ]]; then + LOCAL_PORT="$(grep '^port ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')" + LOCAL_VID_PID="$(grep '^vid_pid ' "$LOCAL_CONFIG" 2>/dev/null | head -1 | sed 's/.*= *"\{0,1\}\([^"]*\)"\{0,1\}/\1/' | tr -d ' ')" +fi +SKETCH_DIR="$SCRIPT_DIR/$SKETCH_NAME" +BUILD_DIR="$SCRIPT_DIR/.build" + +# -- Parse arguments ------------------------------------------------------- +PORT="" +DO_MONITOR=0 +DO_CLEAN=0 +VERBOSE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -p|--port) PORT="$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 " Compiles and uploads the sketch. Settings from .anvil.toml." + exit 0 + ;; + *) die "Unknown option: $1" ;; + esac +done + +# -- Preflight ------------------------------------------------------------- +command -v arduino-cli &>/dev/null \ + || die "arduino-cli not found in PATH." + +[[ -d "$SKETCH_DIR" ]] \ + || die "Sketch directory not found: $SKETCH_DIR" + +# -- 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: + data = json.load(sys.stdin) + for dp in data.get('detected_ports', []): + port = dp.get('port', {}) + if port.get('protocol') != 'serial': + continue + props = port.get('properties', {}) + vid = props.get('vid', '').lower().replace('0x', '') + pid = props.get('pid', '').lower().replace('0x', '') + if vid == '$target_vid' and pid == '$target_pid': + print(port.get('address', '')) + break +except: pass +" 2>/dev/null +} + +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 + if [[ "$PORT" != "$LOCAL_PORT" ]] && [[ -n "$LOCAL_PORT" ]]; then + warn "Device $LOCAL_VID_PID found on $PORT (moved from $LOCAL_PORT)" + else + warn "Using port $PORT (from .anvil.local)" + fi + 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" \ + | awk '{print $1}' \ + | grep -E 'ttyUSB|ttyACM|COM' \ + | head -1) + + if [[ -z "$PORT" ]]; then + PORT=$(arduino-cli board list 2>/dev/null \ + | grep -i "serial" \ + | head -1 \ + | awk '{print $1}') + fi + + if [[ -z "$PORT" ]]; then + die "No serial port detected. Is the board plugged in?\n Specify manually: ./upload.sh -p /dev/ttyUSB0\n Or save a default: anvil devices --set" + fi + + warn "Auto-detected port: $PORT (use -p to override, or: anvil devices --set)" + fi +fi + +# -- Clean ----------------------------------------------------------------- +if [[ $DO_CLEAN -eq 1 ]] && [[ -d "$BUILD_DIR" ]]; then + echo "${YLW}Cleaning build cache...${RST}" + rm -rf "$BUILD_DIR" +fi + +# -- Build include flags --------------------------------------------------- +BUILD_FLAGS="" +for dir in $INCLUDE_DIRS; do + abs="$SCRIPT_DIR/$dir" + if [[ -d "$abs" ]]; then + BUILD_FLAGS="$BUILD_FLAGS -I$abs" + fi +done +for flag in $EXTRA_FLAGS; do + BUILD_FLAGS="$BUILD_FLAGS $flag" +done + +# -- Compile --------------------------------------------------------------- +echo "${CYN}${BLD}Compiling ${SKETCH_NAME}...${RST}" +mkdir -p "$BUILD_DIR" + +COMPILE_ARGS=( + compile + --fqbn "$FQBN" + --build-path "$BUILD_DIR" + --warnings "$WARNINGS" +) + +if [[ -n "$BUILD_FLAGS" ]]; then + COMPILE_ARGS+=(--build-property "build.extra_flags=$BUILD_FLAGS") +fi + +[[ -n "$VERBOSE" ]] && COMPILE_ARGS+=("$VERBOSE") +COMPILE_ARGS+=("$SKETCH_DIR") + +arduino-cli "${COMPILE_ARGS[@]}" || die "Compilation failed." +ok "Compile succeeded." + +# -- Upload ---------------------------------------------------------------- +echo "" +echo "${CYN}${BLD}Uploading to ${PORT}...${RST}" + +UPLOAD_ARGS=( + upload + --fqbn "$FQBN" + --port "$PORT" + --input-dir "$BUILD_DIR" +) + +[[ -n "$VERBOSE" ]] && UPLOAD_ARGS+=("$VERBOSE") + +arduino-cli "${UPLOAD_ARGS[@]}" || die "Upload failed." +ok "Upload complete!" + +# -- Monitor --------------------------------------------------------------- +if [[ $DO_MONITOR -eq 1 ]]; then + echo "" + echo "Opening serial monitor on $PORT at $BAUD baud..." + echo "Press Ctrl+C to exit." + echo "" + arduino-cli monitor -p "$PORT" -c "baudrate=$BAUD" +fi \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 01076e8..96d5979 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -18,7 +18,7 @@ fn test_basic_template_extracts_all_expected_files() { }; let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); - assert!(count >= 10, "Expected at least 10 files, got {}", count); + assert!(count >= 16, "Expected at least 16 files, got {}", count); } #[test] @@ -321,6 +321,12 @@ fn test_full_project_structure() { "lib/hal/hal.h", "lib/hal/hal_arduino.h", "lib/app/full_test_app.h", + "build.sh", + "build.bat", + "upload.sh", + "upload.bat", + "monitor.sh", + "monitor.bat", "test/CMakeLists.txt", "test/test_unit.cpp", "test/run_tests.sh", @@ -406,3 +412,260 @@ fn test_load_config_from_nonproject_fails() { let result = ProjectConfig::load(tmp.path()); assert!(result.is_err()); } + +// ============================================================================ +// Self-contained script tests +// ============================================================================ + +#[test] +fn test_template_creates_self_contained_scripts() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "standalone".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + // All six scripts must exist + let scripts = vec![ + "build.sh", "build.bat", + "upload.sh", "upload.bat", + "monitor.sh", "monitor.bat", + ]; + for script in &scripts { + let p = tmp.path().join(script); + assert!(p.exists(), "Script missing: {}", script); + } +} + +#[test] +fn test_build_sh_reads_anvil_toml() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "toml_reader".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let content = fs::read_to_string(tmp.path().join("build.sh")).unwrap(); + assert!( + content.contains(".anvil.toml"), + "build.sh should reference .anvil.toml" + ); + assert!( + content.contains("arduino-cli"), + "build.sh should invoke arduino-cli" + ); + assert!( + !content.contains("anvil build"), + "build.sh must NOT depend on the anvil binary" + ); +} + +#[test] +fn test_upload_sh_reads_anvil_toml() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "uploader".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let content = fs::read_to_string(tmp.path().join("upload.sh")).unwrap(); + assert!( + content.contains(".anvil.toml"), + "upload.sh should reference .anvil.toml" + ); + assert!( + content.contains("arduino-cli"), + "upload.sh should invoke arduino-cli" + ); + assert!( + content.contains("upload"), + "upload.sh should contain upload command" + ); + assert!( + content.contains("--monitor"), + "upload.sh should support --monitor flag" + ); + assert!( + !content.contains("anvil upload"), + "upload.sh must NOT depend on the anvil binary" + ); +} + +#[test] +fn test_monitor_sh_reads_anvil_toml() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "serial_mon".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let content = fs::read_to_string(tmp.path().join("monitor.sh")).unwrap(); + assert!( + content.contains(".anvil.toml"), + "monitor.sh should reference .anvil.toml" + ); + assert!( + content.contains("--watch"), + "monitor.sh should support --watch flag" + ); + assert!( + !content.contains("anvil monitor"), + "monitor.sh must NOT depend on the anvil binary" + ); +} + +#[test] +fn test_scripts_have_shebangs() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "shebangs".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + for script in &["build.sh", "upload.sh", "monitor.sh", "test/run_tests.sh"] { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + assert!( + content.starts_with("#!/"), + "{} should start with a shebang line", + script + ); + } +} + +#[test] +fn test_scripts_no_anvil_binary_dependency() { + // Critical: generated projects must NOT require the anvil binary + // for build, upload, or monitor operations. + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "no_anvil_dep".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let scripts = vec![ + "build.sh", "build.bat", + "upload.sh", "upload.bat", + "monitor.sh", "monitor.bat", + "test/run_tests.sh", "test/run_tests.bat", + ]; + + for script in &scripts { + let content = fs::read_to_string(tmp.path().join(script)).unwrap(); + // None of these scripts should shell out to anvil + let has_anvil_cmd = content.lines().any(|line| { + let trimmed = line.trim(); + // Skip comments and echo/print lines + if trimmed.starts_with('#') + || trimmed.starts_with("::") + || trimmed.starts_with("echo") + || trimmed.starts_with("REM") + || trimmed.starts_with("rem") + { + return false; + } + // Check for "anvil " as a command invocation + trimmed.contains("anvil ") + && !trimmed.contains("anvil.toml") + && !trimmed.contains("Anvil") + }); + assert!( + !has_anvil_cmd, + "{} should not invoke the anvil binary (project must be self-contained)", + script + ); + } +} + +#[test] +fn test_gitignore_excludes_build_cache() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "gitcheck".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap(); + assert!( + content.contains(".build/"), + ".gitignore should exclude .build/ (arduino-cli build cache)" + ); + assert!( + content.contains("test/build/"), + ".gitignore should exclude test/build/ (cmake build cache)" + ); +} + +#[test] +fn test_readme_documents_self_contained_workflow() { + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "docs_check".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); + + let readme = fs::read_to_string(tmp.path().join("README.md")).unwrap(); + assert!( + readme.contains("./build.sh"), + "README should document build.sh" + ); + assert!( + readme.contains("./upload.sh"), + "README should document upload.sh" + ); + assert!( + readme.contains("./monitor.sh"), + "README should document monitor.sh" + ); + assert!( + readme.contains("self-contained"), + "README should mention self-contained" + ); +} + +#[test] +fn test_scripts_tolerate_missing_toml_keys() { + // Regression: toml_get must not kill the script when a key is absent. + // With set -euo pipefail, bare grep returns exit 1 on no match, + // pipefail propagates it, and set -e terminates silently. + // Every grep in toml_get/toml_array must have "|| true". + let tmp = TempDir::new().unwrap(); + let ctx = TemplateContext { + project_name: "grep_safe".to_string(), + anvil_version: "1.0.0".to_string(), + }; + + 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(); + + // If the script uses set -e (or -euo pipefail), then every + // toml_get/toml_array function must guard grep with || true + if content.contains("set -e") || content.contains("set -euo") { + // Find the toml_get function body and check for || true + let has_safe_grep = content.contains("|| true"); + assert!( + has_safe_grep, + "{} uses set -e but toml_get/toml_array lacks '|| true' guard. \ + Missing TOML keys will silently kill the script.", + script + ); + } + } +}