From 68e618feef47e2057b65b8a960eebbffa792cf7d Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Thu, 19 Feb 2026 16:58:41 -0600 Subject: [PATCH] Included pin subcommand with auditing and assignment --- src/board/mod.rs | 1 + src/board/pinmap.rs | 618 ++++++++++++++++++++++ src/commands/mod.rs | 3 +- src/commands/pin.rs | 1034 +++++++++++++++++++++++++++++++++++++ src/main.rs | 156 ++++++ src/project/config.rs | 29 ++ tests/integration_test.rs | 271 ++++++++++ 7 files changed, 2111 insertions(+), 1 deletion(-) create mode 100644 src/board/pinmap.rs create mode 100644 src/commands/pin.rs diff --git a/src/board/mod.rs b/src/board/mod.rs index 2280172..ad6c7cb 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; pub mod presets; +pub mod pinmap; /// Information about a detected serial port. #[derive(Debug, Clone)] diff --git a/src/board/pinmap.rs b/src/board/pinmap.rs new file mode 100644 index 0000000..263b938 --- /dev/null +++ b/src/board/pinmap.rs @@ -0,0 +1,618 @@ +/// Pin maps for supported Arduino boards. +/// +/// Each board has a static pin map describing: +/// - Available pins and their capabilities +/// - Hardware bus groups (SPI, I2C, UART) +/// - Supported capability categories for filtering + +// -- Capability names (used as filter keys) ------------------------------- + +/// All recognized pin capabilities. Displayed by `anvil pin --capabilities`. +pub const ALL_CAPABILITIES: &[CapabilityInfo] = &[ + CapabilityInfo { name: "digital", description: "Digital I/O (HIGH/LOW)" }, + CapabilityInfo { name: "analog", description: "Analog input (ADC)" }, + CapabilityInfo { name: "pwm", description: "PWM output (analogWrite)" }, + CapabilityInfo { name: "interrupt", description: "External interrupt (attachInterrupt)" }, + CapabilityInfo { name: "spi", description: "SPI bus pins" }, + CapabilityInfo { name: "i2c", description: "I2C / TWI bus pins" }, + CapabilityInfo { name: "uart", description: "UART / Serial pins" }, + CapabilityInfo { name: "led", description: "Built-in LED" }, +]; + +/// All recognized pin modes for assignments. +pub const ALL_MODES: &[&str] = &[ + "input", "output", "input_pullup", "pwm", "analog", +]; + +// -- Data types ----------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct CapabilityInfo { + pub name: &'static str, + pub description: &'static str, +} + +#[derive(Debug, Clone)] +pub struct PinInfo { + /// Arduino digital pin number + pub number: u8, + /// Alternate names (e.g. "A0", "LED_BUILTIN", "SDA") + pub aliases: &'static [&'static str], + /// Capabilities this pin supports + pub capabilities: &'static [&'static str], +} + +#[derive(Debug, Clone)] +pub struct BusGroup { + /// Bus name: "spi", "i2c", "uart", "uart1", etc. + pub name: &'static str, + /// Human description + pub description: &'static str, + /// Fixed pins: (role, pin_number) + pub fixed_pins: &'static [(&'static str, u8)], + /// Roles the user must select a pin for (e.g. "cs" for SPI) + pub user_selectable: &'static [&'static str], +} + +#[derive(Debug, Clone)] +pub struct BoardPinMap { + /// Preset board name (matches presets.rs) + pub board: &'static str, + /// Total digital pin count + pub total_digital: u8, + /// Total analog-capable pin count + pub total_analog: u8, + /// All pins + pub pins: &'static [PinInfo], + /// Hardware bus groups + pub groups: &'static [BusGroup], +} + +// -- Pin maps ------------------------------------------------------------- + +pub static PINMAPS: &[&BoardPinMap] = &[ + &PIN_MAP_UNO, + &PIN_MAP_MEGA, + &PIN_MAP_NANO, + &PIN_MAP_LEONARDO, + &PIN_MAP_MICRO, +]; + +// Arduino Uno (ATmega328P) +static PIN_MAP_UNO: BoardPinMap = BoardPinMap { + board: "uno", + total_digital: 14, + total_analog: 6, + pins: &[ + PinInfo { number: 0, aliases: &["RX"], capabilities: &["digital", "uart"] }, + PinInfo { number: 1, aliases: &["TX"], capabilities: &["digital", "uart"] }, + PinInfo { number: 2, aliases: &["INT0"], capabilities: &["digital", "interrupt"] }, + PinInfo { number: 3, aliases: &["INT1"], capabilities: &["digital", "pwm", "interrupt"] }, + PinInfo { number: 4, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 5, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 6, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 7, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 8, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 9, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 10, aliases: &["SPI_SS"], capabilities: &["digital", "pwm", "spi"] }, + PinInfo { number: 11, aliases: &["SPI_MOSI"], capabilities: &["digital", "pwm", "spi"] }, + PinInfo { number: 12, aliases: &["SPI_MISO"], capabilities: &["digital", "spi"] }, + PinInfo { number: 13, aliases: &["SPI_SCK", "LED_BUILTIN"], capabilities: &["digital", "spi", "led"] }, + PinInfo { number: 14, aliases: &["A0"], capabilities: &["digital", "analog"] }, + PinInfo { number: 15, aliases: &["A1"], capabilities: &["digital", "analog"] }, + PinInfo { number: 16, aliases: &["A2"], capabilities: &["digital", "analog"] }, + PinInfo { number: 17, aliases: &["A3"], capabilities: &["digital", "analog"] }, + PinInfo { number: 18, aliases: &["A4", "SDA"], capabilities: &["digital", "analog", "i2c"] }, + PinInfo { number: 19, aliases: &["A5", "SCL"], capabilities: &["digital", "analog", "i2c"] }, + ], + groups: &[ + BusGroup { + name: "spi", + description: "SPI bus (hardware)", + fixed_pins: &[("sck", 13), ("mosi", 11), ("miso", 12)], + user_selectable: &["cs"], + }, + BusGroup { + name: "i2c", + description: "I2C / TWI bus", + fixed_pins: &[("sda", 18), ("scl", 19)], + user_selectable: &[], + }, + BusGroup { + name: "uart", + description: "Hardware serial (Serial)", + fixed_pins: &[("rx", 0), ("tx", 1)], + user_selectable: &[], + }, + ], +}; + +// Arduino Mega 2560 +static PIN_MAP_MEGA: BoardPinMap = BoardPinMap { + board: "mega", + total_digital: 54, + total_analog: 16, + pins: &[ + PinInfo { number: 0, aliases: &["RX0"], capabilities: &["digital", "uart"] }, + PinInfo { number: 1, aliases: &["TX0"], capabilities: &["digital", "uart"] }, + PinInfo { number: 2, aliases: &["INT0"], capabilities: &["digital", "pwm", "interrupt"] }, + PinInfo { number: 3, aliases: &["INT1"], capabilities: &["digital", "pwm", "interrupt"] }, + PinInfo { number: 4, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 5, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 6, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 7, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 8, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 9, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 10, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 11, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 12, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 13, aliases: &["LED_BUILTIN"], capabilities: &["digital", "pwm", "led"] }, + PinInfo { number: 14, aliases: &["TX3"], capabilities: &["digital", "uart"] }, + PinInfo { number: 15, aliases: &["RX3"], capabilities: &["digital", "uart"] }, + PinInfo { number: 16, aliases: &["TX2"], capabilities: &["digital", "uart"] }, + PinInfo { number: 17, aliases: &["RX2"], capabilities: &["digital", "uart"] }, + PinInfo { number: 18, aliases: &["TX1", "INT5"], capabilities: &["digital", "uart", "interrupt"] }, + PinInfo { number: 19, aliases: &["RX1", "INT4"], capabilities: &["digital", "uart", "interrupt"] }, + PinInfo { number: 20, aliases: &["SDA", "INT3"], capabilities: &["digital", "i2c", "interrupt"] }, + PinInfo { number: 21, aliases: &["SCL", "INT2"], capabilities: &["digital", "i2c", "interrupt"] }, + // 22-43: general digital I/O + PinInfo { number: 22, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 23, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 24, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 25, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 26, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 27, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 28, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 29, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 30, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 31, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 32, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 33, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 34, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 35, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 36, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 37, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 38, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 39, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 40, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 41, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 42, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 43, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 44, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 45, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 46, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 47, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 48, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 49, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 50, aliases: &["SPI_MISO"], capabilities: &["digital", "spi"] }, + PinInfo { number: 51, aliases: &["SPI_MOSI"], capabilities: &["digital", "spi"] }, + PinInfo { number: 52, aliases: &["SPI_SCK"], capabilities: &["digital", "spi"] }, + PinInfo { number: 53, aliases: &["SPI_SS"], capabilities: &["digital", "spi"] }, + // Analog pins + PinInfo { number: 54, aliases: &["A0"], capabilities: &["digital", "analog"] }, + PinInfo { number: 55, aliases: &["A1"], capabilities: &["digital", "analog"] }, + PinInfo { number: 56, aliases: &["A2"], capabilities: &["digital", "analog"] }, + PinInfo { number: 57, aliases: &["A3"], capabilities: &["digital", "analog"] }, + PinInfo { number: 58, aliases: &["A4"], capabilities: &["digital", "analog"] }, + PinInfo { number: 59, aliases: &["A5"], capabilities: &["digital", "analog"] }, + PinInfo { number: 60, aliases: &["A6"], capabilities: &["digital", "analog"] }, + PinInfo { number: 61, aliases: &["A7"], capabilities: &["digital", "analog"] }, + PinInfo { number: 62, aliases: &["A8"], capabilities: &["digital", "analog"] }, + PinInfo { number: 63, aliases: &["A9"], capabilities: &["digital", "analog"] }, + PinInfo { number: 64, aliases: &["A10"], capabilities: &["digital", "analog"] }, + PinInfo { number: 65, aliases: &["A11"], capabilities: &["digital", "analog"] }, + PinInfo { number: 66, aliases: &["A12"], capabilities: &["digital", "analog"] }, + PinInfo { number: 67, aliases: &["A13"], capabilities: &["digital", "analog"] }, + PinInfo { number: 68, aliases: &["A14"], capabilities: &["digital", "analog"] }, + PinInfo { number: 69, aliases: &["A15"], capabilities: &["digital", "analog"] }, + ], + groups: &[ + BusGroup { + name: "spi", + description: "SPI bus (hardware)", + fixed_pins: &[("sck", 52), ("mosi", 51), ("miso", 50)], + user_selectable: &["cs"], + }, + BusGroup { + name: "i2c", + description: "I2C / TWI bus", + fixed_pins: &[("sda", 20), ("scl", 21)], + user_selectable: &[], + }, + BusGroup { + name: "uart", + description: "Hardware serial (Serial)", + fixed_pins: &[("rx", 0), ("tx", 1)], + user_selectable: &[], + }, + BusGroup { + name: "uart1", + description: "Serial1", + fixed_pins: &[("rx", 19), ("tx", 18)], + user_selectable: &[], + }, + BusGroup { + name: "uart2", + description: "Serial2", + fixed_pins: &[("rx", 17), ("tx", 16)], + user_selectable: &[], + }, + BusGroup { + name: "uart3", + description: "Serial3", + fixed_pins: &[("rx", 15), ("tx", 14)], + user_selectable: &[], + }, + ], +}; + +// Arduino Nano (ATmega328P) -- same silicon as Uno, A6/A7 are analog-only +static PIN_MAP_NANO: BoardPinMap = BoardPinMap { + board: "nano", + total_digital: 14, + total_analog: 8, + pins: &[ + PinInfo { number: 0, aliases: &["RX"], capabilities: &["digital", "uart"] }, + PinInfo { number: 1, aliases: &["TX"], capabilities: &["digital", "uart"] }, + PinInfo { number: 2, aliases: &["INT0"], capabilities: &["digital", "interrupt"] }, + PinInfo { number: 3, aliases: &["INT1"], capabilities: &["digital", "pwm", "interrupt"] }, + PinInfo { number: 4, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 5, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 6, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 7, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 8, aliases: &[], capabilities: &["digital"] }, + PinInfo { number: 9, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 10, aliases: &["SPI_SS"], capabilities: &["digital", "pwm", "spi"] }, + PinInfo { number: 11, aliases: &["SPI_MOSI"], capabilities: &["digital", "pwm", "spi"] }, + PinInfo { number: 12, aliases: &["SPI_MISO"], capabilities: &["digital", "spi"] }, + PinInfo { number: 13, aliases: &["SPI_SCK", "LED_BUILTIN"], capabilities: &["digital", "spi", "led"] }, + PinInfo { number: 14, aliases: &["A0"], capabilities: &["digital", "analog"] }, + PinInfo { number: 15, aliases: &["A1"], capabilities: &["digital", "analog"] }, + PinInfo { number: 16, aliases: &["A2"], capabilities: &["digital", "analog"] }, + PinInfo { number: 17, aliases: &["A3"], capabilities: &["digital", "analog"] }, + PinInfo { number: 18, aliases: &["A4", "SDA"], capabilities: &["digital", "analog", "i2c"] }, + PinInfo { number: 19, aliases: &["A5", "SCL"], capabilities: &["digital", "analog", "i2c"] }, + // A6 and A7 are analog-only on Nano (no digital I/O) + PinInfo { number: 20, aliases: &["A6"], capabilities: &["analog"] }, + PinInfo { number: 21, aliases: &["A7"], capabilities: &["analog"] }, + ], + groups: &[ + BusGroup { + name: "spi", + description: "SPI bus (hardware)", + fixed_pins: &[("sck", 13), ("mosi", 11), ("miso", 12)], + user_selectable: &["cs"], + }, + BusGroup { + name: "i2c", + description: "I2C / TWI bus", + fixed_pins: &[("sda", 18), ("scl", 19)], + user_selectable: &[], + }, + BusGroup { + name: "uart", + description: "Hardware serial (Serial)", + fixed_pins: &[("rx", 0), ("tx", 1)], + user_selectable: &[], + }, + ], +}; + +// Shared pins/groups for ATmega32U4 boards (Leonardo, Micro) +static ATM32U4_PINS: &[PinInfo] = &[ + PinInfo { number: 0, aliases: &["RX", "INT2"], capabilities: &["digital", "uart", "interrupt"] }, + PinInfo { number: 1, aliases: &["TX", "INT3"], capabilities: &["digital", "uart", "interrupt"] }, + PinInfo { number: 2, aliases: &["SDA", "INT1"], capabilities: &["digital", "i2c", "interrupt"] }, + PinInfo { number: 3, aliases: &["SCL", "INT0"], capabilities: &["digital", "pwm", "i2c", "interrupt"] }, + PinInfo { number: 4, aliases: &["A6"], capabilities: &["digital", "analog"] }, + PinInfo { number: 5, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 6, aliases: &["A7"], capabilities: &["digital", "pwm", "analog"] }, + PinInfo { number: 7, aliases: &["INT6"], capabilities: &["digital", "interrupt"] }, + PinInfo { number: 8, aliases: &["A8"], capabilities: &["digital", "analog"] }, + PinInfo { number: 9, aliases: &["A9"], capabilities: &["digital", "pwm", "analog"] }, + PinInfo { number: 10, aliases: &["A10"], capabilities: &["digital", "pwm", "analog"] }, + PinInfo { number: 11, aliases: &[], capabilities: &["digital", "pwm"] }, + PinInfo { number: 12, aliases: &["A11"], capabilities: &["digital", "analog"] }, + PinInfo { number: 13, aliases: &["LED_BUILTIN"], capabilities: &["digital", "pwm", "led"] }, + // ICSP-routed SPI: MISO=14, SCK=15, MOSI=16, SS=17 + PinInfo { number: 14, aliases: &["SPI_MISO"], capabilities: &["digital", "spi"] }, + PinInfo { number: 15, aliases: &["SPI_SCK"], capabilities: &["digital", "spi"] }, + PinInfo { number: 16, aliases: &["SPI_MOSI"], capabilities: &["digital", "spi"] }, + PinInfo { number: 17, aliases: &["SPI_SS"], capabilities: &["digital", "spi"] }, + PinInfo { number: 18, aliases: &["A0"], capabilities: &["digital", "analog"] }, + PinInfo { number: 19, aliases: &["A1"], capabilities: &["digital", "analog"] }, + PinInfo { number: 20, aliases: &["A2"], capabilities: &["digital", "analog"] }, + PinInfo { number: 21, aliases: &["A3"], capabilities: &["digital", "analog"] }, + PinInfo { number: 22, aliases: &["A4"], capabilities: &["digital", "analog"] }, + PinInfo { number: 23, aliases: &["A5"], capabilities: &["digital", "analog"] }, +]; + +static ATM32U4_GROUPS: &[BusGroup] = &[ + BusGroup { + name: "spi", + description: "SPI bus (ICSP header)", + fixed_pins: &[("sck", 15), ("mosi", 16), ("miso", 14)], + user_selectable: &["cs"], + }, + BusGroup { + name: "i2c", + description: "I2C / TWI bus", + fixed_pins: &[("sda", 2), ("scl", 3)], + user_selectable: &[], + }, + BusGroup { + name: "uart", + description: "Hardware serial (Serial1)", + fixed_pins: &[("rx", 0), ("tx", 1)], + user_selectable: &[], + }, +]; + +// Arduino Leonardo (ATmega32U4) +static PIN_MAP_LEONARDO: BoardPinMap = BoardPinMap { + board: "leonardo", + total_digital: 20, + total_analog: 12, + pins: ATM32U4_PINS, + groups: ATM32U4_GROUPS, +}; + +// Arduino Micro (ATmega32U4) -- same silicon as Leonardo +static PIN_MAP_MICRO: BoardPinMap = BoardPinMap { + board: "micro", + total_digital: 20, + total_analog: 12, + pins: ATM32U4_PINS, + groups: ATM32U4_GROUPS, +}; + +// -- Lookup --------------------------------------------------------------- + +/// Find the pin map for a board by preset name. +pub fn find_pinmap(board: &str) -> Option<&'static BoardPinMap> { + let lower = board.to_lowercase(); + PINMAPS.iter().find(|m| m.board == lower).copied() +} + +/// Find the pin map for a board, also checking for nano-old -> nano alias. +pub fn find_pinmap_fuzzy(board: &str) -> Option<&'static BoardPinMap> { + find_pinmap(board).or_else(|| { + // nano-old uses the same pin map as nano + if board == "nano-old" { + find_pinmap("nano") + } else { + None + } + }) +} + +/// Check if a pin number is valid for a given board. +pub fn is_valid_pin(pinmap: &BoardPinMap, pin: u8) -> bool { + pinmap.pins.iter().any(|p| p.number == pin) +} + +/// Get info about a specific pin. +pub fn get_pin(pinmap: &BoardPinMap, pin: u8) -> Option<&PinInfo> { + pinmap.pins.iter().find(|p| p.number == pin) +} + +/// Resolve an alias like "A0", "SDA", "LED_BUILTIN" to a pin number. +pub fn resolve_alias(pinmap: &BoardPinMap, name: &str) -> Option { + let upper = name.to_uppercase(); + // Try as a number first + if let Ok(n) = name.parse::() { + if is_valid_pin(pinmap, n) { + return Some(n); + } + } + // Try aliases + for p in pinmap.pins { + for alias in p.aliases { + if alias.to_uppercase() == upper { + return Some(p.number); + } + } + } + None +} + +/// Get all pins that have a given capability. +pub fn pins_with_capability<'a>(pinmap: &'a BoardPinMap, capability: &str) -> Vec<&'a PinInfo> { + pinmap.pins.iter() + .filter(|p| p.capabilities.contains(&capability)) + .collect() +} + +/// Get all capabilities present on this board (deduplicated). +pub fn board_capabilities(pinmap: &BoardPinMap) -> Vec<&'static str> { + let mut caps: Vec<&str> = Vec::new(); + for p in pinmap.pins { + for &c in p.capabilities { + if !caps.contains(&c) { + caps.push(c); + } + } + } + caps.sort(); + caps +} + +/// Get all pins reserved by a bus group (fixed + any user-provided). +pub fn bus_fixed_pins(group: &BusGroup) -> Vec<(String, u8)> { + group.fixed_pins.iter() + .map(|&(role, pin)| (role.to_string(), pin)) + .collect() +} + +/// List all board names that have pin maps. +pub fn available_boards() -> Vec<&'static str> { + PINMAPS.iter().map(|m| m.board).collect() +} + +// -- Tests ---------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_pinmap_uno() { + let m = find_pinmap("uno").unwrap(); + assert_eq!(m.board, "uno"); + assert_eq!(m.total_digital, 14); + assert_eq!(m.total_analog, 6); + } + + #[test] + fn test_find_pinmap_mega() { + let m = find_pinmap("mega").unwrap(); + assert_eq!(m.board, "mega"); + assert_eq!(m.total_digital, 54); + assert_eq!(m.total_analog, 16); + } + + #[test] + fn test_find_pinmap_case_insensitive() { + // find_pinmap lowercases internally + assert!(find_pinmap("UNO").is_some()); + } + + #[test] + fn test_find_pinmap_unknown() { + assert!(find_pinmap("esp32").is_none()); + } + + #[test] + fn test_fuzzy_nano_old() { + assert!(find_pinmap_fuzzy("nano-old").is_some()); + } + + #[test] + fn test_resolve_alias_number() { + let m = find_pinmap("uno").unwrap(); + assert_eq!(resolve_alias(m, "13"), Some(13)); + } + + #[test] + fn test_resolve_alias_a0() { + let m = find_pinmap("uno").unwrap(); + assert_eq!(resolve_alias(m, "A0"), Some(14)); + assert_eq!(resolve_alias(m, "a0"), Some(14)); + } + + #[test] + fn test_resolve_alias_sda() { + let m = find_pinmap("uno").unwrap(); + assert_eq!(resolve_alias(m, "SDA"), Some(18)); + } + + #[test] + fn test_resolve_alias_led_builtin() { + let m = find_pinmap("uno").unwrap(); + assert_eq!(resolve_alias(m, "LED_BUILTIN"), Some(13)); + } + + #[test] + fn test_resolve_alias_invalid() { + let m = find_pinmap("uno").unwrap(); + assert_eq!(resolve_alias(m, "NOPE"), None); + assert_eq!(resolve_alias(m, "99"), None); + } + + #[test] + fn test_pins_with_capability_pwm() { + let m = find_pinmap("uno").unwrap(); + let pwm = pins_with_capability(m, "pwm"); + let nums: Vec = pwm.iter().map(|p| p.number).collect(); + assert_eq!(nums, vec![3, 5, 6, 9, 10, 11]); + } + + #[test] + fn test_pins_with_capability_analog() { + let m = find_pinmap("uno").unwrap(); + let analog = pins_with_capability(m, "analog"); + assert_eq!(analog.len(), 6); // A0-A5 + } + + #[test] + fn test_board_capabilities() { + let m = find_pinmap("uno").unwrap(); + let caps = board_capabilities(m); + assert!(caps.contains(&"digital")); + assert!(caps.contains(&"pwm")); + assert!(caps.contains(&"analog")); + assert!(caps.contains(&"spi")); + assert!(caps.contains(&"i2c")); + } + + #[test] + fn test_mega_spi_different_from_uno() { + let uno = find_pinmap("uno").unwrap(); + let mega = find_pinmap("mega").unwrap(); + let uno_spi = uno.groups.iter().find(|g| g.name == "spi").unwrap(); + let mega_spi = mega.groups.iter().find(|g| g.name == "spi").unwrap(); + // Uno SPI SCK = 13, Mega SPI SCK = 52 + let uno_sck = uno_spi.fixed_pins.iter().find(|p| p.0 == "sck").unwrap().1; + let mega_sck = mega_spi.fixed_pins.iter().find(|p| p.0 == "sck").unwrap().1; + assert_ne!(uno_sck, mega_sck); + } + + #[test] + fn test_mega_has_multiple_uarts() { + let m = find_pinmap("mega").unwrap(); + let uarts: Vec<_> = m.groups.iter() + .filter(|g| g.name.starts_with("uart")) + .collect(); + assert_eq!(uarts.len(), 4); // uart, uart1, uart2, uart3 + } + + #[test] + fn test_leonardo_i2c_on_2_3() { + let m = find_pinmap("leonardo").unwrap(); + let i2c = m.groups.iter().find(|g| g.name == "i2c").unwrap(); + let sda = i2c.fixed_pins.iter().find(|p| p.0 == "sda").unwrap().1; + let scl = i2c.fixed_pins.iter().find(|p| p.0 == "scl").unwrap().1; + assert_eq!(sda, 2); + assert_eq!(scl, 3); + } + + #[test] + fn test_micro_shares_leonardo_pins() { + let leo = find_pinmap("leonardo").unwrap(); + let mic = find_pinmap("micro").unwrap(); + assert_eq!(leo.pins.len(), mic.pins.len()); + } + + #[test] + fn test_nano_has_analog_only_pins() { + let m = find_pinmap("nano").unwrap(); + let a6 = get_pin(m, 20).unwrap(); + assert!(a6.aliases.contains(&"A6")); + assert!(a6.capabilities.contains(&"analog")); + assert!(!a6.capabilities.contains(&"digital")); + } + + #[test] + fn test_spi_has_user_selectable_cs() { + let m = find_pinmap("uno").unwrap(); + let spi = m.groups.iter().find(|g| g.name == "spi").unwrap(); + assert!(spi.user_selectable.contains(&"cs")); + } + + #[test] + fn test_i2c_has_no_user_selectable() { + let m = find_pinmap("uno").unwrap(); + let i2c = m.groups.iter().find(|g| g.name == "i2c").unwrap(); + assert!(i2c.user_selectable.is_empty()); + } + + #[test] + fn test_available_boards() { + let boards = available_boards(); + assert!(boards.contains(&"uno")); + assert!(boards.contains(&"mega")); + assert!(boards.contains(&"nano")); + assert!(boards.contains(&"leonardo")); + assert!(boards.contains(&"micro")); + } +} \ No newline at end of file diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 03f420b..6e6a686 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,4 +3,5 @@ pub mod doctor; pub mod setup; pub mod devices; pub mod refresh; -pub mod board; \ No newline at end of file +pub mod board; +pub mod pin; \ No newline at end of file diff --git a/src/commands/pin.rs b/src/commands/pin.rs new file mode 100644 index 0000000..84881c4 --- /dev/null +++ b/src/commands/pin.rs @@ -0,0 +1,1034 @@ +use anyhow::{Result, Context, bail}; +use colored::*; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use crate::board::pinmap::{ + self, BoardPinMap, ALL_CAPABILITIES, ALL_MODES, +}; +use crate::project::config::{ + ProjectConfig, BoardPinConfig, PinAssignment, BusConfig, CONFIG_FILENAME, +}; + +// ========================================================================= +// Show pin map +// ========================================================================= + +/// Display the pin map for a board, optionally filtered by capability. +pub fn show_pin_map( + board_name: Option<&str>, + filter: Option<&str>, + project_dir: Option<&str>, +) -> Result<()> { + let board = resolve_board_name(board_name, project_dir)?; + let pinmap = require_pinmap(&board)?; + + println!( + "{}", + format!("Pin map: {} ({} digital, {} analog)", + pinmap.board, pinmap.total_digital, pinmap.total_analog) + .bright_cyan().bold() + ); + println!(); + + let pins: Vec<_> = match filter { + Some(cap) => { + // Validate capability + if !pinmap::board_capabilities(pinmap).contains(&cap) { + let avail = pinmap::board_capabilities(pinmap); + bail!( + "Board '{}' has no '{}' capability.\n \ + Available: {}\n \ + List all: anvil pin --capabilities", + board, cap, avail.join(", ") + ); + } + pinmap::pins_with_capability(pinmap, cap) + } + None => pinmap.pins.iter().collect(), + }; + + if let Some(cap) = filter { + println!(" {} pins with '{}' capability:", pins.len(), cap); + println!(); + } + + // Print pin table + println!( + " {:<6} {:<24} {}", + "Pin".bold(), + "Aliases".bold(), + "Capabilities".bold() + ); + println!(" {}", "-".repeat(60)); + + for p in &pins { + let aliases = if p.aliases.is_empty() { + "-".bright_black().to_string() + } else { + p.aliases.join(", ") + }; + + let caps = p.capabilities.iter() + .map(|c| match *c { + "pwm" => "pwm".yellow().to_string(), + "analog" => "analog".green().to_string(), + "interrupt" => "interrupt".magenta().to_string(), + "spi" => "spi".cyan().to_string(), + "i2c" => "i2c".cyan().to_string(), + "uart" => "uart".blue().to_string(), + "led" => "led".bright_yellow().to_string(), + other => other.to_string(), + }) + .collect::>() + .join(", "); + + println!(" {:<6} {:<24} {}", p.number, aliases, caps); + } + + // Print bus groups + println!(); + println!(" {}", "Bus Groups:".bold()); + for g in pinmap.groups { + let pin_strs: Vec = g.fixed_pins.iter() + .map(|&(role, pin)| format!("{}={}", role, pin)) + .collect(); + let user_str = if g.user_selectable.is_empty() { + String::new() + } else { + format!(" (user selects: {})", g.user_selectable.join(", ")) + }; + println!( + " {:<8} {}{} -- {}", + g.name, pin_strs.join(", "), user_str, g.description + ); + } + + println!(); + Ok(()) +} + +// ========================================================================= +// Show capabilities +// ========================================================================= + +/// List all capabilities supported by a board. +pub fn show_capabilities( + board_name: Option<&str>, + project_dir: Option<&str>, +) -> Result<()> { + let board = resolve_board_name(board_name, project_dir)?; + let pinmap = require_pinmap(&board)?; + let board_caps = pinmap::board_capabilities(pinmap); + + println!( + "{}", + format!("Capabilities for: {}", pinmap.board).bright_cyan().bold() + ); + println!(); + + for cap_info in ALL_CAPABILITIES { + let supported = board_caps.contains(&cap_info.name); + let count = pinmap::pins_with_capability(pinmap, cap_info.name).len(); + if supported { + println!( + " {} {:<12} {:>3} pins {}", + "ok ".green(), + cap_info.name, + count, + cap_info.description.bright_black() + ); + } else { + println!( + " {} {:<12} {}", + " --".bright_black(), + cap_info.name.bright_black(), + cap_info.description.bright_black() + ); + } + } + + println!(); + println!( + " Filter pins: {}", + "anvil pin ".bright_black() + ); + println!(); + Ok(()) +} + +// ========================================================================= +// Assign a pin +// ========================================================================= + +/// Assign a friendly name to a pin. +pub fn assign_pin( + name: &str, + pin_str: &str, + mode: Option<&str>, + board_name: Option<&str>, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + let board = board_name.unwrap_or(&config.build.default).to_string(); + let pinmap = require_pinmap(&board)?; + + // Resolve the pin (number or alias) + let pin_num = pinmap::resolve_alias(pinmap, pin_str) + .ok_or_else(|| anyhow::anyhow!( + "Pin '{}' not found on board '{}'.\n \ + Use a number (0-{}) or alias (A0, SDA, etc.)\n \ + See all pins: anvil pin --board {}", + pin_str, board, + pinmap.pins.last().map(|p| p.number).unwrap_or(0), + board + ))?; + + // Validate mode + let pin_mode = mode.unwrap_or("output"); + if !ALL_MODES.contains(&pin_mode) { + bail!( + "Unknown pin mode: '{}'\n \ + Valid modes: {}", + pin_mode, ALL_MODES.join(", ") + ); + } + + // Validate the pin supports the requested mode + let pin_info = pinmap::get_pin(pinmap, pin_num).unwrap(); + validate_mode_for_pin(pin_info, pin_mode)?; + + // Validate name is a valid identifier + validate_pin_name(name)?; + + // Write the assignment + let assignment = PinAssignment { + pin: pin_num, + mode: pin_mode.to_string(), + }; + write_pin_assignment(&project_path, &board, name, &assignment)?; + + let alias_note = if pin_str.parse::().is_err() { + format!(" ({})", pin_str) + } else { + let aliases: Vec<&str> = pin_info.aliases.iter() + .filter(|a| a.to_uppercase() != pin_str.to_uppercase()) + .copied() + .collect(); + if aliases.is_empty() { + String::new() + } else { + format!(" ({})", aliases.join(", ")) + } + }; + + println!( + "{} {} -> Pin {}{} [{}]", + "ok ".green(), + name.bold(), + pin_num, + alias_note.bright_black(), + pin_mode + ); + + Ok(()) +} + +// ========================================================================= +// Assign a bus group +// ========================================================================= + +/// Claim a hardware bus group (SPI, I2C, UART, etc.) +pub fn assign_bus( + bus_name: &str, + user_pins: &[(&str, &str)], + board_name: Option<&str>, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + let board = board_name.unwrap_or(&config.build.default).to_string(); + let pinmap = require_pinmap(&board)?; + + // Find the bus group + let group = pinmap.groups.iter() + .find(|g| g.name == bus_name.to_lowercase()) + .ok_or_else(|| { + let avail: Vec<_> = pinmap.groups.iter().map(|g| g.name).collect(); + anyhow::anyhow!( + "No bus '{}' on board '{}'.\n Available: {}", + bus_name, board, avail.join(", ") + ) + })?; + + // Validate user-provided pins for selectable roles + let mut bus_config = BusConfig { user_pins: HashMap::new() }; + + for &(role, pin_str) in user_pins { + if !group.user_selectable.contains(&role) { + bail!( + "Bus '{}' does not have a selectable '{}' pin.\n \ + Selectable roles: {}", + bus_name, role, + if group.user_selectable.is_empty() { + "(none)".to_string() + } else { + group.user_selectable.join(", ") + } + ); + } + let pin_num = pinmap::resolve_alias(pinmap, pin_str) + .ok_or_else(|| anyhow::anyhow!( + "Pin '{}' not found on board '{}'.", pin_str, board + ))?; + bus_config.user_pins.insert(role.to_string(), pin_num); + } + + // Check all required user-selectable pins are provided + for &req in group.user_selectable { + if !bus_config.user_pins.contains_key(req) { + bail!( + "Bus '{}' requires a '{}' pin.\n \ + Usage: anvil pin --assign {} --{} ", + bus_name, req, bus_name, req + ); + } + } + + // Write the bus config + write_bus_assignment(&project_path, &board, bus_name, &bus_config)?; + + // Display summary + println!( + "{} Bus '{}' reserved on board '{}':", + "ok ".green(), bus_name.bold(), board + ); + for &(role, pin) in group.fixed_pins { + println!(" {:<8} -> Pin {}", role, pin); + } + for (role, pin) in &bus_config.user_pins { + println!(" {:<8} -> Pin {} (user-selected)", role, pin); + } + + Ok(()) +} + +// ========================================================================= +// Remove a pin/bus assignment +// ========================================================================= + +pub fn remove_assignment( + name: &str, + board_name: Option<&str>, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let mut config = ProjectConfig::load(&project_path)?; + let board = board_name.unwrap_or(&config.build.default).to_string(); + + let pin_config = config.pins.get_mut(&board) + .ok_or_else(|| anyhow::anyhow!( + "No pin assignments for board '{}'.", board + ))?; + + if pin_config.assignments.remove(name).is_some() { + save_pins(&project_path, &config)?; + println!("{} Removed pin assignment: {}", "ok ".green(), name); + } else if pin_config.buses.remove(name).is_some() { + save_pins(&project_path, &config)?; + println!("{} Removed bus assignment: {}", "ok ".green(), name); + } else { + let mut available: Vec<&str> = pin_config.assignments.keys() + .map(|s| s.as_str()) + .collect(); + available.extend(pin_config.buses.keys().map(|s| s.as_str())); + available.sort(); + bail!( + "No assignment '{}' found for board '{}'.\n \ + Current assignments: {}", + name, board, + if available.is_empty() { "(none)".to_string() } + else { available.join(", ") } + ); + } + + Ok(()) +} + +// ========================================================================= +// Audit pin assignments +// ========================================================================= + +pub fn audit_pins( + board_name: Option<&str>, + brief: bool, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + let board = board_name.unwrap_or(&config.build.default).to_string(); + + // Board profile is required + let profile = config.boards.get(&board) + .ok_or_else(|| anyhow::anyhow!( + "No board '{}' in .anvil.toml.\n \ + Add it: anvil board --add {}", board, board + ))?; + + let pinmap_opt = pinmap::find_pinmap_fuzzy(&board); + + let pin_config = config.pins.get(&board); + let empty_config = BoardPinConfig::default(); + let pc = pin_config.unwrap_or(&empty_config); + + println!( + "{}", + format!("Pin Audit: {} ({})", board, profile.fqbn) + .bright_cyan().bold() + ); + println!(); + + 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!(); + return Ok(()); + } + + // Build a map: pin_number -> Vec<(name, role)> for conflict detection + let mut pin_usage: HashMap> = HashMap::new(); + + // Individual assignments + if !pc.assignments.is_empty() { + println!(" {}", "Pin Assignments:".bold()); + let mut names: Vec<_> = pc.assignments.keys().collect(); + names.sort(); + for name in &names { + let a = &pc.assignments[*name]; + let pin_aliases = pinmap_opt + .and_then(|m| pinmap::get_pin(m, a.pin)) + .map(|p| { + if p.aliases.is_empty() { String::new() } + else { format!(" ({})", p.aliases.join(", ")) } + }) + .unwrap_or_default(); + + println!( + " {:<20} -> Pin {:<4}{} [{}]", + name, a.pin, pin_aliases.bright_black(), a.mode + ); + pin_usage.entry(a.pin).or_default().push(name.to_string()); + } + println!(); + } + + // Bus groups + if !pc.buses.is_empty() { + println!(" {}", "Bus Groups:".bold()); + let mut bus_names: Vec<_> = pc.buses.keys().collect(); + bus_names.sort(); + for bus_name in &bus_names { + let bc = &pc.buses[*bus_name]; + + // Get fixed pins from pinmap + if let Some(pm) = pinmap_opt { + if let Some(group) = pm.groups.iter().find(|g| g.name == bus_name.as_str()) { + println!(" {} ({}):", bus_name.bold(), group.description); + for &(role, pin) in group.fixed_pins { + println!(" {:<8} -> Pin {}", role, pin); + pin_usage.entry(pin).or_default() + .push(format!("{}.{}", bus_name, role)); + } + for (role, &pin) in &bc.user_pins { + println!(" {:<8} -> Pin {} (user-selected)", role, pin); + pin_usage.entry(pin).or_default() + .push(format!("{}.{}", bus_name, role)); + } + } else { + println!(" {} (unknown bus):", bus_name.bold()); + for (role, &pin) in &bc.user_pins { + println!(" {:<8} -> Pin {}", role, pin); + pin_usage.entry(pin).or_default() + .push(format!("{}.{}", bus_name, role)); + } + } + } + } + println!(); + } + + // Conflict detection + let conflicts: Vec<_> = pin_usage.iter() + .filter(|(_, users)| users.len() > 1) + .collect(); + + if !conflicts.is_empty() { + println!( + " {}", + "CONFLICTS:".red().bold() + ); + let mut sorted: Vec<_> = conflicts.iter().collect(); + sorted.sort_by_key(|(pin, _)| *pin); + for (pin, users) in sorted { + println!( + " {} Pin {}: {}", + "!!".red().bold(), + pin, + users.join(", ") + ); + } + println!(); + } else { + println!(" {} No conflicts detected.", "ok ".green()); + println!(); + } + + // Free pins + if !brief { + if let Some(pm) = pinmap_opt { + let used: Vec = pin_usage.keys().copied().collect(); + let free: Vec = pm.pins.iter() + .map(|p| p.number) + .filter(|n| !used.contains(n)) + .collect(); + if !free.is_empty() { + println!( + " Free pins: {}", + format_pin_ranges(&free).bright_black() + ); + println!(); + } + } + } + + // Wiring checklist + println!(" {}", "Wiring Checklist:".bold()); + let mut all_wiring: Vec<(u8, String, String)> = Vec::new(); + + for (name, a) in &pc.assignments { + all_wiring.push((a.pin, name.clone(), a.mode.to_uppercase())); + } + for (bus_name, bc) in &pc.buses { + if let Some(pm) = pinmap_opt { + if let Some(group) = pm.groups.iter().find(|g| g.name == bus_name.as_str()) { + for &(role, pin) in group.fixed_pins { + all_wiring.push(( + pin, + format!("{} {}", bus_name.to_uppercase(), role.to_uppercase()), + String::new(), + )); + } + for (role, &pin) in &bc.user_pins { + all_wiring.push(( + pin, + format!("{} {}", bus_name.to_uppercase(), role.to_uppercase()), + String::new(), + )); + } + } + } + } + + all_wiring.sort_by_key(|(pin, _, _)| *pin); + for (pin, label, mode) in &all_wiring { + let mode_str = if mode.is_empty() { + String::new() + } else { + format!(" ({})", mode) + }; + println!(" [ ] Pin {:<4} -> {}{}", pin, label, mode_str); + } + println!(); + + Ok(()) +} + +// ========================================================================= +// Init pins from another board +// ========================================================================= + +pub fn init_from( + source_board: &str, + target_board: &str, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let mut config = ProjectConfig::load(&project_path)?; + + // Source must have pin assignments + let source_config = config.pins.get(source_board) + .ok_or_else(|| anyhow::anyhow!( + "No pin assignments for source board '{}'.", source_board + ))? + .clone(); + + // Target must exist as a board + if !config.boards.contains_key(target_board) { + bail!( + "Board '{}' not in .anvil.toml.\n \ + Add it: anvil board --add {}", target_board, target_board + ); + } + + let assign_count = source_config.assignments.len(); + let bus_count = source_config.buses.len(); + + config.pins.insert(target_board.to_string(), source_config); + save_pins(&project_path, &config)?; + + println!( + "{} Copied {} pin assignments and {} bus groups from '{}' to '{}'.", + "ok ".green(), + assign_count, + bus_count, + source_board, + target_board, + ); + println!(); + println!( + " {} Pin numbers may differ between boards!", + "warn".yellow() + ); + println!( + " Review: {}", + format!("anvil pin --audit --board {}", target_board).bright_black() + ); + println!(); + + Ok(()) +} + +// ========================================================================= +// Generate pins.h +// ========================================================================= + +pub fn generate_pins_header( + board_name: Option<&str>, + project_dir: Option<&str>, +) -> Result<()> { + let project_path = resolve_project_dir(project_dir)?; + let config = ProjectConfig::load(&project_path)?; + let board = board_name.unwrap_or(&config.build.default).to_string(); + + let profile = config.boards.get(&board) + .ok_or_else(|| anyhow::anyhow!( + "No board '{}' in .anvil.toml.", board + ))?; + + let pin_config = config.pins.get(&board); + let empty = BoardPinConfig::default(); + let pc = pin_config.unwrap_or(&empty); + + if pc.assignments.is_empty() && pc.buses.is_empty() { + bail!( + "No pin assignments for board '{}'.\n \ + Assign pins first: anvil pin --assign ", + board + ); + } + + let pinmap_opt = pinmap::find_pinmap_fuzzy(&board); + + // Generate the header content + let mut lines = Vec::new(); + lines.push("// Auto-generated by Anvil -- edit .anvil.toml, then: anvil pin --generate".to_string()); + lines.push(format!("// Board: {} ({})", board, profile.fqbn)); + lines.push("#pragma once".to_string()); + lines.push(String::new()); + lines.push("#include ".to_string()); + lines.push(String::new()); + lines.push("namespace Pins {".to_string()); + + // User assignments + if !pc.assignments.is_empty() { + lines.push(" // User assignments".to_string()); + let mut names: Vec<_> = pc.assignments.keys().collect(); + names.sort(); + for name in &names { + let a = &pc.assignments[*name]; + let alias_comment = pinmap_opt + .and_then(|m| pinmap::get_pin(m, a.pin)) + .map(|p| { + if p.aliases.is_empty() { String::new() } + else { format!(" // {}", p.aliases.join(", ")) } + }) + .unwrap_or_default(); + + lines.push(format!( + " constexpr uint8_t {} = {};{}", + name.to_uppercase(), a.pin, alias_comment + )); + } + } + + // Bus groups + if !pc.buses.is_empty() { + let mut bus_names: Vec<_> = pc.buses.keys().collect(); + bus_names.sort(); + + for bus_name in &bus_names { + let bc = &pc.buses[*bus_name]; + lines.push(String::new()); + + if let Some(pm) = pinmap_opt { + if let Some(group) = pm.groups.iter().find(|g| g.name == bus_name.as_str()) { + lines.push(format!(" // {} ({})", group.description, bus_name)); + let prefix = bus_name.to_uppercase(); + for &(role, pin) in group.fixed_pins { + lines.push(format!( + " constexpr uint8_t {}_{} = {};", + prefix, role.to_uppercase(), pin + )); + } + for (role, &pin) in &bc.user_pins { + lines.push(format!( + " constexpr uint8_t {}_{} = {};", + prefix, role.to_uppercase(), pin + )); + } + } + } + } + } + + lines.push("}".to_string()); + lines.push(String::new()); + + let content = lines.join("\n"); + + // Write to lib/hal/pins.h + let pins_dir = project_path.join("lib").join("hal"); + if !pins_dir.exists() { + fs::create_dir_all(&pins_dir) + .context("Failed to create lib/hal directory")?; + } + let pins_path = pins_dir.join("pins.h"); + fs::write(&pins_path, &content) + .context("Failed to write pins.h")?; + + println!( + "{} Generated: lib/hal/pins.h ({} board)", + "ok ".green(), board + ); + println!( + " {} pin assignments, {} bus groups", + pc.assignments.len(), pc.buses.len() + ); + println!(); + + Ok(()) +} + +// ========================================================================= +// Internal helpers +// ========================================================================= + +fn resolve_project_dir(dir: Option<&str>) -> Result { + match dir { + Some(d) => Ok(PathBuf::from(d)), + None => std::env::current_dir() + .context("Could not determine current directory"), + } +} + +fn resolve_board_name(board_name: Option<&str>, project_dir: Option<&str>) -> Result { + if let Some(name) = board_name { + return Ok(name.to_string()); + } + // Try to load from project config + let project_path = resolve_project_dir(project_dir)?; + if let Ok(config) = ProjectConfig::load(&project_path) { + return Ok(config.build.default.clone()); + } + // No project, default to uno for reference purposes + Ok("uno".to_string()) +} + +fn require_pinmap(board: &str) -> Result<&'static BoardPinMap> { + pinmap::find_pinmap_fuzzy(board).ok_or_else(|| { + let avail = pinmap::available_boards(); + anyhow::anyhow!( + "No pin map available for board '{}'.\n \ + Boards with pin maps: {}\n \ + (Custom boards can still use --assign with pin numbers.)", + board, avail.join(", ") + ) + }) +} + +fn validate_pin_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Pin name cannot be empty."); + } + if !name.chars().next().unwrap().is_ascii_alphabetic() && name.chars().next().unwrap() != '_' { + bail!( + "Pin name '{}' must start with a letter or underscore.", + name + ); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + bail!( + "Pin name '{}' can only contain letters, numbers, and underscores.", + name + ); + } + // Reserved names + let reserved = [ + "pin", "mode", "bus", "spi", "i2c", "uart", "input", "output", + ]; + if reserved.contains(&name.to_lowercase().as_str()) { + bail!( + "Pin name '{}' is reserved. Choose a different name.", + name + ); + } + Ok(()) +} + +fn validate_mode_for_pin(pin_info: &pinmap::PinInfo, mode: &str) -> Result<()> { + match mode { + "pwm" => { + if !pin_info.capabilities.contains(&"pwm") { + bail!( + "Pin {} does not support PWM.\n \ + PWM pins: anvil pin pwm", + pin_info.number + ); + } + } + "analog" => { + if !pin_info.capabilities.contains(&"analog") { + bail!( + "Pin {} does not support analog input.\n \ + Analog pins: anvil pin analog", + pin_info.number + ); + } + } + "input" | "output" | "input_pullup" => { + if !pin_info.capabilities.contains(&"digital") { + bail!( + "Pin {} does not support digital I/O.", + pin_info.number + ); + } + } + _ => {} + } + Ok(()) +} + +/// Write a single pin assignment to .anvil.toml. +/// Uses full config reload + rewrite of [pins.*] sections. +fn write_pin_assignment( + project_path: &PathBuf, + board: &str, + name: &str, + assignment: &PinAssignment, +) -> Result<()> { + let mut config = ProjectConfig::load(project_path)?; + let pin_config = config.pins + .entry(board.to_string()) + .or_insert_with(BoardPinConfig::default); + pin_config.assignments.insert(name.to_string(), assignment.clone()); + save_pins(project_path, &config) +} + +/// Write a bus group assignment to .anvil.toml. +fn write_bus_assignment( + project_path: &PathBuf, + board: &str, + bus_name: &str, + bus_config: &BusConfig, +) -> Result<()> { + let mut config = ProjectConfig::load(project_path)?; + let pin_config = config.pins + .entry(board.to_string()) + .or_insert_with(BoardPinConfig::default); + pin_config.buses.insert(bus_name.to_string(), bus_config.clone()); + save_pins(project_path, &config) +} + +/// Rewrite the [pins.*] sections in .anvil.toml. +/// Strips all existing [pins.*] sections and re-appends them. +fn save_pins(project_path: &PathBuf, config: &ProjectConfig) -> Result<()> { + let config_path = project_path.join(CONFIG_FILENAME); + let content = fs::read_to_string(&config_path) + .context("Failed to read .anvil.toml")?; + + // Strip existing [pins.*] sections + let mut lines: Vec<&str> = Vec::new(); + let mut in_pins_section = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("[pins.") || trimmed == "[pins]" { + in_pins_section = true; + continue; + } + if in_pins_section && trimmed.starts_with('[') { + in_pins_section = false; + } + if !in_pins_section { + lines.push(line); + } + } + + // Remove trailing blank lines + while lines.last().map_or(false, |l| l.trim().is_empty()) { + lines.pop(); + } + + let mut output = lines.join("\n"); + output.push('\n'); + + // Append pin sections + if !config.pins.is_empty() { + let mut board_names: Vec<_> = config.pins.keys().collect(); + board_names.sort(); + + for board_name in board_names { + let pc = &config.pins[board_name]; + if pc.assignments.is_empty() && pc.buses.is_empty() { + continue; + } + + // Assignments + if !pc.assignments.is_empty() { + output.push('\n'); + output.push_str(&format!("[pins.{}.assignments]\n", board_name)); + let mut names: Vec<_> = pc.assignments.keys().collect(); + names.sort(); + for name in names { + let a = &pc.assignments[name]; + output.push_str(&format!( + "{} = {{ pin = {}, mode = \"{}\" }}\n", + name, a.pin, a.mode + )); + } + } + + // Buses + if !pc.buses.is_empty() { + let mut bus_names: Vec<_> = pc.buses.keys().collect(); + bus_names.sort(); + for bus_name in bus_names { + let bc = &pc.buses[bus_name]; + output.push('\n'); + output.push_str(&format!( + "[pins.{}.buses.{}]\n", board_name, bus_name + )); + if !bc.user_pins.is_empty() { + let mut pin_names: Vec<_> = bc.user_pins.keys().collect(); + pin_names.sort(); + for pk in pin_names { + output.push_str(&format!( + "{} = {}\n", pk, bc.user_pins[pk] + )); + } + } + } + } + } + } + + fs::write(&config_path, output) + .context("Failed to write .anvil.toml")?; + + Ok(()) +} + +/// Format a list of pin numbers as ranges: "0-6, 8, 14-19" +fn format_pin_ranges(pins: &[u8]) -> String { + if pins.is_empty() { + return "(none)".to_string(); + } + + let mut sorted = pins.to_vec(); + sorted.sort(); + sorted.dedup(); + + let mut ranges: Vec = Vec::new(); + let mut start = sorted[0]; + let mut end = sorted[0]; + + for &pin in &sorted[1..] { + if pin == end + 1 { + end = pin; + } else { + if start == end { + ranges.push(format!("{}", start)); + } else { + ranges.push(format!("{}-{}", start, end)); + } + start = pin; + end = pin; + } + } + // Last range + if start == end { + ranges.push(format!("{}", start)); + } else { + ranges.push(format!("{}-{}", start, end)); + } + + ranges.join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_pin_ranges_contiguous() { + assert_eq!(format_pin_ranges(&[0, 1, 2, 3]), "0-3"); + } + + #[test] + fn test_format_pin_ranges_gaps() { + assert_eq!(format_pin_ranges(&[0, 1, 3, 5, 6, 7]), "0-1, 3, 5-7"); + } + + #[test] + fn test_format_pin_ranges_single() { + assert_eq!(format_pin_ranges(&[13]), "13"); + } + + #[test] + fn test_format_pin_ranges_empty() { + assert_eq!(format_pin_ranges(&[]), "(none)"); + } + + #[test] + fn test_validate_pin_name_ok() { + assert!(validate_pin_name("red_led").is_ok()); + assert!(validate_pin_name("motor_pwm").is_ok()); + assert!(validate_pin_name("_private").is_ok()); + assert!(validate_pin_name("sensor1").is_ok()); + } + + #[test] + fn test_validate_pin_name_bad() { + assert!(validate_pin_name("").is_err()); + assert!(validate_pin_name("1abc").is_err()); + assert!(validate_pin_name("red-led").is_err()); + assert!(validate_pin_name("red led").is_err()); + } + + #[test] + fn test_validate_pin_name_reserved() { + assert!(validate_pin_name("spi").is_err()); + assert!(validate_pin_name("i2c").is_err()); + assert!(validate_pin_name("input").is_err()); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 61a7a69..73586bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,6 +119,60 @@ enum Commands { #[arg(long, short = 'd', value_name = "DIR")] dir: Option, }, + + /// View pin maps, assign pins, and audit wiring + Pin { + /// Capability filter (pwm, analog, spi, i2c, uart, interrupt) + /// or pin/bus name for --assign / --remove + name: Option, + + /// Pin number or alias for --assign (e.g. 13, A0, SDA) + pin: Option, + + /// Assign a pin or bus group + #[arg(long, conflicts_with_all = ["remove", "audit", "generate", "capabilities", "init_from"])] + assign: bool, + + /// Remove a pin or bus assignment + #[arg(long, conflicts_with_all = ["assign", "audit", "generate", "capabilities", "init_from"])] + remove: bool, + + /// Show wiring audit report + #[arg(long, conflicts_with_all = ["assign", "remove", "generate", "capabilities", "init_from"])] + audit: bool, + + /// Show only the wiring checklist (with --audit) + #[arg(long, requires = "audit")] + brief: bool, + + /// Generate lib/hal/pins.h + #[arg(long, conflicts_with_all = ["assign", "remove", "audit", "capabilities", "init_from"])] + generate: bool, + + /// List capabilities supported by the board + #[arg(long, conflicts_with_all = ["assign", "remove", "audit", "generate", "init_from"])] + capabilities: bool, + + /// Copy pin assignments from another board + #[arg(long, value_name = "BOARD", conflicts_with_all = ["assign", "remove", "audit", "generate", "capabilities"])] + init_from: Option, + + /// Pin mode (input, output, input_pullup, pwm, analog) + #[arg(long, value_name = "MODE")] + mode: Option, + + /// SPI chip-select pin (with --assign spi) + #[arg(long, value_name = "PIN")] + cs: Option, + + /// Target board (defaults to project default) + #[arg(long, short = 'b', value_name = "BOARD")] + board: Option, + + /// Path to project directory (defaults to current directory) + #[arg(long, short = 'd', value_name = "DIR")] + dir: Option, + }, } fn main() -> Result<()> { @@ -225,6 +279,108 @@ fn main() -> Result<()> { commands::board::list_boards(dir.as_deref()) } } + Commands::Pin { + name, pin, assign, remove, audit, brief, + generate, capabilities, init_from, mode, cs, board, dir, + } => { + if capabilities { + commands::pin::show_capabilities( + board.as_deref(), + dir.as_deref(), + ) + } else if audit { + commands::pin::audit_pins( + board.as_deref(), + brief, + dir.as_deref(), + ) + } else if generate { + commands::pin::generate_pins_header( + board.as_deref(), + dir.as_deref(), + ) + } else if remove { + let pin_name = name.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Name required.\n\ + Usage: anvil pin --remove red_led\n\ + Audit current: anvil pin --audit" + ) + })?; + commands::pin::remove_assignment( + pin_name, + board.as_deref(), + dir.as_deref(), + ) + } else if let Some(ref source) = init_from { + let target = board.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Target board required.\n\ + Usage: anvil pin --init-from uno --board mega" + ) + })?; + commands::pin::init_from( + source, + target, + dir.as_deref(), + ) + } else if assign { + let assign_name = name.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Name required.\n\ + Usage: anvil pin --assign red_led 13\n\ + Usage: anvil pin --assign spi --cs 10\n\ + See pins: anvil pin" + ) + })?; + + // Check if this is a bus group assignment + let is_bus = { + let board_name = board.as_deref().unwrap_or("uno"); + anvil::board::pinmap::find_pinmap_fuzzy(board_name) + .map(|pm| pm.groups.iter().any(|g| { + g.name == assign_name.to_lowercase() + })) + .unwrap_or(false) + }; + + if is_bus { + let mut user_pins: Vec<(&str, &str)> = Vec::new(); + if let Some(ref cs_pin) = cs { + user_pins.push(("cs", cs_pin.as_str())); + } + commands::pin::assign_bus( + assign_name, + &user_pins, + board.as_deref(), + dir.as_deref(), + ) + } else { + let pin_str = pin.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Pin number required.\n\ + Usage: anvil pin --assign {} 13\n\ + See pins: anvil pin", + assign_name + ) + })?; + commands::pin::assign_pin( + assign_name, + pin_str, + mode.as_deref(), + board.as_deref(), + dir.as_deref(), + ) + } + } else { + // Default: show pin map, optionally filtered + commands::pin::show_pin_map( + board.as_deref(), + name.as_deref(), + dir.as_deref(), + ) + } + } } } diff --git a/src/project/config.rs b/src/project/config.rs index b9e2dd5..8d4b775 100644 --- a/src/project/config.rs +++ b/src/project/config.rs @@ -15,6 +15,8 @@ pub struct ProjectConfig { pub monitor: MonitorConfig, #[serde(default)] pub boards: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub pins: HashMap, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -51,6 +53,32 @@ pub struct BoardProfile { pub baud: Option, } +/// Pin configuration for a specific board. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct BoardPinConfig { + /// Individual pin assignments: name -> { pin, mode } + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub assignments: HashMap, + /// Bus group reservations: "spi" -> { cs = 10 }, "i2c" -> {} + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub buses: HashMap, +} + +/// A single pin assignment with a friendly name. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PinAssignment { + pub pin: u8, + pub mode: String, +} + +/// Configuration for a claimed bus group. +/// User-selectable pins (like CS for SPI) are stored as flattened keys. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct BusConfig { + #[serde(flatten)] + pub user_pins: HashMap, +} + impl ProjectConfig { /// Create a new project config with sensible defaults. pub fn new(name: &str) -> Self { @@ -76,6 +104,7 @@ impl ProjectConfig { port: None, }, boards, + pins: HashMap::new(), } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index a5ad3d7..2286e37 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1970,4 +1970,275 @@ fn test_monitor_sh_timestamps_work_in_watch_mode() { "monitor_filter should be defined and used in both watch and normal mode (found {} refs)", filter_count ); +} + +// ========================================================================== +// Pin map data +// ========================================================================== + +#[test] +fn test_pinmap_exists_for_all_presets() { + use anvil::board::presets; + use anvil::board::pinmap; + + // Every board preset (except nano-old) should have a pin map + for preset in presets::PRESETS { + let found = pinmap::find_pinmap_fuzzy(preset.name); + assert!( + found.is_some(), + "Board preset '{}' has no pin map. Add one to pinmap.rs.", + preset.name + ); + } +} + +#[test] +fn test_pinmap_uno_basics() { + use anvil::board::pinmap; + + let m = pinmap::find_pinmap("uno").unwrap(); + assert_eq!(m.total_digital, 14); + assert_eq!(m.total_analog, 6); + // Uno has 20 pins total (D0-D13 + A0-A5 mapped as D14-D19) + assert_eq!(m.pins.len(), 20); +} + +#[test] +fn test_pinmap_mega_basics() { + use anvil::board::pinmap; + + let m = pinmap::find_pinmap("mega").unwrap(); + assert_eq!(m.total_digital, 54); + assert_eq!(m.total_analog, 16); + // Mega has 70 pins: D0-D53 + A0-A15 + assert_eq!(m.pins.len(), 70); +} + +#[test] +fn test_pinmap_resolve_alias_a0() { + use anvil::board::pinmap; + + let uno = pinmap::find_pinmap("uno").unwrap(); + assert_eq!(pinmap::resolve_alias(uno, "A0"), Some(14)); + assert_eq!(pinmap::resolve_alias(uno, "a0"), Some(14)); + + let mega = pinmap::find_pinmap("mega").unwrap(); + assert_eq!(pinmap::resolve_alias(mega, "A0"), Some(54)); +} + +#[test] +fn test_pinmap_spi_pins_differ_by_board() { + use anvil::board::pinmap; + + let uno = pinmap::find_pinmap("uno").unwrap(); + let mega = pinmap::find_pinmap("mega").unwrap(); + + let uno_spi = uno.groups.iter().find(|g| g.name == "spi").unwrap(); + let mega_spi = mega.groups.iter().find(|g| g.name == "spi").unwrap(); + + let uno_sck = uno_spi.fixed_pins.iter().find(|p| p.0 == "sck").unwrap().1; + let mega_sck = mega_spi.fixed_pins.iter().find(|p| p.0 == "sck").unwrap().1; + + // Uno SCK=13, Mega SCK=52 + assert_ne!(uno_sck, mega_sck, "SPI SCK should differ between uno and mega"); +} + +#[test] +fn test_pinmap_capabilities_filter() { + use anvil::board::pinmap; + + let m = pinmap::find_pinmap("uno").unwrap(); + let pwm = pinmap::pins_with_capability(m, "pwm"); + // Uno has PWM on 3, 5, 6, 9, 10, 11 + assert_eq!(pwm.len(), 6); + + let analog = pinmap::pins_with_capability(m, "analog"); + assert_eq!(analog.len(), 6); // A0-A5 +} + +#[test] +fn test_pinmap_board_capabilities_list() { + use anvil::board::pinmap; + + let m = pinmap::find_pinmap("uno").unwrap(); + let caps = pinmap::board_capabilities(m); + assert!(caps.contains(&"digital")); + assert!(caps.contains(&"analog")); + assert!(caps.contains(&"pwm")); + assert!(caps.contains(&"spi")); + assert!(caps.contains(&"i2c")); + assert!(caps.contains(&"uart")); + assert!(caps.contains(&"interrupt")); + assert!(caps.contains(&"led")); +} + +#[test] +fn test_pinmap_mega_has_four_uarts() { + use anvil::board::pinmap; + + let m = pinmap::find_pinmap("mega").unwrap(); + let uart_count = m.groups.iter() + .filter(|g| g.name.starts_with("uart")) + .count(); + assert_eq!(uart_count, 4, "Mega should have uart, uart1, uart2, uart3"); +} + +#[test] +fn test_pinmap_leonardo_i2c_different_from_uno() { + use anvil::board::pinmap; + + let uno = pinmap::find_pinmap("uno").unwrap(); + let leo = pinmap::find_pinmap("leonardo").unwrap(); + + let uno_i2c = uno.groups.iter().find(|g| g.name == "i2c").unwrap(); + let leo_i2c = leo.groups.iter().find(|g| g.name == "i2c").unwrap(); + + let uno_sda = uno_i2c.fixed_pins.iter().find(|p| p.0 == "sda").unwrap().1; + let leo_sda = leo_i2c.fixed_pins.iter().find(|p| p.0 == "sda").unwrap().1; + + // Uno SDA=18 (A4), Leonardo SDA=2 + assert_ne!(uno_sda, leo_sda, "I2C SDA should differ between uno and leonardo"); +} + +#[test] +fn test_pinmap_spi_has_user_selectable_cs() { + use anvil::board::pinmap; + + // All boards with SPI should require user to select CS + for board in pinmap::available_boards() { + let m = pinmap::find_pinmap(board).unwrap(); + if let Some(spi) = m.groups.iter().find(|g| g.name == "spi") { + assert!( + spi.user_selectable.contains(&"cs"), + "Board '{}' SPI should have user-selectable CS pin", + board + ); + } + } +} + +#[test] +fn test_pinmap_nano_has_analog_only_pins() { + use anvil::board::pinmap; + + let m = pinmap::find_pinmap("nano").unwrap(); + let a6 = pinmap::get_pin(m, 20).unwrap(); // A6 + assert!(a6.capabilities.contains(&"analog")); + assert!(!a6.capabilities.contains(&"digital"), + "Nano A6 should be analog-only"); +} + +// ========================================================================== +// Pin config in .anvil.toml +// ========================================================================== + +#[test] +fn test_config_with_pins_roundtrips() { + use anvil::project::config::{BoardPinConfig, PinAssignment, BusConfig}; + use std::collections::HashMap; + + let tmp = TempDir::new().unwrap(); + let mut config = ProjectConfig::new("pin_test"); + + // Add pin assignments + let mut assignments = HashMap::new(); + assignments.insert("red_led".to_string(), PinAssignment { + pin: 13, + mode: "output".to_string(), + }); + assignments.insert("limit_switch".to_string(), PinAssignment { + pin: 7, + mode: "input".to_string(), + }); + + let mut buses = HashMap::new(); + let mut spi_pins = HashMap::new(); + spi_pins.insert("cs".to_string(), 10u8); + buses.insert("spi".to_string(), BusConfig { user_pins: spi_pins }); + buses.insert("i2c".to_string(), BusConfig { user_pins: HashMap::new() }); + + config.pins.insert("uno".to_string(), BoardPinConfig { + assignments, + buses, + }); + + config.save(tmp.path()).unwrap(); + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + + let pc = loaded.pins.get("uno").unwrap(); + assert_eq!(pc.assignments.len(), 2); + assert_eq!(pc.assignments["red_led"].pin, 13); + assert_eq!(pc.assignments["red_led"].mode, "output"); + assert_eq!(pc.assignments["limit_switch"].pin, 7); + assert_eq!(pc.buses.len(), 2); + assert_eq!(*pc.buses["spi"].user_pins.get("cs").unwrap(), 10u8); + assert!(pc.buses["i2c"].user_pins.is_empty()); +} + +#[test] +fn test_config_without_pins_loads_empty() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("no_pins"); + config.save(tmp.path()).unwrap(); + + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + assert!(loaded.pins.is_empty(), "Config without pins section should load with empty pins"); +} + +#[test] +fn test_config_pins_skip_serializing_when_empty() { + let tmp = TempDir::new().unwrap(); + let config = ProjectConfig::new("empty_pins"); + config.save(tmp.path()).unwrap(); + + let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap(); + assert!( + !content.contains("[pins"), + "Empty pins should not appear in serialized config" + ); +} + +#[test] +fn test_pins_per_board_independence() { + use anvil::project::config::{BoardPinConfig, PinAssignment}; + use std::collections::HashMap; + + let tmp = TempDir::new().unwrap(); + let mut config = ProjectConfig::new("multi_board_pins"); + config.boards.insert("mega".to_string(), BoardProfile { + fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(), + baud: None, + }); + + // Different pin numbers for same friendly name on different boards + let mut uno_assigns = HashMap::new(); + uno_assigns.insert("status_led".to_string(), PinAssignment { + pin: 13, mode: "output".to_string(), + }); + + let mut mega_assigns = HashMap::new(); + mega_assigns.insert("status_led".to_string(), PinAssignment { + pin: 13, mode: "output".to_string(), + }); + mega_assigns.insert("extra_led".to_string(), PinAssignment { + pin: 22, mode: "output".to_string(), + }); + + config.pins.insert("uno".to_string(), BoardPinConfig { + assignments: uno_assigns, buses: HashMap::new(), + }); + config.pins.insert("mega".to_string(), BoardPinConfig { + assignments: mega_assigns, buses: HashMap::new(), + }); + + config.save(tmp.path()).unwrap(); + let loaded = ProjectConfig::load(tmp.path()).unwrap(); + + let uno_pins = loaded.pins.get("uno").unwrap(); + let mega_pins = loaded.pins.get("mega").unwrap(); + + assert_eq!(uno_pins.assignments.len(), 1); + assert_eq!(mega_pins.assignments.len(), 2); + assert!(mega_pins.assignments.contains_key("extra_led")); + assert!(!uno_pins.assignments.contains_key("extra_led")); } \ No newline at end of file