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
This commit is contained in:
Eric Ratliff
2026-02-16 08:29:33 -06:00
parent 3298844399
commit 8fe1ef0e27
25 changed files with 2551 additions and 731 deletions

68
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
@@ -69,7 +69,6 @@ dependencies = [
"assert_cmd", "assert_cmd",
"clap", "clap",
"colored", "colored",
"ctrlc",
"dirs", "dirs",
"home", "home",
"include_dir", "include_dir",
@@ -115,15 +114,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@@ -141,12 +131,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.58" version = "4.5.58"
@@ -203,17 +187,6 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "difflib" name = "difflib"
version = "0.4.0" version = "0.4.0"
@@ -241,18 +214,6 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -415,18 +376,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 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]] [[package]]
name = "normalize-line-endings" name = "normalize-line-endings"
version = "0.3.0" version = "0.3.0"
@@ -442,21 +391,6 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"

View File

@@ -40,11 +40,10 @@ colored = "2.1"
which = "5.0" which = "5.0"
home = "=0.5.9" home = "=0.5.9"
# Signal handling # Temp dirs (for refresh command)
ctrlc = "3.4" tempfile = "3.13"
[dev-dependencies] [dev-dependencies]
tempfile = "3.13"
assert_cmd = "2.0" assert_cmd = "2.0"
predicates = "3.1" predicates = "3.1"

View File

@@ -2,9 +2,13 @@
**Arduino project generator and build tool -- forges clean embedded projects.** **Arduino project generator and build tool -- forges clean embedded projects.**
A single binary that scaffolds testable Arduino projects with hardware abstraction, A single binary that scaffolds self-contained Arduino projects with hardware
Google Mock infrastructure, and a streamlined build/upload/monitor workflow. Works on abstraction, Google Mock infrastructure, and a streamlined build/upload/monitor
Linux and Windows. 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. Anvil is a [Nexus Workshops](https://nxlearn.net) project.
@@ -36,20 +40,37 @@ your system is ready.
# Create a new project # Create a new project
anvil new blink anvil new blink
# Check system health # Enter the project
anvil doctor cd blink
# Find your board # Compile (verify only)
anvil devices ./build.sh
# Compile and upload to board
./upload.sh
# Compile, upload, and open serial monitor # Compile, upload, and open serial monitor
cd blink ./upload.sh --monitor
anvil build --monitor blink
# Run host-side tests (no board needed) # 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 ## Commands
| Command | Description | | Command | Description |
@@ -58,9 +79,12 @@ cd test && ./run_tests.sh
| `anvil doctor` | Check system prerequisites | | `anvil doctor` | Check system prerequisites |
| `anvil setup` | Install arduino-cli and AVR core | | `anvil setup` | Install arduino-cli and AVR core |
| `anvil devices` | List connected boards and serial ports | | `anvil devices` | List connected boards and serial ports |
| `anvil build DIR` | Compile and upload a sketch | | `anvil build DIR` | Compile and upload a sketch (convenience) |
| `anvil upload DIR`| Upload cached build (no recompile) | | `anvil upload DIR`| Upload cached build (convenience) |
| `anvil monitor` | Open serial monitor (`--watch` for persistent) | | `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 ## Project Architecture
@@ -74,6 +98,9 @@ your-project/
lib/app/your-project_app.h -- app logic (testable) lib/app/your-project_app.h -- app logic (testable)
test/mocks/mock_hal.h -- Google Mock HAL test/mocks/mock_hal.h -- Google Mock HAL
test/test_unit.cpp -- unit tests 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 .anvil.toml -- project config
``` ```

Binary file not shown.

View File

@@ -11,6 +11,40 @@ pub struct PortInfo {
pub protocol: String, pub protocol: String,
pub board_name: String, pub board_name: String,
pub fqbn: 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` /// JSON schema for `arduino-cli board list --format json`
@@ -90,11 +124,34 @@ fn list_ports_via_cli(cli: &Path) -> Result<Vec<PortInfo>> {
_ => ("Unknown".to_string(), String::new()), _ => ("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 { result.push(PortInfo {
port_name: port.address, port_name: port.address,
protocol: port.protocol_label, protocol: port.protocol_label,
board_name, board_name,
fqbn, fqbn,
vid,
pid,
serial_number,
}); });
} }
@@ -123,6 +180,9 @@ fn list_ports_fallback() -> Vec<PortInfo> {
protocol: "serial".to_string(), protocol: "serial".to_string(),
board_name: board.to_string(), board_name: board.to_string(),
fqbn: String::new(), fqbn: String::new(),
vid: String::new(),
pid: String::new(),
serial_number: String::new(),
}); });
} }
} }
@@ -149,6 +209,9 @@ fn list_ports_fallback() -> Vec<PortInfo> {
protocol: "serial".to_string(), protocol: "serial".to_string(),
board_name: "Detected via WMI".to_string(), board_name: "Detected via WMI".to_string(),
fqbn: String::new(), fqbn: String::new(),
vid: String::new(),
pid: String::new(),
serial_number: String::new(),
}); });
} }
} }
@@ -160,6 +223,50 @@ fn list_ports_fallback() -> Vec<PortInfo> {
result result
} }
/// Find a port by VID:PID. Returns the port name if found.
pub fn resolve_vid_pid(vid_pid: &str) -> Option<String> {
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<usize> {
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. /// Auto-detect a single serial port.
pub fn auto_detect_port() -> Result<String> { pub fn auto_detect_port() -> Result<String> {
let ports = list_ports(); let ports = list_ports();
@@ -171,35 +278,23 @@ pub fn auto_detect_port() -> Result<String> {
); );
} }
if ports.len() == 1 { let idx = pick_default_port(&ports).unwrap_or(0);
return Ok(ports[0].port_name.clone());
}
if ports.len() > 1 {
eprintln!("{}", "Multiple serial ports detected:".yellow()); eprintln!("{}", "Multiple serial ports detected:".yellow());
for p in &ports { for p in &ports {
eprintln!(" {} ({})", p.port_name, p.board_name); eprintln!(" {} ({})", p.port_name, p.board_name);
} }
// Prefer a port with a recognized board
for p in &ports {
if !p.fqbn.is_empty() {
eprintln!( eprintln!(
"{}", "{}",
format!( format!(
"Auto-selected {} ({}). Use -p to override.", "Auto-selected {}. Use -p to override.",
p.port_name, p.board_name ports[idx].port_name
).yellow() ).yellow()
); );
return Ok(p.port_name.clone());
}
} }
let selected = ports[0].port_name.clone(); Ok(ports[idx].port_name.clone())
eprintln!(
"{}",
format!("Auto-selected {}. Use -p to override.", selected).yellow()
);
Ok(selected)
} }
/// Print detailed port information. /// Print detailed port information.
@@ -219,8 +314,18 @@ pub fn print_port_details(ports: &[PortInfo]) {
return; return;
} }
for port in ports { 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!(" {}", port.port_name.green().bold());
}
println!(" Board: {}", port.board_name); println!(" Board: {}", port.board_name);
if !port.fqbn.is_empty() { if !port.fqbn.is_empty() {
println!(" FQBN: {}", port.fqbn); println!(" FQBN: {}", port.fqbn);
@@ -229,6 +334,14 @@ pub fn print_port_details(ports: &[PortInfo]) {
println!(" Protocol: {}", port.protocol); 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)] #[cfg(unix)]
{ {
use std::fs::OpenOptions; use std::fs::OpenOptions;
@@ -254,6 +367,85 @@ pub fn print_port_details(ports: &[PortInfo]) {
println!(); 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. /// Find arduino-cli in PATH or in ~/.anvil/bin.
@@ -308,11 +500,70 @@ mod tests {
protocol: "serial".to_string(), protocol: "serial".to_string(),
board_name: "Test".to_string(), board_name: "Test".to_string(),
fqbn: "arduino:avr:uno".to_string(), fqbn: "arduino:avr:uno".to_string(),
vid: "0x2341".to_string(),
pid: "0x0043".to_string(),
serial_number: String::new(),
}; };
let cloned = info.clone(); let cloned = info.clone();
assert_eq!(cloned.port_name, info.port_name); 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] #[test]
fn test_parse_empty_board_list() { fn test_parse_empty_board_list() {
let json = r#"{"detected_ports": []}"#; let json = r#"{"detected_ports": []}"#;
@@ -327,7 +578,12 @@ mod tests {
"port": { "port": {
"address": "/dev/ttyUSB0", "address": "/dev/ttyUSB0",
"protocol": "serial", "protocol": "serial",
"protocol_label": "Serial Port (USB)" "protocol_label": "Serial Port (USB)",
"properties": {
"vid": "0x2341",
"pid": "0x0043",
"serialNumber": "ABC123"
}
}, },
"matching_boards": [{ "matching_boards": [{
"name": "Arduino Uno", "name": "Arduino Uno",
@@ -338,8 +594,96 @@ mod tests {
let parsed: BoardListOutput = serde_json::from_str(json).unwrap(); let parsed: BoardListOutput = serde_json::from_str(json).unwrap();
assert_eq!(parsed.detected_ports.len(), 1); assert_eq!(parsed.detected_ports.len(), 1);
let dp = &parsed.detected_ports[0]; let dp = &parsed.detected_ports[0];
assert_eq!(dp.port.as_ref().unwrap().address, "/dev/ttyUSB0"); let port = dp.port.as_ref().unwrap();
let boards = dp.matching_boards.as_ref().unwrap(); assert_eq!(port.address, "/dev/ttyUSB0");
assert_eq!(boards[0].name, "Arduino Uno"); 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<PortInfo> = 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));
} }
} }

View File

@@ -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<u32>,
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<String> = 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<PathBuf> {
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<String> {
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!();
}

View File

@@ -1,5 +1,7 @@
use anyhow::Result; use anyhow::{Result, Context};
use colored::*; use colored::*;
use std::path::{Path, PathBuf};
use std::fs;
use crate::board; use crate::board;
@@ -60,8 +62,261 @@ pub fn scan_devices() -> Result<()> {
println!(" - Check kernel log: dmesg | tail -20"); println!(" - Check kernel log: dmesg | tail -20");
println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'"); println!(" - Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'");
} }
#[cfg(target_os = "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!(); println!();
} }
Ok(()) 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 <PORT>".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 <PORT>\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<PathBuf> {
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 <DIR>",
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))
}

View File

@@ -43,10 +43,12 @@ pub fn run_diagnostics() -> Result<()> {
} else { } else {
println!( println!(
"{}", "{}",
"Issues found. Run 'anvil setup' to fix." "Issues found. See instructions below."
.bright_yellow() .bright_yellow()
.bold() .bold()
); );
println!();
print_fix_instructions(&health);
} }
println!(); println!();
@@ -78,7 +80,7 @@ pub fn check_system_health() -> SystemHealth {
let cmake_ok = which::which("cmake").is_ok(); let cmake_ok = which::which("cmake").is_ok();
// C++ compiler (optional -- for host tests) // 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 // git
let git_ok = which::which("git").is_ok(); 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) { fn print_diagnostics(health: &SystemHealth) {
println!("{}", "Required:".bright_yellow().bold()); println!("{}", "Required:".bright_yellow().bold());
println!(); println!();
@@ -139,14 +155,23 @@ fn print_diagnostics(health: &SystemHealth) {
println!("{}", "Optional:".bright_yellow().bold()); println!("{}", "Optional:".bright_yellow().bold());
println!(); println!();
// avr-size // avr-size -- installed as part of the avr core, not a separate step
if health.avr_size_ok { if health.avr_size_ok {
println!(" {} avr-size (binary size reporting)", "ok".green()); println!(" {} avr-size (binary size reporting)", "ok".green());
} else { } else if !health.avr_core_ok {
println!( println!(
" {} avr-size {}", " {} avr-size {}",
"--".bright_black(), "--".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!( println!(
" {} cmake {}", " {} cmake {}",
"--".bright_black(), "--".bright_black(),
"install: sudo apt install cmake".bright_black() hint_cmake().bright_black()
); );
} }
// C++ compiler // C++ compiler
if health.cpp_compiler_ok { if health.cpp_compiler_ok {
println!(" {} C++ compiler (g++/clang++)", "ok".green()); println!(" {} C++ compiler", "ok".green());
} else { } else {
println!( println!(
" {} C++ compiler {}", " {} C++ compiler {}",
"--".bright_black(), "--".bright_black(),
"install: sudo apt install g++".bright_black() hint_cpp_compiler().bright_black()
); );
} }
@@ -193,7 +218,7 @@ fn print_diagnostics(health: &SystemHealth) {
println!( println!(
" {} git {}", " {} git {}",
"--".bright_black(), "--".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 { fn check_dialout() -> bool {
#[cfg(unix)] #[cfg(unix)]
{ {

View File

@@ -2,5 +2,4 @@ pub mod new;
pub mod doctor; pub mod doctor;
pub mod setup; pub mod setup;
pub mod devices; pub mod devices;
pub mod build; pub mod refresh;
pub mod monitor;

View File

@@ -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<u32>,
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()
}
}

View File

@@ -177,7 +177,12 @@ fn init_git(project_dir: &PathBuf, template_name: &str) {
fn make_executable(project_dir: &PathBuf) { fn make_executable(project_dir: &PathBuf) {
use std::os::unix::fs::PermissionsExt; 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 { for script in &scripts {
let path = project_dir.join(script); let path = project_dir.join(script);
if path.exists() { if path.exists() {
@@ -196,23 +201,67 @@ fn print_next_steps(project_name: &str) {
" 1. {}", " 1. {}",
format!("cd {}", project_name).bright_cyan() format!("cd {}", project_name).bright_cyan()
); );
println!(" 2. Check your system: {}", "anvil doctor".bright_cyan());
if cfg!(target_os = "windows") {
println!( println!(
" 3. Find your board: {}", " 2. Compile: {}",
"anvil devices".bright_cyan() "build.bat".bright_cyan()
); );
println!( println!(
" 4. Build and upload: {}", " 3. Upload to board: {}",
format!("anvil build {}", project_name).bright_cyan() "upload.bat".bright_cyan()
); );
println!( println!(
" 5. Build + monitor: {}", " 4. Upload + monitor: {}",
format!("anvil build --monitor {}", project_name).bright_cyan() "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!();
println!( println!(
" Run host tests: {}", " {}",
"cd test && ./run_tests.sh".bright_cyan() "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!(
" {}",
"System check: anvil doctor | Port scan: anvil devices"
.bright_black()
); );
println!(); println!();
} }

191
src/commands/refresh.rs Normal file
View File

@@ -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(())
}

View File

@@ -78,11 +78,22 @@ pub fn run_setup() -> Result<()> {
if which::which("avr-size").is_ok() { if which::which("avr-size").is_ok() {
println!(" {} avr-size (binary size reporting)", "ok".green()); println!(" {} avr-size (binary size reporting)", "ok".green());
} else { } else {
println!( print_optional_hint("avr-size", hint_avr_size());
" {} avr-size not found. Install for binary size details:", }
"info".bright_black()
); if which::which("cmake").is_ok() {
println!(" sudo apt install gcc-avr"); 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)] #[cfg(unix)]
@@ -128,15 +139,31 @@ pub fn run_setup() -> Result<()> {
println!(" 1. Plug in your RedBoard"); println!(" 1. Plug in your RedBoard");
println!(" 2. {}", "anvil devices".bright_cyan()); println!(" 2. {}", "anvil devices".bright_cyan());
println!(" 3. {}", "anvil new blink".bright_cyan()); println!(" 3. {}", "anvil new blink".bright_cyan());
if cfg!(target_os = "windows") {
println!( println!(
" 4. {}", " 4. {}",
"cd blink && anvil build blink".bright_cyan() "cd blink && build.bat".bright_cyan()
); );
} else {
println!(
" 4. {}",
"cd blink && ./build.sh".bright_cyan()
);
}
println!(); println!();
Ok(()) 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() { fn print_install_instructions() {
println!("{}", "Install arduino-cli:".bright_yellow().bold()); println!("{}", "Install arduino-cli:".bright_yellow().bold());
println!(); println!();
@@ -161,3 +188,37 @@ fn print_install_instructions() {
println!(); println!();
println!(" Then re-run: anvil setup"); 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++"
}
}

View File

@@ -43,73 +43,31 @@ enum Commands {
Setup, Setup,
/// List connected boards and serial ports /// 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) /// Show the saved port for this project
Build { #[arg(long, conflicts_with = "set")]
/// Path to sketch directory get: bool,
sketch: String,
/// Compile only -- do not upload /// Port name (e.g. COM3, /dev/ttyUSB0). Auto-detects if omitted with --set.
#[arg(long)] port_or_dir: Option<String>,
verify: bool,
/// Open serial monitor after upload /// Path to project directory (defaults to current directory)
#[arg(long)] #[arg(long, short = 'd', value_name = "DIR")]
monitor: bool, dir: Option<String>,
/// 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<String>,
/// Serial monitor baud rate
#[arg(short, long)]
baud: Option<u32>,
/// Override Fully Qualified Board Name
#[arg(long)]
fqbn: Option<String>,
}, },
/// Upload cached build artifacts (no recompile) /// Update project scripts to the latest version
Upload { Refresh {
/// Path to sketch directory /// Path to project directory (defaults to current directory)
sketch: String, dir: Option<String>,
/// Serial port (auto-detected if omitted) /// Overwrite scripts even if they have been modified
#[arg(short, long)]
port: Option<String>,
/// Show full avrdude output
#[arg(long)] #[arg(long)]
verbose: bool, force: bool,
/// Override Fully Qualified Board Name
#[arg(long)]
fqbn: Option<String>,
},
/// Open serial monitor
Monitor {
/// Serial port (auto-detected if omitted)
#[arg(short, long)]
port: Option<String>,
/// Baud rate (default: from project config or 115200)
#[arg(short, long)]
baud: Option<u32>,
/// Persistent mode: reconnect after upload/reset/replug
#[arg(long)]
watch: bool,
}, },
} }
@@ -133,7 +91,7 @@ fn main() -> Result<()> {
} else { } else {
anyhow::bail!( anyhow::bail!(
"Project name required.\n\ "Project name required.\n\
Usage: anvil new <name>\n\ Usage: anvil new <n>\n\
List templates: anvil new --list-templates" List templates: anvil new --list-templates"
); );
} }
@@ -144,31 +102,24 @@ fn main() -> Result<()> {
Commands::Setup => { Commands::Setup => {
commands::setup::run_setup() commands::setup::run_setup()
} }
Commands::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::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::Refresh { dir, force } => {
commands::build::run_upload_only( commands::refresh::run_refresh(
&sketch, dir.as_deref(),
port.as_deref(), force,
verbose,
fqbn.as_deref(),
)
}
Commands::Monitor { port, baud, watch } => {
commands::monitor::run_monitor(
port.as_deref(),
baud,
watch,
) )
} }
} }

View File

@@ -31,6 +31,8 @@ pub struct BuildConfig {
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MonitorConfig { pub struct MonitorConfig {
pub baud: u32, pub baud: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<String>,
} }
impl ProjectConfig { impl ProjectConfig {
@@ -49,6 +51,7 @@ impl ProjectConfig {
}, },
monitor: MonitorConfig { monitor: MonitorConfig {
baud: 115200, baud: 115200,
port: None,
}, },
} }
} }
@@ -143,13 +146,6 @@ pub fn anvil_home() -> Result<PathBuf> {
Ok(anvil_dir) Ok(anvil_dir)
} }
/// Return the build cache directory (~/.anvil/builds).
pub fn build_cache_dir() -> Result<PathBuf> {
let dir = anvil_home()?.join("builds");
fs::create_dir_all(&dir)?;
Ok(dir)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -1,29 +1,38 @@
# {{PROJECT_NAME}} # {{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 ## Quick Start
```bash ```bash
# Check your system # Compile only (verify)
anvil doctor ./build.sh
# Find connected boards # Compile and upload to board
anvil devices ./upload.sh
# Compile only (no upload)
anvil build --verify {{PROJECT_NAME}}
# Compile and upload
anvil build {{PROJECT_NAME}}
# Compile, upload, and open serial monitor # 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) # 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 ## Project Structure
``` ```
@@ -44,6 +53,9 @@ cd test && ./run_tests.sh
CMakeLists.txt Test build system CMakeLists.txt Test build system
run_tests.sh Test runner (Linux/Mac) run_tests.sh Test runner (Linux/Mac)
run_tests.bat Test runner (Windows) 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 .anvil.toml Project configuration
``` ```
@@ -52,7 +64,7 @@ cd test && ./run_tests.sh
All hardware access goes through the `Hal` interface. The app code All hardware access goes through the `Hal` interface. The app code
(`lib/app/`) depends only on `Hal`, never on `Arduino.h` directly. (`lib/app/`) depends only on `Hal`, never on `Arduino.h` directly.
This means the app can be compiled and tested on the host without This means the app can be compiled and tested on the host without
any Arduino SDK. any Arduino hardware.
Two HAL implementations: Two HAL implementations:
- `ArduinoHal` -- passthroughs to real hardware (used in the .ino) - `ArduinoHal` -- passthroughs to real hardware (used in the .ino)
@@ -72,3 +84,9 @@ extra_flags = ["-Werror"]
[monitor] [monitor]
baud = 115200 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`

View File

@@ -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
}

View File

@@ -1,6 +1,10 @@
# Build artifacts # Build artifacts
.build/
test/build/ test/build/
# Machine-specific config (created by: anvil devices --set)
.anvil.local
# IDE # IDE
.vscode/.browse* .vscode/.browse*
.vscode/*.log .vscode/*.log

112
templates/basic/build.bat Normal file
View File

@@ -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.

145
templates/basic/build.sh Normal file
View File

@@ -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 ""

113
templates/basic/monitor.bat Normal file
View File

@@ -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%"

173
templates/basic/monitor.sh Normal file
View File

@@ -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

169
templates/basic/upload.bat Normal file
View File

@@ -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%"
)

226
templates/basic/upload.sh Normal file
View File

@@ -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

View File

@@ -18,7 +18,7 @@ fn test_basic_template_extracts_all_expected_files() {
}; };
let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); 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] #[test]
@@ -321,6 +321,12 @@ fn test_full_project_structure() {
"lib/hal/hal.h", "lib/hal/hal.h",
"lib/hal/hal_arduino.h", "lib/hal/hal_arduino.h",
"lib/app/full_test_app.h", "lib/app/full_test_app.h",
"build.sh",
"build.bat",
"upload.sh",
"upload.bat",
"monitor.sh",
"monitor.bat",
"test/CMakeLists.txt", "test/CMakeLists.txt",
"test/test_unit.cpp", "test/test_unit.cpp",
"test/run_tests.sh", "test/run_tests.sh",
@@ -406,3 +412,260 @@ fn test_load_config_from_nonproject_fails() {
let result = ProjectConfig::load(tmp.path()); let result = ProjectConfig::load(tmp.path());
assert!(result.is_err()); 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
);
}
}
}