feat: Layer 2 device library system with TMP36 reference driver
Add embedded device library registry with full lifecycle management, automated test integration, and pin assignment workflow. Library system: - Library registry embedded in binary via include_dir! (same as templates) - library.toml metadata format: name, version, bus type, pins, provided files - anvil add <name> extracts headers to lib/drivers/<name>/, test files to test/ - anvil remove <name> cleans up all files, config entries, and include paths - anvil lib lists installed libraries with status - anvil lib --available shows registry with student-friendly wiring summary - [libraries] section in .anvil.toml tracks installed libraries and versions - CMake auto-discovers lib/drivers/*/ for include paths at configure time TMP36 analog temperature sensor (reference implementation): - TempSensor abstract interface (readCelsius/readFahrenheit/readRaw) - Tmp36Analog: real hardware impl via Hal::analogRead with configurable Vref - Tmp36Mock: programmable values, read counting, no hardware needed - Tmp36Sim: deterministic noise via seeded LCG for repeatable system tests - test_tmp36.cpp: 21 Google Test cases covering mock, analog, sim, polymorphism - TMP36 formula: voltage_mV = raw * Vref_mV / 1024, temp_C = (mV - 500) / 10 Automated test integration: - Library test files (test_*.cpp) route to test/ during extraction - CMakeLists.txt auto-discovers test_*.cpp via GLOB, builds each as own target - anvil remove cleans up test files alongside driver headers - Zero manual CMake editing: add library, run test --clean, tests appear Pin assignment integration: - anvil add <name> --pin A0 does extract + pin assignment in one step - Without --pin, prints step-by-step wiring guidance with copy-paste commands - anvil pin --audit flags installed libraries with unassigned pins - Audit works even with zero existing pin assignments (fixed early-return bug) - LibraryMeta helpers: wiring_summary(), pin_roles(), default_mode() - Bus-aware guidance: analog pins, I2C bus registration, SPI with CS selection UX improvements: - anvil lib --available shows "Needs: 1 analog pin (e.g. A0)" not raw metadata - anvil add prints app code example, test code example, and next step - anvil pin --audit prints exact commands to resolve missing library pins - anvil remove shows test file deletion in output Files added: - libraries/tmp36/library.toml - libraries/tmp36/src/tmp36.h, tmp36_analog.h, tmp36_mock.h, tmp36_sim.h - libraries/tmp36/src/test_tmp36.cpp - src/library/mod.rs Files modified: - src/lib.rs, src/main.rs, src/commands/mod.rs - src/commands/lib.rs (add/remove/list/list_available with --pin support) - src/commands/pin.rs (audit library pin warnings, print_library_pin_warnings) - src/project/config.rs (libraries HashMap field) - templates/basic/test/CMakeLists.txt.tmpl (driver + test auto-discovery) Tests: 254 total (89 unit + 165 integration) - 12 library/mod.rs unit tests (registry, extraction, helpers) - 2 commands/lib.rs unit tests (class name derivation) - 30+ new integration tests covering library lifecycle, pin integration, audit flows, file routing, CMake discovery, config roundtrips, ASCII compliance, polymorphism contracts, and idempotent add/remove cycles
This commit is contained in:
523
src/commands/lib.rs
Normal file
523
src/commands/lib.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
use anyhow::{Result, bail};
|
||||
use colored::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::library;
|
||||
use crate::project::config::ProjectConfig;
|
||||
|
||||
/// Add a library to the current project.
|
||||
pub fn add_library(name: &str, pin: Option<&str>, project_dir: Option<&str>) -> Result<()> {
|
||||
let meta = library::find_library(name)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<String> = library::list_available()
|
||||
.iter()
|
||||
.map(|l| l.name.clone())
|
||||
.collect();
|
||||
anyhow::anyhow!(
|
||||
"Unknown library: '{}'\n Available: {}",
|
||||
name,
|
||||
if available.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
available.join(", ")
|
||||
}
|
||||
)
|
||||
})?;
|
||||
|
||||
let project_path = match project_dir {
|
||||
Some(dir) => PathBuf::from(dir),
|
||||
None => std::env::current_dir()?,
|
||||
};
|
||||
let project_root = ProjectConfig::find_project_root(&project_path)?;
|
||||
let mut config = ProjectConfig::load(&project_root)?;
|
||||
|
||||
// Check if already installed
|
||||
if config.libraries.contains_key(name) {
|
||||
println!(
|
||||
"{} {} is already installed (version {}).",
|
||||
"ok".green(),
|
||||
name.bright_white().bold(),
|
||||
config.libraries[name]
|
||||
);
|
||||
println!(
|
||||
" To reinstall, remove it first: {}",
|
||||
format!("anvil remove {}", name).bright_cyan()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Extract files
|
||||
println!(
|
||||
"Adding {} {}",
|
||||
meta.name.bright_white().bold(),
|
||||
format!("({})", meta.description).bright_black()
|
||||
);
|
||||
|
||||
let written = library::extract_library(name, &project_root)?;
|
||||
for f in &written {
|
||||
println!(" {} {}", "+".bright_green(), f.bright_white());
|
||||
}
|
||||
|
||||
// Add to include_dirs if not present
|
||||
let driver_include = format!("lib/drivers/{}", name);
|
||||
if !config.build.include_dirs.contains(&driver_include) {
|
||||
config.build.include_dirs.push(driver_include);
|
||||
}
|
||||
|
||||
// Track in config
|
||||
config.libraries.insert(name.to_string(), meta.version.clone());
|
||||
config.save(&project_root)?;
|
||||
|
||||
// If --pin was given and this is a simple analog/digital library, assign it now
|
||||
if let Some(pin_str) = pin {
|
||||
if meta.bus == "i2c" || meta.bus == "spi" {
|
||||
println!();
|
||||
println!(
|
||||
" {} --pin is not used for {} libraries (bus pins are fixed).",
|
||||
"note:".bright_yellow(),
|
||||
meta.bus.bright_cyan()
|
||||
);
|
||||
if meta.bus == "i2c" {
|
||||
println!(
|
||||
" Assign the bus: {}",
|
||||
"anvil pin --assign i2c".bright_cyan()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" Assign the bus: {}",
|
||||
"anvil pin --assign spi --cs 10".bright_cyan()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Assign each pin role to the given pin
|
||||
// For single-pin libraries this is straightforward
|
||||
// For multi-pin, the user would need to run multiple commands
|
||||
if meta.pins.len() == 1 {
|
||||
let assign_name = meta.pin_assignment_name(&meta.pins[0]);
|
||||
println!();
|
||||
println!("{}", "Assigning pin:".bright_yellow().bold());
|
||||
// Call pin assignment through the commands module
|
||||
match crate::commands::pin::assign_pin(
|
||||
&assign_name, pin_str,
|
||||
Some(meta.default_mode()),
|
||||
None, project_dir,
|
||||
) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
println!(
|
||||
" {} Pin assignment failed: {}",
|
||||
"!!".red(),
|
||||
e
|
||||
);
|
||||
println!(
|
||||
" You can assign it manually: {}",
|
||||
format!(
|
||||
"anvil pin --assign {} {} --mode {}",
|
||||
assign_name, pin_str, meta.default_mode()
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!();
|
||||
println!(
|
||||
" {} This library needs {} pins. Assign them individually:",
|
||||
"note:".bright_yellow(),
|
||||
meta.pins.len()
|
||||
);
|
||||
for (_role, assign_name) in meta.pin_roles() {
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"anvil pin --assign {} <PIN> --mode {}",
|
||||
assign_name, meta.default_mode()
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print usage help
|
||||
println!();
|
||||
println!("{}", "In your app code:".bright_yellow().bold());
|
||||
println!(
|
||||
" #include \"{}\"",
|
||||
meta.interface.bright_white()
|
||||
);
|
||||
println!(
|
||||
" #include \"{}\"",
|
||||
meta.implementation.bright_white()
|
||||
);
|
||||
match meta.bus.as_str() {
|
||||
"analog" => {
|
||||
let pin_example = pin.unwrap_or("A0");
|
||||
println!(
|
||||
" {} sensor(&hal, {});",
|
||||
impl_class_name(&meta.implementation).bright_cyan(),
|
||||
pin_example
|
||||
);
|
||||
println!(" float temp = sensor.readCelsius();");
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
" {} sensor(&hal);",
|
||||
impl_class_name(&meta.implementation).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "In your tests:".bright_yellow().bold());
|
||||
println!(
|
||||
" #include \"{}\"",
|
||||
meta.mock.bright_white()
|
||||
);
|
||||
println!(
|
||||
" {} sensor;",
|
||||
mock_class_name(&meta.mock).bright_cyan()
|
||||
);
|
||||
println!(" sensor.setTemperature(25.0f);");
|
||||
println!(
|
||||
" // Your app sees 25 C -- no hardware needed"
|
||||
);
|
||||
|
||||
// Next steps
|
||||
println!();
|
||||
if pin.is_none() && meta.bus != "i2c" && meta.bus != "spi" {
|
||||
println!("{}", "Next step:".bright_yellow().bold());
|
||||
print_wiring_guide(&meta, None);
|
||||
} else if pin.is_none() && (meta.bus == "i2c" || meta.bus == "spi") {
|
||||
println!("{}", "Next step:".bright_yellow().bold());
|
||||
print_bus_guide(&meta);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} {} {} added. Run {} to rebuild tests.",
|
||||
"ok".green(),
|
||||
meta.name.bright_white().bold(),
|
||||
meta.version.bright_black(),
|
||||
"./test.sh --clean".bright_cyan()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print wiring guidance for analog/digital libraries.
|
||||
fn print_wiring_guide(meta: &library::LibraryMeta, board: Option<&str>) {
|
||||
let board_flag = board.map(|b| format!(" --board {}", b)).unwrap_or_default();
|
||||
match meta.bus.as_str() {
|
||||
"analog" => {
|
||||
println!(
|
||||
" Wire the {} sensor signal pin to an analog input (e.g. {}).",
|
||||
meta.name.bright_white(),
|
||||
"A0".bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" Then tell Anvil which pin you chose:"
|
||||
);
|
||||
println!();
|
||||
for (_role, assign_name) in meta.pin_roles() {
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"anvil pin --assign {} A0 --mode analog{}",
|
||||
assign_name, board_flag
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"Pick any analog pin (A0-A5 on Uno). The pin you choose here"
|
||||
.bright_black()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"must match the physical wire AND the pin in your code."
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
"digital" => {
|
||||
println!(
|
||||
" Wire the {} sensor to a digital pin.",
|
||||
meta.name.bright_white()
|
||||
);
|
||||
println!(
|
||||
" Then tell Anvil which pin you chose:"
|
||||
);
|
||||
println!();
|
||||
for (_role, assign_name) in meta.pin_roles() {
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"anvil pin --assign {} <PIN> --mode input{}",
|
||||
assign_name, board_flag
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for (role, assign_name) in meta.pin_roles() {
|
||||
println!(
|
||||
" Assign {} pin: {}",
|
||||
role.bright_white(),
|
||||
format!(
|
||||
"anvil pin --assign {} <PIN>{}",
|
||||
assign_name, board_flag
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Print wiring guidance for I2C/SPI bus libraries.
|
||||
fn print_bus_guide(meta: &library::LibraryMeta) {
|
||||
match meta.bus.as_str() {
|
||||
"i2c" => {
|
||||
println!(
|
||||
" Wire the {} sensor to the I2C bus (SDA + SCL pins).",
|
||||
meta.name.bright_white()
|
||||
);
|
||||
println!(
|
||||
" Then register the bus:"
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"anvil pin --assign i2c".bright_cyan()
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"SDA and SCL are fixed pins on your board (A4/A5 on Uno)."
|
||||
.bright_black()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"Anvil knows which pins they are -- just register the bus."
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
"spi" => {
|
||||
println!(
|
||||
" Wire the {} sensor to the SPI bus (MOSI/MISO/SCK) with a CS pin.",
|
||||
meta.name.bright_white()
|
||||
);
|
||||
println!(
|
||||
" Then register the bus with your chosen CS pin:"
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"anvil pin --assign spi --cs 10".bright_cyan()
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
"MOSI/MISO/SCK are fixed on your board. You pick the CS pin."
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Remove a library from the current project.
|
||||
pub fn remove_library(name: &str, project_dir: Option<&str>) -> Result<()> {
|
||||
let project_path = match project_dir {
|
||||
Some(dir) => PathBuf::from(dir),
|
||||
None => std::env::current_dir()?,
|
||||
};
|
||||
let project_root = ProjectConfig::find_project_root(&project_path)?;
|
||||
let mut config = ProjectConfig::load(&project_root)?;
|
||||
|
||||
if !config.libraries.contains_key(name)
|
||||
&& !library::is_installed_on_disk(name, &project_root)
|
||||
{
|
||||
bail!(
|
||||
"Library '{}' is not installed.\n See installed: anvil lib\n See available: anvil lib --available",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
// Remove files
|
||||
let test_file = project_root.join("test").join(format!("test_{}.cpp", name));
|
||||
let had_test = test_file.exists();
|
||||
library::remove_library_files(name, &project_root)?;
|
||||
println!(
|
||||
" {} lib/drivers/{}/",
|
||||
"-".bright_red(),
|
||||
name.bright_white()
|
||||
);
|
||||
if had_test {
|
||||
println!(
|
||||
" {} test/test_{}.cpp",
|
||||
"-".bright_red(),
|
||||
name.bright_white()
|
||||
);
|
||||
}
|
||||
|
||||
// Remove from include_dirs
|
||||
let driver_include = format!("lib/drivers/{}", name);
|
||||
config.build.include_dirs.retain(|d| d != &driver_include);
|
||||
|
||||
// Remove from tracked libraries
|
||||
config.libraries.remove(name);
|
||||
config.save(&project_root)?;
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{} {} removed.",
|
||||
"ok".green(),
|
||||
name.bright_white().bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List installed libraries in the current project.
|
||||
pub fn list_libraries(project_dir: Option<&str>) -> Result<()> {
|
||||
let project_path = match project_dir {
|
||||
Some(dir) => PathBuf::from(dir),
|
||||
None => std::env::current_dir()?,
|
||||
};
|
||||
let project_root = ProjectConfig::find_project_root(&project_path)?;
|
||||
let config = ProjectConfig::load(&project_root)?;
|
||||
|
||||
if config.libraries.is_empty() {
|
||||
println!("No libraries installed.");
|
||||
println!();
|
||||
println!(
|
||||
" Browse available: {}",
|
||||
"anvil lib --available".bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" Add one: {}",
|
||||
"anvil add tmp36".bright_cyan()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{}", "Installed libraries:".bright_yellow().bold());
|
||||
println!();
|
||||
|
||||
for (name, version) in &config.libraries {
|
||||
let on_disk = library::is_installed_on_disk(name, &project_root);
|
||||
let meta = library::find_library(name);
|
||||
|
||||
let desc = meta
|
||||
.as_ref()
|
||||
.map(|m| m.description.as_str())
|
||||
.unwrap_or("(unknown)");
|
||||
|
||||
if on_disk {
|
||||
println!(
|
||||
" {} {} {} {}",
|
||||
"ok".green(),
|
||||
name.bright_white().bold(),
|
||||
version.bright_black(),
|
||||
desc.bright_black()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} {} {} {}",
|
||||
"!!".red(),
|
||||
name.bright_white().bold(),
|
||||
version.bright_black(),
|
||||
"files missing -- run: anvil add ".red()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all available libraries in the Anvil registry.
|
||||
pub fn list_available() -> Result<()> {
|
||||
let libs = library::list_available();
|
||||
|
||||
if libs.is_empty() {
|
||||
println!("No libraries available.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{}", "Available libraries:".bright_yellow().bold());
|
||||
println!();
|
||||
|
||||
for lib in &libs {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
lib.name.bright_white().bold(),
|
||||
lib.version.bright_black(),
|
||||
lib.description
|
||||
);
|
||||
println!(
|
||||
" Needs: {}",
|
||||
lib.wiring_summary().bright_cyan()
|
||||
);
|
||||
// Show inline pin example for simple cases
|
||||
match lib.bus.as_str() {
|
||||
"analog" if lib.pins.len() == 1 => {
|
||||
println!(
|
||||
" Add: {}",
|
||||
format!("anvil add {} --pin A0", lib.name).bright_cyan()
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
" Add: {}",
|
||||
format!("anvil add {}", lib.name).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive class name from implementation filename.
|
||||
/// "tmp36_analog.h" -> "Tmp36Analog"
|
||||
fn impl_class_name(filename: &str) -> String {
|
||||
let stem = filename.trim_end_matches(".h");
|
||||
stem.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(c) => {
|
||||
let mut s = c.to_uppercase().to_string();
|
||||
s.push_str(&chars.collect::<String>());
|
||||
s
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive mock class name from mock filename.
|
||||
/// "tmp36_mock.h" -> "Tmp36Mock"
|
||||
fn mock_class_name(filename: &str) -> String {
|
||||
impl_class_name(filename)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_impl_class_name() {
|
||||
assert_eq!(impl_class_name("tmp36_analog.h"), "Tmp36Analog");
|
||||
assert_eq!(impl_class_name("bmp280_i2c.h"), "Bmp280I2c");
|
||||
assert_eq!(impl_class_name("servo.h"), "Servo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mock_class_name() {
|
||||
assert_eq!(mock_class_name("tmp36_mock.h"), "Tmp36Mock");
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,5 @@ pub mod setup;
|
||||
pub mod devices;
|
||||
pub mod refresh;
|
||||
pub mod board;
|
||||
pub mod pin;
|
||||
pub mod pin;
|
||||
pub mod lib;
|
||||
@@ -7,6 +7,7 @@ use std::path::PathBuf;
|
||||
use crate::board::pinmap::{
|
||||
self, BoardPinMap, ALL_CAPABILITIES, ALL_MODES,
|
||||
};
|
||||
use crate::library;
|
||||
use crate::project::config::{
|
||||
ProjectConfig, BoardPinConfig, PinAssignment, BusConfig, CONFIG_FILENAME,
|
||||
};
|
||||
@@ -393,16 +394,22 @@ pub fn audit_pins(
|
||||
if pc.assignments.is_empty() && pc.buses.is_empty() {
|
||||
println!(" {}", "No pin assignments configured.".bright_black());
|
||||
println!();
|
||||
println!(" Get started:");
|
||||
println!(
|
||||
" {}",
|
||||
format!("anvil pin --assign led 13 --board {}", board).bright_black()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
format!("anvil pin --assign i2c --board {}", board).bright_black()
|
||||
);
|
||||
println!();
|
||||
|
||||
// Even with no manual assignments, libraries may need pins
|
||||
let has_library_warnings = print_library_pin_warnings(&config, pc);
|
||||
|
||||
if !has_library_warnings {
|
||||
println!(" Get started:");
|
||||
println!(
|
||||
" {}",
|
||||
format!("anvil pin --assign led 13 --board {}", board).bright_black()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
format!("anvil pin --assign i2c --board {}", board).bright_black()
|
||||
);
|
||||
println!();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -512,6 +519,9 @@ pub fn audit_pins(
|
||||
}
|
||||
}
|
||||
|
||||
// Library pin check
|
||||
print_library_pin_warnings(&config, pc);
|
||||
|
||||
// Wiring checklist
|
||||
println!(" {}", "Wiring Checklist:".bold());
|
||||
let mut all_wiring: Vec<(u8, String, String)> = Vec::new();
|
||||
@@ -554,6 +564,126 @@ pub fn audit_pins(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check installed libraries for unassigned pins and print warnings.
|
||||
/// Returns true if any warnings were printed.
|
||||
fn print_library_pin_warnings(config: &ProjectConfig, pc: &BoardPinConfig) -> bool {
|
||||
if config.libraries.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let assigned_names: Vec<String> = pc.assignments.keys().cloned().collect();
|
||||
let mut missing_any = false;
|
||||
|
||||
for (lib_name, _version) in &config.libraries {
|
||||
if let Some(meta) = library::find_library(lib_name) {
|
||||
// Bus-based libraries (i2c/spi) use bus assignments
|
||||
if meta.bus == "i2c" || meta.bus == "spi" {
|
||||
if !pc.buses.contains_key(&meta.bus) {
|
||||
if !missing_any {
|
||||
println!(" {}", "Library Wiring:".bright_yellow().bold());
|
||||
missing_any = true;
|
||||
}
|
||||
println!(
|
||||
" {} {} needs the {} bus -- not yet assigned",
|
||||
"!!".red().bold(),
|
||||
lib_name.bright_white(),
|
||||
meta.bus.bright_cyan()
|
||||
);
|
||||
match meta.bus.as_str() {
|
||||
"i2c" => {
|
||||
println!(
|
||||
" Wire SDA + SCL, then: {}",
|
||||
"anvil pin --assign i2c".bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"(SDA/SCL are fixed pins on your board -- A4/A5 on Uno)"
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
"spi" => {
|
||||
println!(
|
||||
" Wire MOSI/MISO/SCK + CS, then: {}",
|
||||
"anvil pin --assign spi --cs 10".bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"(MOSI/MISO/SCK are fixed. You pick the CS pin.)"
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let unassigned = library::unassigned_pins(&meta, &assigned_names);
|
||||
if !unassigned.is_empty() {
|
||||
if !missing_any {
|
||||
println!(" {}", "Library Wiring:".bright_yellow().bold());
|
||||
missing_any = true;
|
||||
}
|
||||
for assign_name in &unassigned {
|
||||
println!(
|
||||
" {} {} needs {} pin \"{}\" -- not yet assigned",
|
||||
"!!".red().bold(),
|
||||
lib_name.bright_white(),
|
||||
meta.bus.bright_cyan(),
|
||||
assign_name.bright_white()
|
||||
);
|
||||
match meta.bus.as_str() {
|
||||
"analog" => {
|
||||
println!(
|
||||
" Wire the sensor to an analog pin (e.g. A0), then:",
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"anvil pin --assign {} A0 --mode analog",
|
||||
assign_name
|
||||
).bright_cyan()
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
"Any analog pin works (A0-A5 on Uno). Match wire to code."
|
||||
.bright_black()
|
||||
);
|
||||
}
|
||||
"digital" => {
|
||||
println!(
|
||||
" Wire the sensor to a digital pin, then:",
|
||||
);
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"anvil pin --assign {} <PIN> --mode input",
|
||||
assign_name
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
" {}",
|
||||
format!(
|
||||
"anvil pin --assign {} <PIN>",
|
||||
assign_name
|
||||
).bright_cyan()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if missing_any {
|
||||
println!();
|
||||
}
|
||||
|
||||
missing_any
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Init pins from another board
|
||||
// =========================================================================
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod commands;
|
||||
pub mod project;
|
||||
pub mod board;
|
||||
pub mod templates;
|
||||
pub mod library;
|
||||
358
src/library/mod.rs
Normal file
358
src/library/mod.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
use include_dir::{include_dir, Dir};
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use anyhow::{Result, Context};
|
||||
|
||||
static LIBRARY_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/libraries");
|
||||
|
||||
/// Metadata parsed from a library's library.toml.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibraryMeta {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub bus: String,
|
||||
pub pins: Vec<String>,
|
||||
pub interface: String,
|
||||
pub implementation: String,
|
||||
pub mock: String,
|
||||
pub simulation: Option<String>,
|
||||
pub test: Option<String>,
|
||||
}
|
||||
|
||||
impl LibraryMeta {
|
||||
/// Human-readable wiring requirement (e.g. "1 analog pin (e.g. A0)").
|
||||
pub fn wiring_summary(&self) -> String {
|
||||
match self.bus.as_str() {
|
||||
"analog" => format!(
|
||||
"{} analog pin{} (e.g. A0)",
|
||||
self.pins.len(),
|
||||
if self.pins.len() == 1 { "" } else { "s" }
|
||||
),
|
||||
"i2c" => "I2C bus (SDA + SCL)".to_string(),
|
||||
"spi" => "SPI bus (MOSI + MISO + SCK + CS)".to_string(),
|
||||
"digital" => format!(
|
||||
"{} digital pin{}",
|
||||
self.pins.len(),
|
||||
if self.pins.len() == 1 { "" } else { "s" }
|
||||
),
|
||||
_ => format!("{} pin(s)", self.pins.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default pin mode for assignments derived from bus type.
|
||||
pub fn default_mode(&self) -> &str {
|
||||
match self.bus.as_str() {
|
||||
"analog" => "analog",
|
||||
"digital" => "input",
|
||||
_ => "input",
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the pin assignment name for a given role.
|
||||
/// e.g. tmp36 + "data" -> "tmp36_data"
|
||||
pub fn pin_assignment_name(&self, role: &str) -> String {
|
||||
format!("{}_{}", self.name, role)
|
||||
}
|
||||
|
||||
/// Return all pin roles with their assignment names.
|
||||
/// e.g. [("data", "tmp36_data")]
|
||||
pub fn pin_roles(&self) -> Vec<(&str, String)> {
|
||||
self.pins.iter()
|
||||
.map(|role| (role.as_str(), self.pin_assignment_name(role)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available libraries embedded in Anvil.
|
||||
pub fn list_available() -> Vec<LibraryMeta> {
|
||||
let mut libs = Vec::new();
|
||||
for dir in LIBRARY_DIR.dirs() {
|
||||
if let Some(meta) = parse_library_dir(dir) {
|
||||
libs.push(meta);
|
||||
}
|
||||
}
|
||||
libs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
libs
|
||||
}
|
||||
|
||||
/// Look up a specific library by name.
|
||||
pub fn find_library(name: &str) -> Option<LibraryMeta> {
|
||||
list_available().into_iter().find(|lib| lib.name == name)
|
||||
}
|
||||
|
||||
/// Extract a library's source files into a project.
|
||||
/// Header files go to lib/drivers/NAME/, test files go to test/.
|
||||
pub fn extract_library(name: &str, project_root: &Path) -> Result<Vec<String>> {
|
||||
let lib_dir = LIBRARY_DIR.get_dir(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Library '{}' not found in registry", name))?;
|
||||
|
||||
let src_dir = lib_dir.get_dir(&format!("{}/src", name))
|
||||
.or_else(|| lib_dir.get_dir("src"))
|
||||
.ok_or_else(|| anyhow::anyhow!("Library '{}' has no src/ directory", name))?;
|
||||
|
||||
let dest_dir = project_root.join("lib").join("drivers").join(name);
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.context(format!("Failed to create {}", dest_dir.display()))?;
|
||||
|
||||
let test_dir = project_root.join("test");
|
||||
// Ensure test dir exists for library test files
|
||||
if src_dir.files().any(|f| {
|
||||
f.path().file_name()
|
||||
.map(|n| {
|
||||
let s = n.to_string_lossy();
|
||||
s.starts_with("test_") && s.ends_with(".cpp")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
fs::create_dir_all(&test_dir)
|
||||
.context("Failed to create test directory")?;
|
||||
}
|
||||
|
||||
let mut written = Vec::new();
|
||||
|
||||
for file in src_dir.files() {
|
||||
let file_name = file.path().file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if file_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Test files go into test/ directory, everything else into lib/drivers/NAME/
|
||||
if file_name.starts_with("test_") && file_name.ends_with(".cpp") {
|
||||
let dest_path = test_dir.join(&file_name);
|
||||
fs::write(&dest_path, file.contents())
|
||||
.context(format!("Failed to write {}", dest_path.display()))?;
|
||||
written.push(format!("test/{}", file_name));
|
||||
} else {
|
||||
let dest_path = dest_dir.join(&file_name);
|
||||
fs::write(&dest_path, file.contents())
|
||||
.context(format!("Failed to write {}", dest_path.display()))?;
|
||||
written.push(format!("lib/drivers/{}/{}", name, file_name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Remove a library's files from a project.
|
||||
pub fn remove_library_files(name: &str, project_root: &Path) -> Result<()> {
|
||||
let dir = project_root.join("lib").join("drivers").join(name);
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)
|
||||
.context(format!("Failed to remove {}", dir.display()))?;
|
||||
}
|
||||
|
||||
// Remove the library's test file from test/ if present
|
||||
let test_file = project_root.join("test").join(format!("test_{}.cpp", name));
|
||||
if test_file.exists() {
|
||||
fs::remove_file(&test_file)
|
||||
.context(format!("Failed to remove {}", test_file.display()))?;
|
||||
}
|
||||
|
||||
// Clean up empty drivers/ directory
|
||||
let drivers_dir = project_root.join("lib").join("drivers");
|
||||
if drivers_dir.exists() {
|
||||
if fs::read_dir(&drivers_dir)?.next().is_none() {
|
||||
fs::remove_dir(&drivers_dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a library is installed in a project (files exist on disk).
|
||||
pub fn is_installed_on_disk(name: &str, project_root: &Path) -> bool {
|
||||
project_root.join("lib").join("drivers").join(name).is_dir()
|
||||
}
|
||||
|
||||
/// Return library pin roles that are not yet assigned in the config.
|
||||
/// Takes a set of assignment names from the board's pin config.
|
||||
pub fn unassigned_pins(meta: &LibraryMeta, assigned_names: &[String]) -> Vec<String> {
|
||||
meta.pin_roles()
|
||||
.into_iter()
|
||||
.filter(|(_role, assign_name)| !assigned_names.contains(assign_name))
|
||||
.map(|(_role, assign_name)| assign_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a library.toml from an embedded directory.
|
||||
fn parse_library_dir(dir: &Dir<'_>) -> Option<LibraryMeta> {
|
||||
// The include_dir structure nests: libraries/tmp36/library.toml
|
||||
// but dir.get_file() uses relative paths from the dir root.
|
||||
let toml_file = dir.files().find(|f| {
|
||||
f.path().file_name()
|
||||
.map(|n| n.to_string_lossy() == "library.toml")
|
||||
.unwrap_or(false)
|
||||
})?;
|
||||
|
||||
let content = std::str::from_utf8(toml_file.contents()).ok()?;
|
||||
let table: toml::Table = content.parse().ok()?;
|
||||
|
||||
let lib = table.get("library")?.as_table()?;
|
||||
let requires = table.get("requires")?.as_table()?;
|
||||
let provides = table.get("provides")?.as_table()?;
|
||||
|
||||
let pins = requires.get("pins")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect();
|
||||
|
||||
Some(LibraryMeta {
|
||||
name: lib.get("name")?.as_str()?.to_string(),
|
||||
version: lib.get("version")?.as_str()?.to_string(),
|
||||
description: lib.get("description")?.as_str()?.to_string(),
|
||||
bus: requires.get("bus")?.as_str()?.to_string(),
|
||||
pins,
|
||||
interface: provides.get("interface")?.as_str()?.to_string(),
|
||||
implementation: provides.get("implementation")?.as_str()?.to_string(),
|
||||
mock: provides.get("mock")?.as_str()?.to_string(),
|
||||
simulation: provides.get("simulation")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
test: provides.get("test")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_list_available_includes_tmp36() {
|
||||
let libs = list_available();
|
||||
assert!(
|
||||
libs.iter().any(|l| l.name == "tmp36"),
|
||||
"Should include tmp36 library"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_library_tmp36() {
|
||||
let meta = find_library("tmp36").expect("tmp36 should exist");
|
||||
assert_eq!(meta.name, "tmp36");
|
||||
assert_eq!(meta.bus, "analog");
|
||||
assert_eq!(meta.pins, vec!["data"]);
|
||||
assert_eq!(meta.interface, "tmp36.h");
|
||||
assert_eq!(meta.implementation, "tmp36_analog.h");
|
||||
assert_eq!(meta.mock, "tmp36_mock.h");
|
||||
assert!(meta.simulation.is_some());
|
||||
assert_eq!(meta.test.as_deref(), Some("test_tmp36.cpp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_library_nonexistent() {
|
||||
assert!(find_library("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_library_creates_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let written = extract_library("tmp36", tmp.path()).unwrap();
|
||||
|
||||
assert!(!written.is_empty(), "Should write at least one file");
|
||||
|
||||
// Headers in lib/drivers/tmp36/
|
||||
let driver_dir = tmp.path().join("lib/drivers/tmp36");
|
||||
assert!(driver_dir.exists(), "Driver directory should exist");
|
||||
assert!(driver_dir.join("tmp36.h").exists(), "Interface should exist");
|
||||
assert!(driver_dir.join("tmp36_analog.h").exists(), "Implementation should exist");
|
||||
assert!(driver_dir.join("tmp36_mock.h").exists(), "Mock should exist");
|
||||
assert!(driver_dir.join("tmp36_sim.h").exists(), "Simulation should exist");
|
||||
|
||||
// Test file in test/
|
||||
assert!(
|
||||
tmp.path().join("test/test_tmp36.cpp").exists(),
|
||||
"Test file should be in test/ directory"
|
||||
);
|
||||
assert!(
|
||||
written.iter().any(|w| w == "test/test_tmp36.cpp"),
|
||||
"Written list should include test file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_library_cleans_up() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
extract_library("tmp36", tmp.path()).unwrap();
|
||||
|
||||
assert!(is_installed_on_disk("tmp36", tmp.path()));
|
||||
assert!(tmp.path().join("test/test_tmp36.cpp").exists());
|
||||
remove_library_files("tmp36", tmp.path()).unwrap();
|
||||
assert!(!is_installed_on_disk("tmp36", tmp.path()));
|
||||
assert!(!tmp.path().join("test/test_tmp36.cpp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_library_files_are_ascii() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
extract_library("tmp36", tmp.path()).unwrap();
|
||||
|
||||
let driver_dir = tmp.path().join("lib/drivers/tmp36");
|
||||
for entry in fs::read_dir(&driver_dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let content = fs::read_to_string(entry.path()).unwrap();
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
for (col, ch) in line.chars().enumerate() {
|
||||
assert!(
|
||||
ch.is_ascii(),
|
||||
"Non-ASCII in {} at {}:{}: U+{:04X}",
|
||||
entry.file_name().to_string_lossy(),
|
||||
line_num + 1, col + 1, ch as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wiring_summary_analog() {
|
||||
let meta = find_library("tmp36").unwrap();
|
||||
let summary = meta.wiring_summary();
|
||||
assert!(summary.contains("analog"), "Should mention analog");
|
||||
assert!(summary.contains("A0"), "Should suggest A0 as example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pin_assignment_name() {
|
||||
let meta = find_library("tmp36").unwrap();
|
||||
assert_eq!(meta.pin_assignment_name("data"), "tmp36_data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pin_roles() {
|
||||
let meta = find_library("tmp36").unwrap();
|
||||
let roles = meta.pin_roles();
|
||||
assert_eq!(roles.len(), 1);
|
||||
assert_eq!(roles[0].0, "data");
|
||||
assert_eq!(roles[0].1, "tmp36_data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_mode_analog() {
|
||||
let meta = find_library("tmp36").unwrap();
|
||||
assert_eq!(meta.default_mode(), "analog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unassigned_pins_all_missing() {
|
||||
let meta = find_library("tmp36").unwrap();
|
||||
let assigned: Vec<String> = vec![];
|
||||
let missing = unassigned_pins(&meta, &assigned);
|
||||
assert_eq!(missing, vec!["tmp36_data"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unassigned_pins_all_assigned() {
|
||||
let meta = find_library("tmp36").unwrap();
|
||||
let assigned = vec!["tmp36_data".to_string()];
|
||||
let missing = unassigned_pins(&meta, &assigned);
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
}
|
||||
48
src/main.rs
48
src/main.rs
@@ -120,6 +120,41 @@ enum Commands {
|
||||
dir: Option<String>,
|
||||
},
|
||||
|
||||
/// Add a device library to the project
|
||||
Add {
|
||||
/// Library name (e.g. tmp36)
|
||||
name: String,
|
||||
|
||||
/// Assign a pin during add (e.g. --pin A0)
|
||||
#[arg(long, value_name = "PIN")]
|
||||
pin: Option<String>,
|
||||
|
||||
/// Path to project directory (defaults to current directory)
|
||||
#[arg(long, short = 'd', value_name = "DIR")]
|
||||
dir: Option<String>,
|
||||
},
|
||||
|
||||
/// Remove a device library from the project
|
||||
Remove {
|
||||
/// Library name (e.g. tmp36)
|
||||
name: String,
|
||||
|
||||
/// Path to project directory (defaults to current directory)
|
||||
#[arg(long, short = 'd', value_name = "DIR")]
|
||||
dir: Option<String>,
|
||||
},
|
||||
|
||||
/// List installed or available device libraries
|
||||
Lib {
|
||||
/// Show all available libraries (not just installed)
|
||||
#[arg(long)]
|
||||
available: bool,
|
||||
|
||||
/// Path to project directory (defaults to current directory)
|
||||
#[arg(long, short = 'd', value_name = "DIR")]
|
||||
dir: Option<String>,
|
||||
},
|
||||
|
||||
/// View pin maps, assign pins, and audit wiring
|
||||
Pin {
|
||||
/// Capability filter (pwm, analog, spi, i2c, uart, interrupt)
|
||||
@@ -279,6 +314,19 @@ fn main() -> Result<()> {
|
||||
commands::board::list_boards(dir.as_deref())
|
||||
}
|
||||
}
|
||||
Commands::Add { name, pin, dir } => {
|
||||
commands::lib::add_library(&name, pin.as_deref(), dir.as_deref())
|
||||
}
|
||||
Commands::Remove { name, dir } => {
|
||||
commands::lib::remove_library(&name, dir.as_deref())
|
||||
}
|
||||
Commands::Lib { available, dir } => {
|
||||
if available {
|
||||
commands::lib::list_available()
|
||||
} else {
|
||||
commands::lib::list_libraries(dir.as_deref())
|
||||
}
|
||||
}
|
||||
Commands::Pin {
|
||||
name, pin, assign, remove, audit, brief,
|
||||
generate, capabilities, init_from, mode, cs, board, dir,
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct ProjectConfig {
|
||||
pub boards: HashMap<String, BoardProfile>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub pins: HashMap<String, BoardPinConfig>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub libraries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -105,6 +107,7 @@ impl ProjectConfig {
|
||||
},
|
||||
boards,
|
||||
pins: HashMap::new(),
|
||||
libraries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user