Anvil v1.0.0 -- Arduino build tool with HAL and test scaffolding
Single-binary CLI that scaffolds testable Arduino projects, compiles, uploads, and monitors serial output. Templates embed a hardware abstraction layer, Google Mock infrastructure, and CMake-based host tests so application logic can be verified without hardware. Commands: new, doctor, setup, devices, build, upload, monitor 39 Rust tests (21 unit, 18 integration) Cross-platform: Linux and Windows
This commit is contained in:
345
src/board/mod.rs
Normal file
345
src/board/mod.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
use anyhow::{Result, bail};
|
||||
use colored::*;
|
||||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Information about a detected serial port.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortInfo {
|
||||
pub port_name: String,
|
||||
pub protocol: String,
|
||||
pub board_name: String,
|
||||
pub fqbn: String,
|
||||
}
|
||||
|
||||
/// JSON schema for `arduino-cli board list --format json`
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BoardListOutput {
|
||||
#[serde(default)]
|
||||
detected_ports: Vec<DetectedPort>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DetectedPort {
|
||||
#[serde(default)]
|
||||
port: Option<PortEntry>,
|
||||
#[serde(default)]
|
||||
matching_boards: Option<Vec<MatchingBoard>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct PortEntry {
|
||||
#[serde(default)]
|
||||
address: String,
|
||||
#[serde(default)]
|
||||
protocol: String,
|
||||
#[serde(default)]
|
||||
protocol_label: String,
|
||||
#[serde(default)]
|
||||
properties: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MatchingBoard {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
fqbn: String,
|
||||
}
|
||||
|
||||
/// Enumerate serial ports via `arduino-cli board list --format json`.
|
||||
/// Falls back to OS-level detection if arduino-cli is unavailable.
|
||||
pub fn list_ports() -> Vec<PortInfo> {
|
||||
if let Some(cli) = find_arduino_cli() {
|
||||
if let Ok(ports) = list_ports_via_cli(&cli) {
|
||||
return ports;
|
||||
}
|
||||
}
|
||||
list_ports_fallback()
|
||||
}
|
||||
|
||||
fn list_ports_via_cli(cli: &Path) -> Result<Vec<PortInfo>> {
|
||||
let output = Command::new(cli)
|
||||
.args(["board", "list", "--format", "json"])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("arduino-cli board list failed");
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: BoardListOutput = serde_json::from_str(&stdout)?;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for dp in parsed.detected_ports {
|
||||
let port = match dp.port {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if port.protocol != "serial" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (board_name, fqbn) = match dp.matching_boards {
|
||||
Some(ref boards) if !boards.is_empty() => {
|
||||
(boards[0].name.clone(), boards[0].fqbn.clone())
|
||||
}
|
||||
_ => ("Unknown".to_string(), String::new()),
|
||||
};
|
||||
|
||||
result.push(PortInfo {
|
||||
port_name: port.address,
|
||||
protocol: port.protocol_label,
|
||||
board_name,
|
||||
fqbn,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fallback port detection when arduino-cli is not available.
|
||||
fn list_ports_fallback() -> Vec<PortInfo> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs;
|
||||
if let Ok(entries) = fs::read_dir("/dev") {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("ttyUSB") || name.starts_with("ttyACM") {
|
||||
let path = format!("/dev/{}", name);
|
||||
let board = if name.starts_with("ttyUSB") {
|
||||
"Likely CH340/FTDI (run 'anvil setup' for full detection)"
|
||||
} else {
|
||||
"Likely Arduino (run 'anvil setup' for full detection)"
|
||||
};
|
||||
result.push(PortInfo {
|
||||
port_name: path,
|
||||
protocol: "serial".to_string(),
|
||||
board_name: board.to_string(),
|
||||
fqbn: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile", "-Command",
|
||||
"Get-CimInstance Win32_SerialPort | Select-Object DeviceID,Caption | ConvertTo-Json",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(idx) = line.find("COM") {
|
||||
let port = line[idx..].split('"').next().unwrap_or("").trim();
|
||||
if !port.is_empty() {
|
||||
result.push(PortInfo {
|
||||
port_name: port.to_string(),
|
||||
protocol: "serial".to_string(),
|
||||
board_name: "Detected via WMI".to_string(),
|
||||
fqbn: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Auto-detect a single serial port.
|
||||
pub fn auto_detect_port() -> Result<String> {
|
||||
let ports = list_ports();
|
||||
|
||||
if ports.is_empty() {
|
||||
bail!(
|
||||
"No serial ports found. Is the board plugged in?\n\
|
||||
Run: anvil devices"
|
||||
);
|
||||
}
|
||||
|
||||
if ports.len() == 1 {
|
||||
return Ok(ports[0].port_name.clone());
|
||||
}
|
||||
|
||||
eprintln!("{}", "Multiple serial ports detected:".yellow());
|
||||
for p in &ports {
|
||||
eprintln!(" {} ({})", p.port_name, p.board_name);
|
||||
}
|
||||
|
||||
// Prefer a port with a recognized board
|
||||
for p in &ports {
|
||||
if !p.fqbn.is_empty() {
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!(
|
||||
"Auto-selected {} ({}). Use -p to override.",
|
||||
p.port_name, p.board_name
|
||||
).yellow()
|
||||
);
|
||||
return Ok(p.port_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let selected = ports[0].port_name.clone();
|
||||
eprintln!(
|
||||
"{}",
|
||||
format!("Auto-selected {}. Use -p to override.", selected).yellow()
|
||||
);
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
/// Print detailed port information.
|
||||
pub fn print_port_details(ports: &[PortInfo]) {
|
||||
if ports.is_empty() {
|
||||
println!(" {}", "No serial devices found.".yellow());
|
||||
println!();
|
||||
println!(" Checklist:");
|
||||
println!(" 1. Is the board plugged in via USB?");
|
||||
println!(" 2. Is the USB cable a data cable (not charge-only)?");
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
println!(" 3. Check kernel log: dmesg | tail -20");
|
||||
println!(" 4. Check USB bus: lsusb | grep -i -E 'ch34|arduino|1a86|2341'");
|
||||
}
|
||||
println!(" 5. Try a different USB port or cable");
|
||||
return;
|
||||
}
|
||||
|
||||
for port in ports {
|
||||
println!(" {}", port.port_name.green().bold());
|
||||
println!(" Board: {}", port.board_name);
|
||||
if !port.fqbn.is_empty() {
|
||||
println!(" FQBN: {}", port.fqbn);
|
||||
}
|
||||
if !port.protocol.is_empty() {
|
||||
println!(" Protocol: {}", port.protocol);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::OpenOptions;
|
||||
let path = std::path::Path::new(&port.port_name);
|
||||
if path.exists() {
|
||||
match OpenOptions::new().write(true).open(path) {
|
||||
Ok(_) => {
|
||||
println!(
|
||||
" Permissions: {}",
|
||||
"OK (writable)".green()
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
println!(
|
||||
" Permissions: {}",
|
||||
"NOT writable -- run: sudo usermod -aG dialout $USER"
|
||||
.red()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
/// Find arduino-cli in PATH or in ~/.anvil/bin.
|
||||
pub fn find_arduino_cli() -> Option<PathBuf> {
|
||||
if let Ok(path) = which::which("arduino-cli") {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
if let Ok(home) = crate::project::config::anvil_home() {
|
||||
let name = if cfg!(target_os = "windows") {
|
||||
"arduino-cli.exe"
|
||||
} else {
|
||||
"arduino-cli"
|
||||
};
|
||||
let bin = home.join("bin").join(name);
|
||||
if bin.exists() {
|
||||
return Some(bin);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if the arduino:avr core is installed.
|
||||
pub fn is_avr_core_installed(cli_path: &Path) -> bool {
|
||||
let output = Command::new(cli_path)
|
||||
.args(["core", "list"])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout.contains("arduino:avr")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_list_ports_does_not_panic() {
|
||||
let _ports = list_ports();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_info_clone() {
|
||||
let info = PortInfo {
|
||||
port_name: "/dev/ttyUSB0".to_string(),
|
||||
protocol: "serial".to_string(),
|
||||
board_name: "Test".to_string(),
|
||||
fqbn: "arduino:avr:uno".to_string(),
|
||||
};
|
||||
let cloned = info.clone();
|
||||
assert_eq!(cloned.port_name, info.port_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_board_list() {
|
||||
let json = r#"{"detected_ports": []}"#;
|
||||
let parsed: BoardListOutput = serde_json::from_str(json).unwrap();
|
||||
assert!(parsed.detected_ports.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_board_list_with_port() {
|
||||
let json = r#"{
|
||||
"detected_ports": [{
|
||||
"port": {
|
||||
"address": "/dev/ttyUSB0",
|
||||
"protocol": "serial",
|
||||
"protocol_label": "Serial Port (USB)"
|
||||
},
|
||||
"matching_boards": [{
|
||||
"name": "Arduino Uno",
|
||||
"fqbn": "arduino:avr:uno"
|
||||
}]
|
||||
}]
|
||||
}"#;
|
||||
let parsed: BoardListOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.detected_ports.len(), 1);
|
||||
let dp = &parsed.detected_ports[0];
|
||||
assert_eq!(dp.port.as_ref().unwrap().address, "/dev/ttyUSB0");
|
||||
let boards = dp.matching_boards.as_ref().unwrap();
|
||||
assert_eq!(boards[0].name, "Arduino Uno");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user