From 8fe1ef0e27028db7d5e24b41589aee5eb71d6567 Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Mon, 16 Feb 2026 08:29:33 -0600 Subject: [PATCH] Refactor CLI, add refresh command, fix port detection, add device tracking - Remove build/upload/monitor subcommands (projects are self-contained) - Remove ctrlc dependency (only used by removed monitor watch mode) - Update next-steps messaging to reference project scripts directly - Add 'anvil refresh [DIR] [--force]' to update project scripts to latest templates without touching user code - Fix Windows port detection: replace fragile findstr/batch TOML parsing with proper comment-skipping logic; add _detect_port.ps1 helper for reliable JSON-based port detection via PowerShell - Add .anvil.local for machine-specific config (gitignored) - 'anvil devices --set [PORT] [-d DIR]' saves port + VID:PID - 'anvil devices --get [-d DIR]' shows saved port status - VID:PID tracks USB devices across COM port reassignment - Port resolution: -p flag > VID:PID > saved port > auto-detect - Uppercase normalization for Windows COM port names - Update all .bat/.sh templates to read from .anvil.local - Remove port entries from .anvil.toml (no machine-specific config in git) - Add .anvil.local to .gitignore template - Expand 'anvil devices' output with VID:PID, serial number, and usage instructions --- Cargo.lock | 68 +----- Cargo.toml | 7 +- README.md | 53 +++- anvil_src.zip | Bin 50136 -> 0 bytes src/board/mod.rs | 408 ++++++++++++++++++++++++++++--- src/commands/build.rs | 299 ---------------------- src/commands/devices.rs | 257 ++++++++++++++++++- src/commands/doctor.rs | 199 ++++++++++++++- src/commands/mod.rs | 3 +- src/commands/monitor.rs | 167 ------------- src/commands/new.rs | 85 +++++-- src/commands/refresh.rs | 191 +++++++++++++++ src/commands/setup.rs | 79 +++++- src/main.rs | 123 +++------- src/project/config.rs | 12 +- templates/basic/README.md.tmpl | 46 ++-- templates/basic/_detect_port.ps1 | 78 ++++++ templates/basic/_dot_gitignore | 4 + templates/basic/build.bat | 112 +++++++++ templates/basic/build.sh | 145 +++++++++++ templates/basic/monitor.bat | 113 +++++++++ templates/basic/monitor.sh | 173 +++++++++++++ templates/basic/upload.bat | 169 +++++++++++++ templates/basic/upload.sh | 226 +++++++++++++++++ tests/integration_test.rs | 265 +++++++++++++++++++- 25 files changed, 2551 insertions(+), 731 deletions(-) delete mode 100644 anvil_src.zip delete mode 100644 src/commands/build.rs delete mode 100644 src/commands/monitor.rs create mode 100644 src/commands/refresh.rs create mode 100644 templates/basic/_detect_port.ps1 create mode 100644 templates/basic/build.bat create mode 100644 templates/basic/build.sh create mode 100644 templates/basic/monitor.bat create mode 100644 templates/basic/monitor.sh create mode 100644 templates/basic/upload.bat create mode 100644 templates/basic/upload.sh 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 86df85723ef973824776367f95068c5336c52c47..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50136 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 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 + ); + } + } +}