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:
Eric Ratliff
2026-02-21 11:46:22 -06:00
parent aa1e9d5043
commit 706f420aaa
15 changed files with 2225 additions and 19 deletions

523
src/commands/lib.rs Normal file
View 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");
}
}

View File

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

View File

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