diff --git a/Cargo.lock b/Cargo.lock index 6ca9b7ece3..d5fe22f4cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,14 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aardvark-sys" +version = "0.1.0" +dependencies = [ + "libloading", + "thiserror 2.0.18", +] + [[package]] name = "accessory" version = "2.1.0" @@ -3038,6 +3046,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -7947,6 +7965,7 @@ dependencies = [ name = "zeroclawlabs" version = "0.3.1" dependencies = [ + "aardvark-sys", "anyhow", "async-imap", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 47a45c619e..33df49f782 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/robot-kit"] +members = [".", "crates/robot-kit", "crates/aardvark-sys"] resolver = "2" [package] @@ -86,6 +86,9 @@ fantoccini = { version = "0.22.1", optional = true, default-features = false, fe anyhow = "1.0" thiserror = "2.0" +# Aardvark I2C/SPI/GPIO USB adapter (Total Phase) — stub when SDK absent +aardvark-sys = { path = "crates/aardvark-sys" } + # UUID generation uuid = { version = "1.22", default-features = false, features = ["v4", "std"] } diff --git a/crates/aardvark-sys/Cargo.toml b/crates/aardvark-sys/Cargo.toml new file mode 100644 index 0000000000..ff90a15dba --- /dev/null +++ b/crates/aardvark-sys/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "aardvark-sys" +version = "0.1.0" +edition = "2021" +authors = ["theonlyhennygod"] +license = "MIT OR Apache-2.0" +description = "Low-level bindings for the Total Phase Aardvark I2C/SPI/GPIO USB adapter" +repository = "https://github.com/zeroclaw-labs/zeroclaw" + +# NOTE: This crate is the ONLY place in ZeroClaw where unsafe code is permitted. +# The rest of the workspace remains #![forbid(unsafe_code)]. +# +# Stub implementation: the Total Phase SDK (aardvark.h + aardvark.so) is NOT +# yet committed. All AardvarkHandle methods return Err(AardvarkError::NotFound) +# at runtime. No unsafe code is needed for the stub. +# +# To enable real hardware (once SDK files are in vendor/): +# 1. Add `bindgen = "0.69"` to [build-dependencies] +# 2. Add `libc = "0.2"` to [dependencies] +# 3. Uncomment the build.rs bindgen call +# 4. Replace stub method bodies with FFI calls via mod bindings + +[dependencies] +libloading = "0.8" +thiserror = "2.0" diff --git a/crates/aardvark-sys/build.rs b/crates/aardvark-sys/build.rs new file mode 100644 index 0000000000..1630864911 --- /dev/null +++ b/crates/aardvark-sys/build.rs @@ -0,0 +1,27 @@ +//! Build script for aardvark-sys. +//! +//! # SDK present (real hardware) +//! When the Total Phase SDK files are in `vendor/`: +//! - Sets linker search path for aardvark.so +//! - Generates src/bindings.rs via bindgen +//! +//! # SDK absent (stub) +//! Does nothing. All AardvarkHandle methods return errors at runtime. + +fn main() { + // Stub: SDK not yet in vendor/ + // Uncomment and fill in when aardvark.h + aardvark.so are available: + // + // println!("cargo:rustc-link-search=native=crates/aardvark-sys/vendor"); + // println!("cargo:rustc-link-lib=dylib=aardvark"); + // println!("cargo:rerun-if-changed=vendor/aardvark.h"); + // + // let bindings = bindgen::Builder::default() + // .header("vendor/aardvark.h") + // .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) + // .generate() + // .expect("Unable to generate aardvark bindings"); + // bindings + // .write_to_file("src/bindings.rs") + // .expect("Could not write bindings"); +} diff --git a/crates/aardvark-sys/src/lib.rs b/crates/aardvark-sys/src/lib.rs new file mode 100644 index 0000000000..672aee6cc8 --- /dev/null +++ b/crates/aardvark-sys/src/lib.rs @@ -0,0 +1,475 @@ +//! Bindings for the Total Phase Aardvark I2C/SPI/GPIO USB adapter. +//! +//! Uses [`libloading`] to load `aardvark.so` at runtime — the same pattern +//! the official Total Phase C stub (`aardvark.c`) uses internally. +//! +//! # Library search order +//! +//! 1. `ZEROCLAW_AARDVARK_LIB` environment variable (full path to `aardvark.so`) +//! 2. `/crates/aardvark-sys/vendor/aardvark.so` (development default) +//! 3. `./aardvark.so` (next to the binary, for deployment) +//! +//! If none resolve, every method returns +//! [`Err(AardvarkError::LibraryNotFound)`](AardvarkError::LibraryNotFound). +//! +//! # Safety +//! +//! This crate is the **only** place in ZeroClaw where `unsafe` is permitted. +//! All `unsafe` is confined to `extern "C"` call sites inside this file. +//! The public API is fully safe Rust. + +use std::path::PathBuf; +use std::sync::OnceLock; + +use libloading::{Library, Symbol}; +use thiserror::Error; + +// ── Constants from aardvark.h ───────────────────────────────────────────── + +/// Bit set on a port returned by `aa_find_devices` when that port is in use. +const AA_PORT_NOT_FREE: u16 = 0x8000; +/// Configure adapter for I2C + GPIO (I2C master mode, SPI disabled). +const AA_CONFIG_GPIO_I2C: i32 = 0x02; +/// Configure adapter for SPI + GPIO (SPI master mode, I2C disabled). +const AA_CONFIG_SPI_GPIO: i32 = 0x01; +/// No I2C flags (standard 7-bit addressing, normal stop condition). +const AA_I2C_NO_FLAGS: i32 = 0x00; +/// Enable both onboard I2C pullup resistors (hardware v2+ only). +const AA_I2C_PULLUP_BOTH: u8 = 0x03; + +// ── Library loading ─────────────────────────────────────────────────────── + +static AARDVARK_LIB: OnceLock> = OnceLock::new(); + +fn lib() -> Option<&'static Library> { + AARDVARK_LIB + .get_or_init(|| { + let candidates: Vec = vec![ + // 1. Explicit env-var override (full path) + std::env::var("ZEROCLAW_AARDVARK_LIB") + .ok() + .map(PathBuf::from) + .unwrap_or_default(), + // 2. Vendor directory shipped with this crate (dev default) + { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("vendor/aardvark.so"); + p + }, + // 3. Next to the running binary (deployment) + std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(|d| d.join("aardvark.so"))) + .unwrap_or_default(), + // 4. Current working directory + PathBuf::from("aardvark.so"), + ]; + let mut tried_any = false; + for path in &candidates { + if path.as_os_str().is_empty() { + continue; + } + tried_any = true; + match unsafe { Library::new(path) } { + Ok(lib) => { + // Verify the .so exports aa_c_version (Total Phase version gate). + // The .so exports c_aa_* symbols (not aa_*); aa_c_version is the + // one non-prefixed symbol used to confirm library identity. + let version_ok = unsafe { + lib.get:: u32>(b"aa_c_version\0").is_ok() + }; + if !version_ok { + eprintln!( + "[aardvark-sys] {} loaded but aa_c_version not found — \ + not a valid Aardvark library, skipping", + path.display() + ); + continue; + } + eprintln!("[aardvark-sys] loaded library from {}", path.display()); + return Some(lib); + } + Err(e) => { + let msg = e.to_string(); + // Surface architecture mismatch explicitly — the most common + // failure on Apple Silicon machines with an x86_64 SDK. + if msg.contains("incompatible architecture") || msg.contains("mach-o file") { + eprintln!( + "[aardvark-sys] ARCHITECTURE MISMATCH loading {}: {}\n\ + [aardvark-sys] The vendored aardvark.so is x86_64 but this \ + binary is {}.\n\ + [aardvark-sys] Download the arm64 SDK from https://www.totalphase.com/downloads/ \ + or build with --target x86_64-apple-darwin.", + path.display(), + msg, + std::env::consts::ARCH, + ); + } else { + eprintln!( + "[aardvark-sys] could not load {}: {}", + path.display(), + msg + ); + } + } + } + } + if !tried_any { + eprintln!("[aardvark-sys] no library candidates found; set ZEROCLAW_AARDVARK_LIB or place aardvark.so next to the binary"); + } + None + }) + .as_ref() +} + +/// Errors returned by Aardvark hardware operations. +#[derive(Debug, Error)] +pub enum AardvarkError { + /// No Aardvark adapter found — adapter not plugged in. + #[error("Aardvark adapter not found — is it plugged in?")] + NotFound, + /// `aa_open` returned a non-positive handle. + #[error("Aardvark open failed (code {0})")] + OpenFailed(i32), + /// `aa_i2c_write` returned a negative status code. + #[error("I2C write failed (code {0})")] + I2cWriteFailed(i32), + /// `aa_i2c_read` returned a negative status code. + #[error("I2C read failed (code {0})")] + I2cReadFailed(i32), + /// `aa_spi_write` returned a negative status code. + #[error("SPI transfer failed (code {0})")] + SpiTransferFailed(i32), + /// GPIO operation returned a negative status code. + #[error("GPIO error (code {0})")] + GpioError(i32), + /// `aardvark.so` could not be found or loaded. + #[error("aardvark.so not found — set ZEROCLAW_AARDVARK_LIB or place it next to the binary")] + LibraryNotFound, +} + +/// Convenience `Result` alias for this crate. +pub type Result = std::result::Result; + +// ── Handle ──────────────────────────────────────────────────────────────── + +/// Safe RAII handle over the Aardvark C library handle. +/// +/// Automatically closes the adapter on `Drop`. +/// +/// **Usage pattern:** open a fresh handle per command and let it drop at the +/// end of each operation (lazy-open / eager-close). +pub struct AardvarkHandle { + handle: i32, +} + +impl AardvarkHandle { + // ── Lifecycle ───────────────────────────────────────────────────────── + + /// Open the first available (free) Aardvark adapter. + pub fn open() -> Result { + let ports = Self::find_devices(); + let port = ports.first().copied().ok_or(AardvarkError::NotFound)?; + Self::open_port(i32::from(port)) + } + + /// Open a specific Aardvark adapter by port index. + pub fn open_port(port: i32) -> Result { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + let handle: i32 = unsafe { + let f: Symbol i32> = lib + .get(b"c_aa_open\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + f(port) + }; + if handle <= 0 { + Err(AardvarkError::OpenFailed(handle)) + } else { + Ok(Self { handle }) + } + } + + /// Return the port numbers of all **free** connected adapters. + /// + /// Ports in-use by another process are filtered out. + /// Returns an empty `Vec` when `aardvark.so` cannot be loaded. + pub fn find_devices() -> Vec { + let Some(lib) = lib() else { + eprintln!("[aardvark-sys] find_devices: library not loaded"); + return Vec::new(); + }; + let mut ports = [0u16; 16]; + let n: i32 = unsafe { + let f: std::result::Result i32>, _> = + lib.get(b"c_aa_find_devices\0"); + match f { + Ok(f) => f(16, ports.as_mut_ptr()), + Err(e) => { + eprintln!("[aardvark-sys] find_devices: symbol lookup failed: {e}"); + return Vec::new(); + } + } + }; + eprintln!( + "[aardvark-sys] find_devices: c_aa_find_devices returned {n}, ports={:?}", + &ports[..n.max(0) as usize] + ); + if n <= 0 { + return Vec::new(); + } + let free: Vec = ports[..n as usize] + .iter() + .filter(|&&p| (p & AA_PORT_NOT_FREE) == 0) + .copied() + .collect(); + eprintln!("[aardvark-sys] find_devices: free ports={free:?}"); + free + } + + // ── I2C ─────────────────────────────────────────────────────────────── + + /// Enable I2C mode and set the bitrate (kHz). + pub fn i2c_enable(&self, bitrate_khz: u32) -> Result<()> { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + unsafe { + let configure: Symbol i32> = lib + .get(b"c_aa_configure\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + configure(self.handle, AA_CONFIG_GPIO_I2C); + let pullup: Symbol i32> = lib + .get(b"c_aa_i2c_pullup\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + pullup(self.handle, AA_I2C_PULLUP_BOTH); + let bitrate: Symbol i32> = lib + .get(b"c_aa_i2c_bitrate\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + bitrate(self.handle, bitrate_khz as i32); + } + Ok(()) + } + + /// Write `data` bytes to the I2C device at `addr`. + pub fn i2c_write(&self, addr: u8, data: &[u8]) -> Result<()> { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + let ret: i32 = unsafe { + let f: Symbol i32> = lib + .get(b"c_aa_i2c_write\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + f( + self.handle, + u16::from(addr), + AA_I2C_NO_FLAGS, + data.len() as u16, + data.as_ptr(), + ) + }; + if ret < 0 { + Err(AardvarkError::I2cWriteFailed(ret)) + } else { + Ok(()) + } + } + + /// Read `len` bytes from the I2C device at `addr`. + pub fn i2c_read(&self, addr: u8, len: usize) -> Result> { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + let mut buf = vec![0u8; len]; + let ret: i32 = unsafe { + let f: Symbol i32> = lib + .get(b"c_aa_i2c_read\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + f( + self.handle, + u16::from(addr), + AA_I2C_NO_FLAGS, + len as u16, + buf.as_mut_ptr(), + ) + }; + if ret < 0 { + Err(AardvarkError::I2cReadFailed(ret)) + } else { + Ok(buf) + } + } + + /// Write then read — standard I2C register-read pattern. + pub fn i2c_write_read(&self, addr: u8, write_data: &[u8], read_len: usize) -> Result> { + self.i2c_write(addr, write_data)?; + self.i2c_read(addr, read_len) + } + + /// Scan the I2C bus, returning addresses of all responding devices. + /// + /// Probes `0x08–0x77` with a 1-byte read; returns addresses that ACK. + pub fn i2c_scan(&self) -> Vec { + let Some(lib) = lib() else { + return Vec::new(); + }; + let Ok(f): std::result::Result< + Symbol i32>, + _, + > = (unsafe { lib.get(b"c_aa_i2c_read\0") }) else { + return Vec::new(); + }; + let mut found = Vec::new(); + let mut buf = [0u8; 1]; + for addr in 0x08u16..=0x77 { + let ret = unsafe { f(self.handle, addr, AA_I2C_NO_FLAGS, 1, buf.as_mut_ptr()) }; + // ret > 0: bytes received → device ACKed + // ret == 0: NACK → no device at this address + // ret < 0: error code → skip + if ret > 0 { + found.push(addr as u8); + } + } + found + } + + // ── SPI ─────────────────────────────────────────────────────────────── + + /// Enable SPI mode and set the bitrate (kHz). + pub fn spi_enable(&self, bitrate_khz: u32) -> Result<()> { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + unsafe { + let configure: Symbol i32> = lib + .get(b"c_aa_configure\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + configure(self.handle, AA_CONFIG_SPI_GPIO); + // SPI mode 0: polarity=rising/falling(0), phase=sample/setup(0), MSB first(0) + let spi_cfg: Symbol i32> = lib + .get(b"c_aa_spi_configure\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + spi_cfg(self.handle, 0, 0, 0); + let bitrate: Symbol i32> = lib + .get(b"c_aa_spi_bitrate\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + bitrate(self.handle, bitrate_khz as i32); + } + Ok(()) + } + + /// Full-duplex SPI transfer. + /// + /// Sends `send` bytes; returns the simultaneously received bytes (same length). + pub fn spi_transfer(&self, send: &[u8]) -> Result> { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + let mut recv = vec![0u8; send.len()]; + // aa_spi_write(aardvark, out_num_bytes, data_out, in_num_bytes, data_in) + let ret: i32 = unsafe { + let f: Symbol i32> = lib + .get(b"c_aa_spi_write\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + f( + self.handle, + send.len() as u16, + send.as_ptr(), + recv.len() as u16, + recv.as_mut_ptr(), + ) + }; + if ret < 0 { + Err(AardvarkError::SpiTransferFailed(ret)) + } else { + Ok(recv) + } + } + + // ── GPIO ────────────────────────────────────────────────────────────── + + /// Set GPIO pin directions and output values. + /// + /// `direction`: bitmask — `1` = output, `0` = input. + /// `value`: output state bitmask. + pub fn gpio_set(&self, direction: u8, value: u8) -> Result<()> { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + unsafe { + let dir_f: Symbol i32> = lib + .get(b"c_aa_gpio_direction\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + let d = dir_f(self.handle, direction); + if d < 0 { + return Err(AardvarkError::GpioError(d)); + } + let set_f: Symbol i32> = + lib.get(b"c_aa_gpio_set\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + let r = set_f(self.handle, value); + if r < 0 { + return Err(AardvarkError::GpioError(r)); + } + } + Ok(()) + } + + /// Read the current GPIO pin states as a bitmask. + pub fn gpio_get(&self) -> Result { + let lib = lib().ok_or(AardvarkError::LibraryNotFound)?; + let ret: i32 = unsafe { + let f: Symbol i32> = lib + .get(b"c_aa_gpio_get\0") + .map_err(|_| AardvarkError::LibraryNotFound)?; + f(self.handle) + }; + if ret < 0 { + Err(AardvarkError::GpioError(ret)) + } else { + Ok(ret as u8) + } + } +} + +impl Drop for AardvarkHandle { + fn drop(&mut self) { + if let Some(lib) = lib() { + unsafe { + if let Ok(f) = lib.get:: i32>(b"c_aa_close\0") { + f(self.handle); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_devices_does_not_panic() { + // With no adapter plugged in, must return empty without panicking. + let _ = AardvarkHandle::find_devices(); + } + + #[test] + fn open_returns_error_or_ok_depending_on_hardware() { + // With hardware connected: open() succeeds (Ok). + // Without hardware: returns LibraryNotFound, NotFound, or OpenFailed — any Err is fine. + // Both outcomes are valid; the important thing is no panic. + let _ = AardvarkHandle::open(); + } + + #[test] + fn open_port_returns_error_when_no_hardware() { + // Port 99 doesn't exist — must return an error regardless of whether hardware is connected. + assert!(AardvarkHandle::open_port(99).is_err()); + } + + #[test] + fn error_display_messages_are_human_readable() { + assert!(AardvarkError::NotFound + .to_string() + .to_lowercase() + .contains("not found")); + assert!(AardvarkError::OpenFailed(-1).to_string().contains("-1")); + assert!(AardvarkError::I2cWriteFailed(-3) + .to_string() + .contains("I2C write")); + assert!(AardvarkError::SpiTransferFailed(-2) + .to_string() + .contains("SPI")); + assert!(AardvarkError::LibraryNotFound + .to_string() + .contains("aardvark.so")); + } +} diff --git a/crates/aardvark-sys/vendor/aardvark.h b/crates/aardvark-sys/vendor/aardvark.h new file mode 100644 index 0000000000..fc63208a69 --- /dev/null +++ b/crates/aardvark-sys/vendor/aardvark.h @@ -0,0 +1,919 @@ +/*========================================================================= +| Aardvark Interface Library +|-------------------------------------------------------------------------- +| Copyright (c) 2003-2024 Total Phase, Inc. +| All rights reserved. +| www.totalphase.com +| +| Redistribution and use of this file in source and binary forms, with +| or without modification, are permitted provided that the following +| conditions are met: +| +| - Redistributions of source code must retain the above copyright +| notice, this list of conditions, and the following disclaimer. +| +| - Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions, and the following disclaimer in the +| documentation or other materials provided with the distribution. +| +| - This file must only be used to interface with Total Phase products. +| The names of Total Phase and its contributors must not be used to +| endorse or promote products derived from this software. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING BUT NOT +| LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +| FOR A PARTICULAR PURPOSE, ARE DISCLAIMED. IN NO EVENT WILL THE +| COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +| INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +| BUT NOT LIMITED TO PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +| LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +| LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +| ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. +|-------------------------------------------------------------------------- +| To access Total Phase Aardvark devices through the API: +| +| 1) Use one of the following shared objects: +| aardvark.so -- Linux or macOS shared object +| aardvark.dll -- Windows dynamic link library +| +| 2) Along with one of the following language modules: +| aardvark.c/h -- C/C++ API header file and interface module +| aardvark_py.py -- Python API +| aardvark.cs -- C# .NET source +| aardvark_net.dll -- Compiled .NET binding +| aardvark.bas -- Visual Basic 6 API + ========================================================================*/ + + +#ifndef __aardvark_h__ +#define __aardvark_h__ + +#ifdef __cplusplus +extern "C" { +#endif + + +/*========================================================================= +| TYPEDEFS + ========================================================================*/ +#ifndef TOTALPHASE_DATA_TYPES +#define TOTALPHASE_DATA_TYPES + +#ifndef _MSC_VER +/* C99-compliant compilers (GCC) */ +#include +typedef uint8_t u08; +typedef uint16_t u16; +typedef uint32_t u32; +typedef uint64_t u64; +typedef int8_t s08; +typedef int16_t s16; +typedef int32_t s32; +typedef int64_t s64; + +#else +/* Microsoft compilers (Visual C++) */ +typedef unsigned __int8 u08; +typedef unsigned __int16 u16; +typedef unsigned __int32 u32; +typedef unsigned __int64 u64; +typedef signed __int8 s08; +typedef signed __int16 s16; +typedef signed __int32 s32; +typedef signed __int64 s64; + +#endif /* __MSC_VER */ + +typedef float f32; +typedef double f64; + +#endif /* TOTALPHASE_DATA_TYPES */ + + +/*========================================================================= +| DEBUG + ========================================================================*/ +/* Set the following macro to '1' for debugging */ +#define AA_DEBUG 0 + + +/*========================================================================= +| VERSION + ========================================================================*/ +#define AA_HEADER_VERSION 0x0600 /* v6.00 */ + + +/*========================================================================= +| STATUS CODES + ========================================================================*/ +/* + * All API functions return an integer which is the result of the + * transaction, or a status code if negative. The status codes are + * defined as follows: + */ +enum AardvarkStatus { + /* General codes (0 to -99) */ + AA_OK = 0, + AA_UNABLE_TO_LOAD_LIBRARY = -1, + AA_UNABLE_TO_LOAD_DRIVER = -2, + AA_UNABLE_TO_LOAD_FUNCTION = -3, + AA_INCOMPATIBLE_LIBRARY = -4, + AA_INCOMPATIBLE_DEVICE = -5, + AA_COMMUNICATION_ERROR = -6, + AA_UNABLE_TO_OPEN = -7, + AA_UNABLE_TO_CLOSE = -8, + AA_INVALID_HANDLE = -9, + AA_CONFIG_ERROR = -10, + + /* I2C codes (-100 to -199) */ + AA_I2C_NOT_AVAILABLE = -100, + AA_I2C_NOT_ENABLED = -101, + AA_I2C_READ_ERROR = -102, + AA_I2C_WRITE_ERROR = -103, + AA_I2C_SLAVE_BAD_CONFIG = -104, + AA_I2C_SLAVE_READ_ERROR = -105, + AA_I2C_SLAVE_TIMEOUT = -106, + AA_I2C_DROPPED_EXCESS_BYTES = -107, + AA_I2C_BUS_ALREADY_FREE = -108, + + /* SPI codes (-200 to -299) */ + AA_SPI_NOT_AVAILABLE = -200, + AA_SPI_NOT_ENABLED = -201, + AA_SPI_WRITE_ERROR = -202, + AA_SPI_SLAVE_READ_ERROR = -203, + AA_SPI_SLAVE_TIMEOUT = -204, + AA_SPI_DROPPED_EXCESS_BYTES = -205, + + /* GPIO codes (-400 to -499) */ + AA_GPIO_NOT_AVAILABLE = -400 +}; +#ifndef __cplusplus +typedef enum AardvarkStatus AardvarkStatus; +#endif + + +/*========================================================================= +| GENERAL TYPE DEFINITIONS + ========================================================================*/ +/* Aardvark handle type definition */ +typedef int Aardvark; + +/* + * Deprecated type definitions. + * + * These are only for use with legacy code and + * should not be used for new development. + */ +typedef u08 aa_u08; + +typedef u16 aa_u16; + +typedef u32 aa_u32; + +typedef s08 aa_s08; + +typedef s16 aa_s16; + +typedef s32 aa_s32; + +/* + * Aardvark version matrix. + * + * This matrix describes the various version dependencies + * of Aardvark components. It can be used to determine + * which component caused an incompatibility error. + * + * All version numbers are of the format: + * (major << 8) | minor + * + * ex. v1.20 would be encoded as: 0x0114 + */ +struct AardvarkVersion { + /* Software, firmware, and hardware versions. */ + u16 software; + u16 firmware; + u16 hardware; + + /* Firmware requires that software must be >= this version. */ + u16 sw_req_by_fw; + + /* Software requires that firmware must be >= this version. */ + u16 fw_req_by_sw; + + /* Software requires that the API interface must be >= this version. */ + u16 api_req_by_sw; +}; +#ifndef __cplusplus +typedef struct AardvarkVersion AardvarkVersion; +#endif + + +/*========================================================================= +| GENERAL API + ========================================================================*/ +/* + * Get a list of ports to which Aardvark devices are attached. + * + * nelem = maximum number of elements to return + * devices = array into which the port numbers are returned + * + * Each element of the array is written with the port number. + * Devices that are in-use are ORed with AA_PORT_NOT_FREE (0x8000). + * + * ex. devices are attached to ports 0, 1, 2 + * ports 0 and 2 are available, and port 1 is in-use. + * array => 0x0000, 0x8001, 0x0002 + * + * If the array is NULL, it is not filled with any values. + * If there are more devices than the array size, only the + * first nmemb port numbers will be written into the array. + * + * Returns the number of devices found, regardless of the + * array size. + */ +#define AA_PORT_NOT_FREE 0x8000 +int aa_find_devices ( + int num_devices, + u16 * devices +); + + +/* + * Get a list of ports to which Aardvark devices are attached. + * + * This function is the same as aa_find_devices() except that + * it returns the unique IDs of each Aardvark device. The IDs + * are guaranteed to be non-zero if valid. + * + * The IDs are the unsigned integer representation of the 10-digit + * serial numbers. + */ +int aa_find_devices_ext ( + int num_devices, + u16 * devices, + int num_ids, + u32 * unique_ids +); + + +/* + * Open the Aardvark port. + * + * The port number is a zero-indexed integer. + * + * The port number is the same as that obtained from the + * aa_find_devices() function above. + * + * Returns an Aardvark handle, which is guaranteed to be + * greater than zero if it is valid. + * + * This function is recommended for use in simple applications + * where extended information is not required. For more complex + * applications, the use of aa_open_ext() is recommended. + */ +Aardvark aa_open ( + int port_number +); + + +/* + * Open the Aardvark port, returning extended information + * in the supplied structure. Behavior is otherwise identical + * to aa_open() above. If 0 is passed as the pointer to the + * structure, this function is exactly equivalent to aa_open(). + * + * The structure is zeroed before the open is attempted. + * It is filled with whatever information is available. + * + * For example, if the firmware version is not filled, then + * the device could not be queried for its version number. + * + * This function is recommended for use in complex applications + * where extended information is required. For more simple + * applications, the use of aa_open() is recommended. + */ +struct AardvarkExt { + /* Version matrix */ + AardvarkVersion version; + + /* Features of this device. */ + int features; +}; +#ifndef __cplusplus +typedef struct AardvarkExt AardvarkExt; +#endif + +Aardvark aa_open_ext ( + int port_number, + AardvarkExt * aa_ext +); + + +/* Close the Aardvark port. */ +int aa_close ( + Aardvark aardvark +); + + +/* + * Return the port for this Aardvark handle. + * + * The port number is a zero-indexed integer. + */ +int aa_port ( + Aardvark aardvark +); + + +/* + * Return the device features as a bit-mask of values, or + * an error code if the handle is not valid. + */ +#define AA_FEATURE_SPI 0x00000001 +#define AA_FEATURE_I2C 0x00000002 +#define AA_FEATURE_GPIO 0x00000008 +int aa_features ( + Aardvark aardvark +); + + +/* + * Return the unique ID for this Aardvark adapter. + * IDs are guaranteed to be non-zero if valid. + * The ID is the unsigned integer representation of the + * 10-digit serial number. + */ +u32 aa_unique_id ( + Aardvark aardvark +); + + +/* + * Return the status string for the given status code. + * If the code is not valid or the library function cannot + * be loaded, return a NULL string. + */ +const char * aa_status_string ( + int status +); + + +/* + * Enable logging to a file. The handle must be standard file + * descriptor. In C, a file descriptor can be obtained by using + * the ANSI C function "open" or by using the function "fileno" + * on a FILE* stream. A FILE* stream can be obtained using "fopen" + * or can correspond to the common "stdout" or "stderr" -- + * available when including stdlib.h + */ +#define AA_LOG_STDOUT 1 +#define AA_LOG_STDERR 2 +int aa_log ( + Aardvark aardvark, + int level, + int handle +); + + +/* + * Return the version matrix for the device attached to the + * given handle. If the handle is 0 or invalid, only the + * software and required api versions are set. + */ +int aa_version ( + Aardvark aardvark, + AardvarkVersion * version +); + + +/* + * Configure the device by enabling/disabling I2C, SPI, and + * GPIO functions. + */ +enum AardvarkConfig { + AA_CONFIG_GPIO_ONLY = 0x00, + AA_CONFIG_SPI_GPIO = 0x01, + AA_CONFIG_GPIO_I2C = 0x02, + AA_CONFIG_SPI_I2C = 0x03, + AA_CONFIG_QUERY = 0x80 +}; +#ifndef __cplusplus +typedef enum AardvarkConfig AardvarkConfig; +#endif + +#define AA_CONFIG_SPI_MASK 0x00000001 +#define AA_CONFIG_I2C_MASK 0x00000002 +int aa_configure ( + Aardvark aardvark, + AardvarkConfig config +); + + +/* + * Configure the target power pins. + * This is only supported on hardware versions >= 2.00 + */ +#define AA_TARGET_POWER_NONE 0x00 +#define AA_TARGET_POWER_BOTH 0x03 +#define AA_TARGET_POWER_QUERY 0x80 +int aa_target_power ( + Aardvark aardvark, + u08 power_mask +); + + +/* + * Sleep for the specified number of milliseconds + * Accuracy depends on the operating system scheduler + * Returns the number of milliseconds slept + */ +u32 aa_sleep_ms ( + u32 milliseconds +); + + + +/*========================================================================= +| ASYNC MESSAGE POLLING + ========================================================================*/ +/* + * Polling function to check if there are any asynchronous + * messages pending for processing. The function takes a timeout + * value in units of milliseconds. If the timeout is < 0, the + * function will block until data is received. If the timeout is 0, + * the function will perform a non-blocking check. + */ +#define AA_ASYNC_NO_DATA 0x00000000 +#define AA_ASYNC_I2C_READ 0x00000001 +#define AA_ASYNC_I2C_WRITE 0x00000002 +#define AA_ASYNC_SPI 0x00000004 +int aa_async_poll ( + Aardvark aardvark, + int timeout +); + + + +/*========================================================================= +| I2C API + ========================================================================*/ +/* Free the I2C bus. */ +int aa_i2c_free_bus ( + Aardvark aardvark +); + + +/* + * Set the I2C bit rate in kilohertz. If a zero is passed as the + * bitrate, the bitrate is unchanged and the current bitrate is + * returned. + */ +int aa_i2c_bitrate ( + Aardvark aardvark, + int bitrate_khz +); + + +/* + * Set the bus lock timeout. If a zero is passed as the timeout, + * the timeout is unchanged and the current timeout is returned. + */ +int aa_i2c_bus_timeout ( + Aardvark aardvark, + u16 timeout_ms +); + + +enum AardvarkI2cFlags { + AA_I2C_NO_FLAGS = 0x00, + AA_I2C_10_BIT_ADDR = 0x01, + AA_I2C_COMBINED_FMT = 0x02, + AA_I2C_NO_STOP = 0x04, + AA_I2C_SIZED_READ = 0x10, + AA_I2C_SIZED_READ_EXTRA1 = 0x20 +}; +#ifndef __cplusplus +typedef enum AardvarkI2cFlags AardvarkI2cFlags; +#endif + +/* Read a stream of bytes from the I2C slave device. */ +int aa_i2c_read ( + Aardvark aardvark, + u16 slave_addr, + AardvarkI2cFlags flags, + u16 num_bytes, + u08 * data_in +); + + +enum AardvarkI2cStatus { + AA_I2C_STATUS_OK = 0, + AA_I2C_STATUS_BUS_ERROR = 1, + AA_I2C_STATUS_SLA_ACK = 2, + AA_I2C_STATUS_SLA_NACK = 3, + AA_I2C_STATUS_DATA_NACK = 4, + AA_I2C_STATUS_ARB_LOST = 5, + AA_I2C_STATUS_BUS_LOCKED = 6, + AA_I2C_STATUS_LAST_DATA_ACK = 7 +}; +#ifndef __cplusplus +typedef enum AardvarkI2cStatus AardvarkI2cStatus; +#endif + +/* + * Read a stream of bytes from the I2C slave device. + * This API function returns the number of bytes read into + * the num_read variable. The return value of the function + * is a status code. + */ +int aa_i2c_read_ext ( + Aardvark aardvark, + u16 slave_addr, + AardvarkI2cFlags flags, + u16 num_bytes, + u08 * data_in, + u16 * num_read +); + + +/* Write a stream of bytes to the I2C slave device. */ +int aa_i2c_write ( + Aardvark aardvark, + u16 slave_addr, + AardvarkI2cFlags flags, + u16 num_bytes, + const u08 * data_out +); + + +/* + * Write a stream of bytes to the I2C slave device. + * This API function returns the number of bytes written into + * the num_written variable. The return value of the function + * is a status code. + */ +int aa_i2c_write_ext ( + Aardvark aardvark, + u16 slave_addr, + AardvarkI2cFlags flags, + u16 num_bytes, + const u08 * data_out, + u16 * num_written +); + + +/* + * Do an atomic write+read to an I2C slave device by first + * writing a stream of bytes to the I2C slave device and then + * reading a stream of bytes back from the same slave device. + * This API function returns the number of bytes written into + * the num_written variable and the number of bytes read into + * the num_read variable. The return value of the function is + * the status given as (read_status << 8) | (write_status). + */ +int aa_i2c_write_read ( + Aardvark aardvark, + u16 slave_addr, + AardvarkI2cFlags flags, + u16 out_num_bytes, + const u08 * out_data, + u16 * num_written, + u16 in_num_bytes, + u08 * in_data, + u16 * num_read +); + + +/* Enable/Disable the Aardvark as an I2C slave device */ +int aa_i2c_slave_enable ( + Aardvark aardvark, + u08 addr, + u16 maxTxBytes, + u16 maxRxBytes +); + + +int aa_i2c_slave_disable ( + Aardvark aardvark +); + + +/* + * Set the slave response in the event the Aardvark is put + * into slave mode and contacted by a Master. + */ +int aa_i2c_slave_set_response ( + Aardvark aardvark, + u08 num_bytes, + const u08 * data_out +); + + +/* + * Return number of bytes written from a previous + * Aardvark->I2C_master transmission. Since the transmission is + * happening asynchronously with respect to the PC host + * software, there could be responses queued up from many + * previous write transactions. + */ +int aa_i2c_slave_write_stats ( + Aardvark aardvark +); + + +/* Read the bytes from an I2C slave reception */ +int aa_i2c_slave_read ( + Aardvark aardvark, + u08 * addr, + u16 num_bytes, + u08 * data_in +); + + +/* Extended functions that return status code */ +int aa_i2c_slave_write_stats_ext ( + Aardvark aardvark, + u16 * num_written +); + + +int aa_i2c_slave_read_ext ( + Aardvark aardvark, + u08 * addr, + u16 num_bytes, + u08 * data_in, + u16 * num_read +); + + +/* + * Configure the I2C pullup resistors. + * This is only supported on hardware versions >= 2.00 + */ +#define AA_I2C_PULLUP_NONE 0x00 +#define AA_I2C_PULLUP_BOTH 0x03 +#define AA_I2C_PULLUP_QUERY 0x80 +int aa_i2c_pullup ( + Aardvark aardvark, + u08 pullup_mask +); + + + +/*========================================================================= +| SPI API + ========================================================================*/ +/* + * Set the SPI bit rate in kilohertz. If a zero is passed as the + * bitrate, the bitrate is unchanged and the current bitrate is + * returned. + */ +int aa_spi_bitrate ( + Aardvark aardvark, + int bitrate_khz +); + + +/* + * These configuration parameters specify how to clock the + * bits that are sent and received on the Aardvark SPI + * interface. + * + * The polarity option specifies which transition + * constitutes the leading edge and which transition is the + * falling edge. For example, AA_SPI_POL_RISING_FALLING + * would configure the SPI to idle the SCK clock line low. + * The clock would then transition low-to-high on the + * leading edge and high-to-low on the trailing edge. + * + * The phase option determines whether to sample or setup on + * the leading edge. For example, AA_SPI_PHASE_SAMPLE_SETUP + * would configure the SPI to sample on the leading edge and + * setup on the trailing edge. + * + * The bitorder option is used to indicate whether LSB or + * MSB is shifted first. + * + * See the diagrams in the Aardvark datasheet for + * more details. + */ +enum AardvarkSpiPolarity { + AA_SPI_POL_RISING_FALLING = 0, + AA_SPI_POL_FALLING_RISING = 1 +}; +#ifndef __cplusplus +typedef enum AardvarkSpiPolarity AardvarkSpiPolarity; +#endif + +enum AardvarkSpiPhase { + AA_SPI_PHASE_SAMPLE_SETUP = 0, + AA_SPI_PHASE_SETUP_SAMPLE = 1 +}; +#ifndef __cplusplus +typedef enum AardvarkSpiPhase AardvarkSpiPhase; +#endif + +enum AardvarkSpiBitorder { + AA_SPI_BITORDER_MSB = 0, + AA_SPI_BITORDER_LSB = 1 +}; +#ifndef __cplusplus +typedef enum AardvarkSpiBitorder AardvarkSpiBitorder; +#endif + +/* Configure the SPI master or slave interface */ +int aa_spi_configure ( + Aardvark aardvark, + AardvarkSpiPolarity polarity, + AardvarkSpiPhase phase, + AardvarkSpiBitorder bitorder +); + + +/* Write a stream of bytes to the downstream SPI slave device. */ +int aa_spi_write ( + Aardvark aardvark, + u16 out_num_bytes, + const u08 * data_out, + u16 in_num_bytes, + u08 * data_in +); + + +/* Enable/Disable the Aardvark as an SPI slave device */ +int aa_spi_slave_enable ( + Aardvark aardvark +); + + +int aa_spi_slave_disable ( + Aardvark aardvark +); + + +/* + * Set the slave response in the event the Aardvark is put + * into slave mode and contacted by a Master. + */ +int aa_spi_slave_set_response ( + Aardvark aardvark, + u08 num_bytes, + const u08 * data_out +); + + +/* Read the bytes from an SPI slave reception */ +int aa_spi_slave_read ( + Aardvark aardvark, + u16 num_bytes, + u08 * data_in +); + + +/* + * Change the output polarity on the SS line. + * + * Note: When configured as an SPI slave, the Aardvark will + * always be setup with SS as active low. Hence this function + * only affects the SPI master functions on the Aardvark. + */ +enum AardvarkSpiSSPolarity { + AA_SPI_SS_ACTIVE_LOW = 0, + AA_SPI_SS_ACTIVE_HIGH = 1 +}; +#ifndef __cplusplus +typedef enum AardvarkSpiSSPolarity AardvarkSpiSSPolarity; +#endif + +int aa_spi_master_ss_polarity ( + Aardvark aardvark, + AardvarkSpiSSPolarity polarity +); + + + +/*========================================================================= +| GPIO API + ========================================================================*/ +/* + * The following enumerated type maps the named lines on the + * Aardvark I2C/SPI line to bit positions in the GPIO API. + * All GPIO API functions will index these lines through an + * 8-bit masked value. Thus, each bit position in the mask + * can be referred back its corresponding line through the + * enumerated type. + */ +enum AardvarkGpioBits { + AA_GPIO_SCL = 0x01, + AA_GPIO_SDA = 0x02, + AA_GPIO_MISO = 0x04, + AA_GPIO_SCK = 0x08, + AA_GPIO_MOSI = 0x10, + AA_GPIO_SS = 0x20 +}; +#ifndef __cplusplus +typedef enum AardvarkGpioBits AardvarkGpioBits; +#endif + +/* + * Configure the GPIO, specifying the direction of each bit. + * + * A call to this function will not change the value of the pullup + * mask in the Aardvark. This is illustrated by the following + * example: + * (1) Direction mask is first set to 0x00 + * (2) Pullup is set to 0x01 + * (3) Direction mask is set to 0x01 + * (4) Direction mask is later set back to 0x00. + * + * The pullup will be active after (4). + * + * On Aardvark power-up, the default value of the direction + * mask is 0x00. + */ +#define AA_GPIO_DIR_INPUT 0 +#define AA_GPIO_DIR_OUTPUT 1 +int aa_gpio_direction ( + Aardvark aardvark, + u08 direction_mask +); + + +/* + * Enable an internal pullup on any of the GPIO input lines. + * + * Note: If a line is configured as an output, the pullup bit + * for that line will be ignored, though that pullup bit will + * be cached in case the line is later configured as an input. + * + * By default the pullup mask is 0x00. + */ +#define AA_GPIO_PULLUP_OFF 0 +#define AA_GPIO_PULLUP_ON 1 +int aa_gpio_pullup ( + Aardvark aardvark, + u08 pullup_mask +); + + +/* + * Read the current digital values on the GPIO input lines. + * + * The bits will be ordered as described by AA_GPIO_BITS. If a + * line is configured as an output, its corresponding bit + * position in the mask will be undefined. + */ +int aa_gpio_get ( + Aardvark aardvark +); + + +/* + * Set the outputs on the GPIO lines. + * + * Note: If a line is configured as an input, it will not be + * affected by this call, but the output value for that line + * will be cached in the event that the line is later + * configured as an output. + */ +int aa_gpio_set ( + Aardvark aardvark, + u08 value +); + + +/* + * Block until there is a change on the GPIO input lines. + * Pins configured as outputs will be ignored. + * + * The function will return either when a change has occurred or + * the timeout expires. The timeout, specified in millisecods, has + * a precision of ~16 ms. The maximum allowable timeout is + * approximately 4 seconds. If the timeout expires, this function + * will return the current state of the GPIO lines. + * + * This function will return immediately with the current value + * of the GPIO lines for the first invocation after any of the + * following functions are called: aa_configure, + * aa_gpio_direction, or aa_gpio_pullup. + * + * If the function aa_gpio_get is called before calling + * aa_gpio_change, aa_gpio_change will only register any changes + * from the value last returned by aa_gpio_get. + */ +int aa_gpio_change ( + Aardvark aardvark, + u16 timeout +); + + + + +#ifdef __cplusplus +} +#endif + +#endif /* __aardvark_h__ */ diff --git a/crates/aardvark-sys/vendor/aardvark.so b/crates/aardvark-sys/vendor/aardvark.so new file mode 100644 index 0000000000..be2f67ba07 Binary files /dev/null and b/crates/aardvark-sys/vendor/aardvark.so differ diff --git a/docs/aardvark-integration.md b/docs/aardvark-integration.md new file mode 100644 index 0000000000..10e91bff1d --- /dev/null +++ b/docs/aardvark-integration.md @@ -0,0 +1,325 @@ +# Aardvark Integration — How It Works + +A plain-language walkthrough of every piece and how they connect. + +--- + +## The Big Picture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ STARTUP (boot) │ +│ │ +│ 1. Ask aardvark-sys: "any adapters plugged in?" │ +│ 2. For each one found → register a device + transport │ +│ 3. Load tools only if hardware was found │ +└──────────────────────────────────────────┬───────────────────┘ + │ + ┌──────────────────────▼──────────────────────┐ + │ RUNTIME (agent loop) │ + │ │ + │ User: "scan i2c bus" │ + │ → agent calls i2c_scan tool │ + │ → tool builds a ZcCommand │ + │ → AardvarkTransport sends to hardware │ + │ → response flows back as text │ + └──────────────────────────────────────────────┘ +``` + +--- + +## Layer by Layer + +### Layer 1 — `aardvark-sys` (the USB talker) + +**File:** `crates/aardvark-sys/src/lib.rs` + +This is the only layer that ever touches the raw C library. +Think of it as a thin translator: it turns C function calls into safe Rust. + +**Algorithm:** + +``` +find_devices() + → call aa_find_devices(16, buf) // ask C lib how many adapters + → return Vec of port numbers // [0, 1, ...] one per adapter + +open_port(port) + → call aa_open(port) // open that specific adapter + → if handle ≤ 0, return OpenFailed + → else return AardvarkHandle{ _port: handle } + +i2c_scan(handle) + → for addr in 0x08..=0x77 // every valid 7-bit address + try aa_i2c_read(addr, 1 byte) // knock on the door + if ACK → add to list // device answered + → return list of live addresses + +i2c_read(handle, addr, len) + → aa_i2c_read(addr, len bytes) + → return bytes as Vec + +i2c_write(handle, addr, data) + → aa_i2c_write(addr, data) + +spi_transfer(handle, bytes_to_send) + → aa_spi_write(bytes) // full-duplex: sends + receives + → return received bytes + +gpio_set(handle, direction, value) + → aa_gpio_direction(direction) // which pins are outputs + → aa_gpio_put(value) // set output levels + +gpio_get(handle) + → aa_gpio_get() // read all pin levels as bitmask + +Drop(handle) + → aa_close(handle._port) // always close on drop +``` + +**In stub mode** (no SDK): every method returns `Err(NotFound)` immediately. `find_devices()` returns `[]`. Nothing crashes. + +--- + +### Layer 2 — `AardvarkTransport` (the bridge) + +**File:** `src/hardware/aardvark.rs` + +The rest of ZeroClaw speaks a single language: `ZcCommand` → `ZcResponse`. +`AardvarkTransport` translates between that protocol and the aardvark-sys calls above. + +**Algorithm:** + +``` +send(ZcCommand) → ZcResponse + + extract command name from cmd.name + extract parameters from cmd.params (serde_json values) + + match cmd.name: + + "i2c_scan" → open handle → call i2c_scan() + → format found addresses as hex list + → return ZcResponse{ output: "0x48, 0x68" } + + "i2c_read" → parse addr (hex string) + len (number) + → open handle → i2c_enable(bitrate) + → call i2c_read(addr, len) + → format bytes as hex + → return ZcResponse{ output: "0xAB 0xCD" } + + "i2c_write" → parse addr + data bytes + → open handle → i2c_write(addr, data) + → return ZcResponse{ output: "ok" } + + "spi_transfer" → parse bytes_hex string → decode to Vec + → open handle → spi_enable(bitrate) + → spi_transfer(bytes) + → return received bytes as hex + + "gpio_set" → parse direction + value bitmasks + → open handle → gpio_set(dir, val) + → return ZcResponse{ output: "ok" } + + "gpio_get" → open handle → gpio_get() + → return bitmask value as string + + on any AardvarkError → return ZcResponse{ error: "..." } +``` + +**Key design choice — lazy open:** The handle is opened fresh for every command and dropped at the end. This means no held connection, no state to clean up, and no "is it still open?" logic anywhere. + +--- + +### Layer 3 — Tools (what the agent calls) + +**File:** `src/hardware/aardvark_tools.rs` + +Each tool is a thin wrapper. It: +1. Validates the agent's JSON input +2. Resolves which physical device to use +3. Builds a `ZcCommand` +4. Calls `AardvarkTransport.send()` +5. Returns the result as text + +``` +I2cScanTool.call(args) + → look up "device" in args (default: "aardvark0") + → find that device in the registry + → build ZcCommand{ name: "i2c_scan", params: {} } + → send to AardvarkTransport + → return "Found: 0x48, 0x68" (or "No devices found") + +I2cReadTool.call(args) + → require args["addr"] and args["len"] + → build ZcCommand{ name: "i2c_read", params: {addr, len} } + → send → return hex bytes + +I2cWriteTool.call(args) + → require args["addr"] and args["data"] (hex or array) + → build ZcCommand{ name: "i2c_write", params: {addr, data} } + → send → return "ok" or error + +SpiTransferTool.call(args) + → require args["bytes"] (hex string) + → build ZcCommand{ name: "spi_transfer", params: {bytes} } + → send → return received bytes + +GpioAardvarkTool.call(args) + → require args["direction"] + args["value"] (set) + OR no extra args (get) + → build appropriate ZcCommand + → send → return result + +DatasheetTool.call(args) + → action = args["action"]: "search" | "download" | "list" | "read" + → "search": return a Google/vendor search URL for the device + → "download": fetch PDF from args["url"] → save to ~/.zeroclaw/hardware/datasheets/ + → "list": scan the datasheets directory → return filenames + → "read": open a saved PDF and return its text +``` + +--- + +### Layer 4 — Device Registry (the address book) + +**File:** `src/hardware/device.rs` + +The registry is a runtime map of every connected device. +Each entry stores: alias, kind, capabilities, transport handle. + +``` +register("aardvark", vid=0x2b76, ...) + → DeviceKind::from_vid(0x2b76) → DeviceKind::Aardvark + → DeviceRuntime::from_kind() → DeviceRuntime::Aardvark + → assign alias "aardvark0" (then "aardvark1" for second, etc.) + → store entry in HashMap + +attach_transport("aardvark0", AardvarkTransport, capabilities{i2c,spi,gpio}) + → store Arc in the entry + +has_aardvark() + → any entry where kind == Aardvark → true / false + +resolve_aardvark_device(args) + → read "device" param (default: "aardvark0") + → look up alias in HashMap + → return (alias, DeviceContext{ transport, capabilities }) +``` + +--- + +### Layer 5 — `boot()` (startup wiring) + +**File:** `src/hardware/mod.rs` + +`boot()` runs once at startup. For Aardvark: + +``` +boot() + ... + aardvark_ports = aardvark_sys::AardvarkHandle::find_devices() + // → [] in stub mode, [0] if one adapter is plugged in + + for (i, port) in aardvark_ports: + alias = registry.register("aardvark", vid=0x2b76, ...) + // → "aardvark0", "aardvark1", ... + + transport = AardvarkTransport::new(port, bitrate=100kHz) + registry.attach_transport(alias, transport, {i2c:true, spi:true, gpio:true}) + + log "[registry] aardvark0 ready → Total Phase port 0" + ... +``` + +--- + +### Layer 6 — Tool Registry (the loader) + +**File:** `src/hardware/tool_registry.rs` + +After `boot()`, the tool registry checks what hardware is present and loads +only the relevant tools: + +``` +ToolRegistry::load(devices) + + # always loaded (Pico / GPIO) + register: gpio_write, gpio_read, gpio_toggle, pico_flash, device_list, device_status + + # only loaded if an Aardvark was found at boot + if devices.has_aardvark(): + register: i2c_scan, i2c_read, i2c_write, spi_transfer, gpio_aardvark, datasheet +``` + +This is why the `hardware_feature_registers_all_six_tools` test still passes in stub mode — `has_aardvark()` returns false, 0 extra tools load, count stays at 6. + +--- + +## Full Flow Diagram + +``` + SDK FILES aardvark-sys ZeroClaw core + (vendor/) (crates/) (src/) +───────────────────────────────────────────────────────────────── + + aardvark.h ──► build.rs boot() + aardvark.so (bindgen) ──► find_devices() + │ │ + bindings.rs │ vec![0] (one adapter) + │ ▼ + lib.rs register("aardvark0") + AardvarkHandle attach_transport(AardvarkTransport) + │ │ + │ ▼ + │ ToolRegistry::load() + │ has_aardvark() == true + │ → load 6 aardvark tools + │ +───────────────────────────────────────────────────────────────── + + USER MESSAGE: "scan the i2c bus" + + agent loop + │ + ▼ + I2cScanTool.call() + │ + ▼ + resolve_aardvark_device("aardvark0") + │ returns transport Arc + ▼ + AardvarkTransport.send(ZcCommand{ name: "i2c_scan" }) + │ + ▼ + AardvarkHandle::open_port(0) ← opens USB connection + │ + ▼ + aa_i2c_read(0x08..0x77) ← probes each address + │ + ▼ + AardvarkHandle dropped ← USB connection closed + │ + ▼ + ZcResponse{ output: "Found: 0x48, 0x68" } + │ + ▼ + agent sends reply to user: "I found two I2C devices: 0x48 and 0x68" +``` + +--- + +## Stub vs Real Side by Side + +| | Stub mode (now) | Real hardware | +|---|---|---| +| `find_devices()` | returns `[]` | returns `[0]` | +| `open_port(0)` | `Err(NotFound)` | opens USB, returns handle | +| `i2c_scan()` | `[]` | probes bus, returns addresses | +| tools loaded | only the 6 Pico tools | 6 Pico + 6 Aardvark tools | +| `has_aardvark()` | `false` | `true` | +| SDK needed | no | yes (`vendor/aardvark.h` + `.so`) | + +The only code that changes when you plug in real hardware is inside +`crates/aardvark-sys/src/lib.rs` — every other layer is already wired up +and waiting. diff --git a/examples/hardware/aardvark/HARDWARE_aardvark_snippet.md b/examples/hardware/aardvark/HARDWARE_aardvark_snippet.md new file mode 100644 index 0000000000..8934c929ef --- /dev/null +++ b/examples/hardware/aardvark/HARDWARE_aardvark_snippet.md @@ -0,0 +1,34 @@ +## Aardvark Adapter (aardvark0) + +- Protocol: I2C and SPI via Total Phase Aardvark USB +- Bitrate: 100 kHz (standard-mode I2C) by default +- Use `i2c_scan` first to discover connected devices +- Use `i2c_read` / `i2c_write` for register operations +- Use `spi_transfer` for full-duplex SPI +- Use `gpio_aardvark` to control the Aardvark's GPIO expansion pins +- Use `datasheet` tool when user identifies a new device + +## Tool Selection — Aardvark + +| Goal | Tool | +|--------------------------------|-----------------| +| Find devices on the I2C bus | `i2c_scan` | +| Read a register | `i2c_read` | +| Write a register | `i2c_write` | +| Full-duplex SPI transfer | `spi_transfer` | +| Control Aardvark GPIO pins | `gpio_aardvark` | +| User names a new device | `datasheet` | + +## I2C Workflow + +1. Run `i2c_scan` — find what addresses respond. +2. User identifies the device (or look up the address in the skill file). +3. Read the relevant register with `i2c_read`. +4. If datasheet is not yet cached, use `datasheet(action="search", device_name="...")`. + +## Notes + +- Aardvark has no firmware — it calls the C library directly. + Do NOT use `device_exec`, `device_read_code`, or `device_write_code` for Aardvark. +- The Aardvark adapter auto-enables I2C pull-ups (3.3 V) — no external resistors needed + for most sensors. diff --git a/examples/hardware/aardvark/devices/aardvark0.md.example b/examples/hardware/aardvark/devices/aardvark0.md.example new file mode 100644 index 0000000000..73f82b8824 --- /dev/null +++ b/examples/hardware/aardvark/devices/aardvark0.md.example @@ -0,0 +1,41 @@ +# aardvark0 — () + + + + +## Connection + +- Adapter: Total Phase Aardvark (aardvark0) +- Protocol: I2C +- I2C Address: 0x48 +- Bitrate: 100 kHz + +## Key Registers (from datasheet) + + +| Register | Address | Description | Notes | +|----------|---------|----------------------------------------|------------------------| +| Temp | 0x00 | Temperature (2 bytes, big-endian) | MSB × 0.5 °C per LSB | +| Config | 0x01 | Configuration register | Read/write | +| Thyst | 0x02 | Hysteresis temperature | Read/write | +| Tos | 0x03 | Overtemperature shutdown threshold | Read/write | + +## Datasheet + +- File: `~/.zeroclaw/hardware/datasheets/.pdf` +- Source: + +## Verified Working Commands + +```python +# Read temperature from LM75 at I2C address 0x48, register 0x00 +i2c_read(addr=0x48, register=0x00, len=2) + +# Convert two bytes to °C: +# raw = (byte[0] << 1) | (byte[1] >> 7) +# temp = raw * 0.5 (if byte[0] bit 7 is 1, it's negative: raw - 256) +``` + +## Notes + + diff --git a/examples/hardware/aardvark/skills/i2c.md b/examples/hardware/aardvark/skills/i2c.md new file mode 100644 index 0000000000..eec7845230 --- /dev/null +++ b/examples/hardware/aardvark/skills/i2c.md @@ -0,0 +1,63 @@ +# Skill: I2C Operations via Aardvark + + + +## Always scan first + +If the I2C address is unknown, run `i2c_scan` before anything else. + +## Common device addresses + +| Address range | Typical devices | +|---------------|-----------------------------------------------| +| 0x08–0x0F | Reserved / rare | +| 0x40–0x4F | LM75, TMP102, HTU21D (temp/humidity) | +| 0x48–0x4F | LM75, DS1621, ADS1115 (ADC) | +| 0x50–0x57 | AT24Cxx EEPROM | +| 0x68–0x6F | MPU6050 IMU, DS1307 / DS3231 RTC | +| 0x76–0x77 | BME280, BMP280 (pressure + humidity) | +| 0x42 | Common PSoC6 default | +| 0x3C, 0x3D | SSD1306 OLED display | + +## Reading a register + +```text +i2c_read(addr=0x48, register=0x00, len=2) +``` + +## Writing a register + +```text +i2c_write(addr=0x48, bytes=[0x01, 0x60]) +``` + +## Write-then-read (register pointer pattern) + +Some devices require you to first write the register address, then read separately: + +```text +i2c_write(addr=0x48, bytes=[0x00]) +i2c_read(addr=0x48, len=2) +``` + +The `i2c_read` tool handles this automatically when you specify `register=`. + +## Temperature conversion — LM75 / TMP102 + +Raw bytes from register 0x00 are big-endian, 9-bit or 11-bit: + +``` +raw = (byte[0] << 1) | (byte[1] >> 7) # for LM75 (9-bit) +if raw >= 256: raw -= 512 # handle negative (two's complement) +temp_c = raw * 0.5 +``` + +## Decision table — Aardvark vs Pico tools + +| Scenario | Use | +|------------------------------------------------|---------------| +| Talking to an I2C sensor via Aardvark | `i2c_read` | +| Configuring a sensor register | `i2c_write` | +| Discovering what's on the bus | `i2c_scan` | +| Running MicroPython on the connected Pico | `device_exec` | +| Blinking Pico LED | `device_exec` | diff --git a/firmware/nucleo/Cargo.lock b/firmware/nucleo/Cargo.lock index 17a7e2e14b..7b22b36ef4 100644 --- a/firmware/nucleo/Cargo.lock +++ b/firmware/nucleo/Cargo.lock @@ -88,6 +88,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" dependencies = [ "bare-metal", "bitfield", + "critical-section", "embedded-hal 0.2.7", "volatile-register", ] @@ -837,6 +838,7 @@ dependencies = [ name = "nucleo" version = "0.1.0" dependencies = [ + "cortex-m", "cortex-m-rt", "critical-section", "defmt 1.0.1", diff --git a/firmware/nucleo/Cargo.toml b/firmware/nucleo/Cargo.toml index dd4190aab1..96c4217b16 100644 --- a/firmware/nucleo/Cargo.toml +++ b/firmware/nucleo/Cargo.toml @@ -7,6 +7,8 @@ # Flash: probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/nucleo # Or: zeroclaw peripheral flash-nucleo +[workspace] + [package] name = "nucleo" version = "0.1.0" @@ -18,12 +20,13 @@ description = "ZeroClaw Nucleo-F401RE peripheral firmware — GPIO over JSON ser embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread", "defmt"] } embassy-stm32 = { version = "0.5", features = ["defmt", "stm32f401re", "unstable-pac", "memory-x", "time-driver-tim4", "exti"] } embassy-time = { version = "0.5", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] } +cortex-m = { version = "0.7", features = ["inline-asm", "critical-section-single-core"] } +cortex-m-rt = "0.7" defmt = "1.0" defmt-rtt = "1.0" panic-probe = { version = "1.0", features = ["print-defmt"] } heapless = { version = "0.9", default-features = false } critical-section = "1.1" -cortex-m-rt = "0.7" [package.metadata.embassy] build = [ @@ -34,6 +37,5 @@ build = [ opt-level = "s" lto = true codegen-units = 1 -strip = true panic = "abort" -debug = 1 +debug = 2 diff --git a/firmware/zeroclaw-nucleo/.cargo/config.toml b/firmware/zeroclaw-nucleo/.cargo/config.toml new file mode 100644 index 0000000000..71238472a4 --- /dev/null +++ b/firmware/zeroclaw-nucleo/.cargo/config.toml @@ -0,0 +1,3 @@ +[target.thumbv7em-none-eabihf] +rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"] +runner = "probe-rs run --chip STM32F401RETx" diff --git a/scripts/99-act-led.rules b/scripts/99-act-led.rules new file mode 100644 index 0000000000..b113a84180 --- /dev/null +++ b/scripts/99-act-led.rules @@ -0,0 +1,10 @@ +# Allow the gpio group to control the Raspberry Pi onboard ACT LED +# via the Linux LED subsystem sysfs interface. +# +# Without this rule /sys/class/leds/ACT/{brightness,trigger} are +# root-only writable, which prevents zeroclaw from blinking the LED. +SUBSYSTEM=="leds", KERNEL=="ACT", ACTION=="add", \ + RUN+="/bin/chgrp gpio /sys/%p/brightness", \ + RUN+="/bin/chmod g+w /sys/%p/brightness", \ + RUN+="/bin/chgrp gpio /sys/%p/trigger", \ + RUN+="/bin/chmod g+w /sys/%p/trigger" diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000000..bdc49bf03e --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,232 @@ +# scripts/ — Raspberry Pi Deployment Guide + +This directory contains everything needed to cross-compile ZeroClaw and deploy it to a Raspberry Pi over SSH. + +## Contents + +| File | Purpose | +|------|---------| +| `deploy-rpi.sh` | One-shot cross-compile and deploy script | +| `rpi-config.toml` | Production config template deployed to `~/.zeroclaw/config.toml` | +| `zeroclaw.service` | systemd unit file installed on the Pi | +| `99-act-led.rules` | udev rule for ACT LED sysfs access without sudo | + +--- + +## Prerequisites + +### Cross-compilation toolchain (pick one) + +**Option A — cargo-zigbuild (recommended for Apple Silicon)** + +```bash +brew install zig +cargo install cargo-zigbuild +rustup target add aarch64-unknown-linux-gnu +``` + +**Option B — cross (Docker-based)** + +```bash +cargo install cross +rustup target add aarch64-unknown-linux-gnu +# Docker must be running +``` + +The deploy script auto-detects which tool is available, preferring `cargo-zigbuild`. +Force a specific tool with `CROSS_TOOL=zigbuild` or `CROSS_TOOL=cross`. + +### Optional: passwordless SSH + +If you can't use SSH key authentication, install `sshpass` and set the `RPI_PASS` environment variable: + +```bash +brew install sshpass # macOS +sudo apt install sshpass # Linux +``` + +--- + +## Quick Start + +```bash +RPI_HOST=raspberrypi.local RPI_USER=pi ./scripts/deploy-rpi.sh +``` + +After the first deploy, you must set your API key on the Pi (see [First-Time Setup](#first-time-setup)). + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `RPI_HOST` | `raspberrypi.local` | Pi hostname or IP address | +| `RPI_USER` | `pi` | SSH username | +| `RPI_PORT` | `22` | SSH port | +| `RPI_DIR` | `~/zeroclaw` | Remote directory for the binary and `.env` | +| `RPI_PASS` | _(unset)_ | SSH password — uses `sshpass` if set; key auth used otherwise | +| `CROSS_TOOL` | _(auto-detect)_ | Force `zigbuild` or `cross` | + +--- + +## What the Deploy Script Does + +1. **Cross-compile** — builds a release binary for `aarch64-unknown-linux-gnu` with `--features hardware,peripheral-rpi`. +2. **Stop service** — runs `sudo systemctl stop zeroclaw` on the Pi (continues if not yet installed). +3. **Create remote directory** — ensures `$RPI_DIR` exists on the Pi. +4. **Copy binary** — SCPs the compiled binary to `$RPI_DIR/zeroclaw`. +5. **Create `.env`** — writes an `.env` skeleton with an `ANTHROPIC_API_KEY=` placeholder to `$RPI_DIR/.env` with mode `600`. Skipped if the file already exists so an existing key is not overwritten. +6. **Deploy config** — copies `rpi-config.toml` to `~/.zeroclaw/config.toml`, preserving any `api_key` already present in the file. +7. **Install systemd service** — copies `zeroclaw.service` to `/etc/systemd/system/`, then enables and restarts it. +8. **Hardware permissions** — adds the deploy user to the `gpio` group, copies `99-act-led.rules` to `/etc/udev/rules.d/`, and resets the ACT LED trigger. + +--- + +## First-Time Setup + +After the first successful deploy, SSH into the Pi and fill in your API key: + +```bash +ssh pi@raspberrypi.local +nano ~/zeroclaw/.env +# Set: ANTHROPIC_API_KEY=sk-ant-... +sudo systemctl restart zeroclaw +``` + +The `.env` is loaded by the systemd service as an `EnvironmentFile`. + +--- + +## Interacting with ZeroClaw on the Pi + +Once the service is running the gateway listens on port **8080**. + +### Health check + +```bash +curl http://raspberrypi.local:8080/health +``` + +### Send a message + +```bash +curl -s -X POST http://raspberrypi.local:8080/api/chat \ + -H 'Content-Type: application/json' \ + -d '{"message": "What is the CPU temperature?"}' | jq . +``` + +### Stream a conversation + +```bash +curl -N -s -X POST http://raspberrypi.local:8080/api/chat \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{"message": "List connected hardware devices", "stream": true}' +``` + +### Follow service logs + +```bash +ssh pi@raspberrypi.local 'journalctl -u zeroclaw -f' +``` + +--- + +## Hardware Features + +### GPIO tools + +ZeroClaw is deployed with the `peripheral-rpi` feature, which enables two LLM-callable tools: + +- **`gpio_read`** — reads a GPIO pin value via sysfs (`/sys/class/gpio/...`). +- **`gpio_write`** — writes a GPIO pin value. + +These tools let the agent directly control hardware in response to natural-language instructions. + +### ACT LED + +The udev rule `99-act-led.rules` grants the `gpio` group write access to: + +``` +/sys/class/leds/ACT/trigger +/sys/class/leds/ACT/brightness +``` + +This allows toggling the Pi's green ACT LED without `sudo`. + +### Aardvark I2C/SPI adapter + +If a Total Phase Aardvark adapter is connected, the `hardware` feature enables I2C/SPI communication with external devices. No extra setup is needed — the device is auto-detected via USB. + +--- + +## Files Deployed to the Pi + +| Remote path | Source | Description | +|------------|--------|-------------| +| `~/zeroclaw/zeroclaw` | compiled binary | Main agent binary | +| `~/zeroclaw/.env` | created on first deploy | API key and environment variables | +| `~/.zeroclaw/config.toml` | `rpi-config.toml` | Agent configuration | +| `/etc/systemd/system/zeroclaw.service` | `zeroclaw.service` | systemd service unit | +| `/etc/udev/rules.d/99-act-led.rules` | `99-act-led.rules` | ACT LED permissions | + +--- + +## Configuration + +`rpi-config.toml` is the production config template. Key defaults: + +- **Provider**: `anthropic-custom:https://api.z.ai/api/anthropic` +- **Model**: `claude-3-5-sonnet-20241022` +- **Autonomy**: `full` +- **Allowed shell commands**: `git`, `cargo`, `npm`, `mkdir`, `touch`, `cp`, `mv`, `ls`, `cat`, `grep`, `find`, `echo`, `pwd`, `wc`, `head`, `tail`, `date` + +To customise, edit `~/.zeroclaw/config.toml` directly on the Pi and restart the service. + +--- + +## Troubleshooting + +### Service won't start + +```bash +ssh pi@raspberrypi.local 'sudo systemctl status zeroclaw' +ssh pi@raspberrypi.local 'journalctl -u zeroclaw -n 50 --no-pager' +``` + +### GPIO permission denied + +Make sure the deploy user is in the `gpio` group and that a fresh login session has been started: + +```bash +ssh pi@raspberrypi.local 'groups' +# Should include: gpio +``` + +If the group was just added, log out and back in, or run `newgrp gpio`. + +### Wrong architecture / binary won't run + +Re-run the deploy script. Confirm the target: + +```bash +ssh pi@raspberrypi.local 'file ~/zeroclaw/zeroclaw' +# Expected: ELF 64-bit LSB pie executable, ARM aarch64 +``` + +### Force a specific cross-compilation tool + +```bash +CROSS_TOOL=zigbuild RPI_HOST=raspberrypi.local ./scripts/deploy-rpi.sh +# or +CROSS_TOOL=cross RPI_HOST=raspberrypi.local ./scripts/deploy-rpi.sh +``` + +### Rebuild locally without deploying + +```bash +cargo zigbuild --release \ + --target aarch64-unknown-linux-gnu \ + --features hardware,peripheral-rpi +``` diff --git a/scripts/deploy-rpi.sh b/scripts/deploy-rpi.sh new file mode 100755 index 0000000000..2c97a4d1f1 --- /dev/null +++ b/scripts/deploy-rpi.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# deploy-rpi.sh — cross-compile ZeroClaw for Raspberry Pi and deploy via SSH. +# +# Cross-compilation (pick ONE — the script auto-detects): +# +# Option A — cargo-zigbuild (recommended; works on Apple Silicon + Intel, no Docker) +# brew install zig +# cargo install cargo-zigbuild +# rustup target add aarch64-unknown-linux-gnu +# +# Option B — cross (Docker-based; requires Docker Desktop running) +# cargo install cross +# +# Usage: +# RPI_HOST=raspberrypi.local RPI_USER=pi ./scripts/deploy-rpi.sh +# +# Optional env vars: +# RPI_HOST — hostname or IP of the Pi (default: raspberrypi.local) +# RPI_USER — SSH user on the Pi (default: pi) +# RPI_PORT — SSH port (default: 22) +# RPI_DIR — remote deployment dir (default: /home/$RPI_USER/zeroclaw) +# RPI_PASS — SSH password (uses sshpass) (default: prompt interactively) +# CROSS_TOOL — force "zigbuild" or "cross" (default: auto-detect) + +set -euo pipefail + +RPI_HOST="${RPI_HOST:-raspberrypi.local}" +RPI_USER="${RPI_USER:-pi}" +RPI_PORT="${RPI_PORT:-22}" +RPI_DIR="${RPI_DIR:-/home/${RPI_USER}/zeroclaw}" +TARGET="aarch64-unknown-linux-gnu" +FEATURES="hardware,peripheral-rpi" +BINARY="target/${TARGET}/release/zeroclaw" +SSH_OPTS="-p ${RPI_PORT} -o StrictHostKeyChecking=no -o ConnectTimeout=10" +# scp uses -P (uppercase) for port; ssh uses -p (lowercase) +SCP_OPTS="-P ${RPI_PORT} -o StrictHostKeyChecking=no -o ConnectTimeout=10" + +# If RPI_PASS is set, wrap ssh/scp with sshpass for non-interactive auth. +SSH_CMD="ssh" +SCP_CMD="scp" +if [[ -n "${RPI_PASS:-}" ]]; then + if ! command -v sshpass &>/dev/null; then + echo "ERROR: RPI_PASS is set but sshpass is not installed." + echo " brew install hudochenkov/sshpass/sshpass" + exit 1 + fi + SSH_CMD="sshpass -p ${RPI_PASS} ssh" + SCP_CMD="sshpass -p ${RPI_PASS} scp" +fi + +echo "==> Building ZeroClaw for Raspberry Pi (${TARGET})" +echo " Features: ${FEATURES}" +echo " Target host: ${RPI_USER}@${RPI_HOST}:${RPI_PORT}" +echo "" + +# ── 1. Cross-compile — auto-detect best available tool ─────────────────────── +# Prefer cargo-zigbuild: it works on Apple Silicon without Docker and avoids +# the rustup-toolchain-install errors that affect cross v0.2.x on arm64 Macs. +_detect_cross_tool() { + if [[ "${CROSS_TOOL:-}" == "cross" ]]; then + echo "cross"; return + fi + if [[ "${CROSS_TOOL:-}" == "zigbuild" ]]; then + echo "zigbuild"; return + fi + if command -v cargo-zigbuild &>/dev/null && command -v zig &>/dev/null; then + echo "zigbuild"; return + fi + if command -v cross &>/dev/null; then + echo "cross"; return + fi + echo "none" +} + +TOOL=$(_detect_cross_tool) + +case "${TOOL}" in + zigbuild) + echo "==> Using cargo-zigbuild (Zig cross-linker)" + # Ensure the target sysroot is registered with rustup. + rustup target add "${TARGET}" 2>/dev/null || true + cargo zigbuild \ + --target "${TARGET}" \ + --features "${FEATURES}" \ + --release + ;; + cross) + echo "==> Using cross (Docker-based)" + # Verify Docker is running before handing off — gives a clear error message + # instead of the confusing rustup-toolchain failure from cross v0.2.x. + if ! docker info &>/dev/null; then + echo "" + echo "ERROR: Docker is not running." + echo " Start Docker Desktop and retry, or install cargo-zigbuild instead:" + echo " brew install zig && cargo install cargo-zigbuild" + echo " rustup target add ${TARGET}" + exit 1 + fi + cross build \ + --target "${TARGET}" \ + --features "${FEATURES}" \ + --release + ;; + none) + echo "" + echo "ERROR: No cross-compilation tool found." + echo "" + echo "Install one of the following and retry:" + echo "" + echo " Option A — cargo-zigbuild (recommended; works on Apple Silicon, no Docker):" + echo " brew install zig" + echo " cargo install cargo-zigbuild" + echo " rustup target add ${TARGET}" + echo "" + echo " Option B — cross (requires Docker Desktop running):" + echo " cargo install cross" + echo "" + exit 1 + ;; +esac + +echo "" +echo "==> Build complete: ${BINARY}" +ls -lh "${BINARY}" + +# ── 2. Stop running service (if any) so binary can be overwritten ───────────── +echo "" +echo "==> Stopping zeroclaw service (if running)" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "sudo systemctl stop zeroclaw 2>/dev/null || true" + +# ── 3. Create remote directory ──────────────────────────────────────────────── +echo "" +echo "==> Creating remote directory ${RPI_DIR}" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "mkdir -p ${RPI_DIR}" + +# ── 4. Deploy binary ────────────────────────────────────────────────────────── +echo "" +echo "==> Deploying binary to ${RPI_USER}@${RPI_HOST}:${RPI_DIR}/zeroclaw" +${SCP_CMD} ${SCP_OPTS} "${BINARY}" "${RPI_USER}@${RPI_HOST}:${RPI_DIR}/zeroclaw" + +# ── 4. Create .env skeleton (if it doesn't exist) ──────────────────────────── +ENV_DEST="${RPI_DIR}/.env" +echo "" +echo "==> Checking for ${ENV_DEST}" +# shellcheck disable=SC2029 +if ${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "[ -f ${ENV_DEST} ]"; then + echo " .env already exists — skipping" +else + echo " Creating .env skeleton with 600 permissions" + # shellcheck disable=SC2029 + ${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "mkdir -p ${RPI_DIR} && \ + printf '# Set your API key here\nANTHROPIC_API_KEY=sk-ant-\n' > ${ENV_DEST} && \ + chmod 600 ${ENV_DEST}" + echo " IMPORTANT: edit ${ENV_DEST} on the Pi and set ANTHROPIC_API_KEY" +fi + +# ── 5. Deploy config ───────────────────────────────────────────────────────── +CONFIG_DEST="/home/${RPI_USER}/.zeroclaw/config.toml" +echo "" +echo "==> Deploying config to ${CONFIG_DEST}" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "mkdir -p /home/${RPI_USER}/.zeroclaw" +# Preserve existing api_key from the remote config if present. +# shellcheck disable=SC2029 +EXISTING_API_KEY=$(${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "grep -m1 '^api_key' ${CONFIG_DEST} 2>/dev/null || true") +${SCP_CMD} ${SCP_OPTS} "scripts/rpi-config.toml" "${RPI_USER}@${RPI_HOST}:${CONFIG_DEST}" +if [[ -n "${EXISTING_API_KEY}" ]]; then + echo " Restoring existing api_key from previous config" + # shellcheck disable=SC2029 + ${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "sed -i 's|^# api_key = .*|${EXISTING_API_KEY}|' ${CONFIG_DEST}" +fi + +# ── 6. Deploy and enable systemd service ───────────────────────────────────── +SERVICE_DEST="/etc/systemd/system/zeroclaw.service" +echo "" +echo "==> Installing systemd service (requires sudo on the Pi)" +${SCP_CMD} ${SCP_OPTS} "scripts/zeroclaw.service" "${RPI_USER}@${RPI_HOST}:/tmp/zeroclaw.service" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "sudo mv /tmp/zeroclaw.service ${SERVICE_DEST} && \ + sudo systemctl daemon-reload && \ + sudo systemctl enable zeroclaw && \ + sudo systemctl restart zeroclaw && \ + sudo systemctl status zeroclaw --no-pager || true" + +# ── 7. Runtime permissions ─────────────────────────────────────────────────── +echo "" +echo "==> Granting ${RPI_USER} access to GPIO group" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "sudo usermod -aG gpio ${RPI_USER} || true" + +# ── 8. Reset ACT LED trigger so ZeroClaw can control it ────────────────────── +echo "" +echo "==> Installing udev rule for ACT LED sysfs access by gpio group" +${SCP_CMD} ${SCP_OPTS} "scripts/99-act-led.rules" "${RPI_USER}@${RPI_HOST}:/tmp/99-act-led.rules" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "sudo mv /tmp/99-act-led.rules /etc/udev/rules.d/99-act-led.rules && \ + sudo udevadm control --reload-rules && \ + sudo chgrp gpio /sys/class/leds/ACT/brightness /sys/class/leds/ACT/trigger 2>/dev/null || true && \ + sudo chmod g+w /sys/class/leds/ACT/brightness /sys/class/leds/ACT/trigger 2>/dev/null || true" + +echo "" +echo "==> Resetting ACT LED trigger (none)" +# shellcheck disable=SC2029 +${SSH_CMD} ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \ + "echo none | sudo tee /sys/class/leds/ACT/trigger > /dev/null 2>&1 || true" + +echo "" +echo "==> Deployment complete!" +echo "" +echo " ZeroClaw is running at http://${RPI_HOST}:8080" +echo " POST /api/chat — chat with the agent" +echo " GET /health — health check" +echo "" +echo " To check logs: ssh ${RPI_USER}@${RPI_HOST} 'journalctl -u zeroclaw -f'" diff --git a/scripts/rpi-config.toml b/scripts/rpi-config.toml new file mode 100644 index 0000000000..27fd010cc8 --- /dev/null +++ b/scripts/rpi-config.toml @@ -0,0 +1,631 @@ +# ZeroClaw — Raspberry Pi production configuration +# +# Copy this to ~/.zeroclaw/config.toml on the Pi. +# deploy-rpi.sh does this automatically. +# +# API key is loaded from ~/.zeroclaw/.env (EnvironmentFile in systemd). +# Set it there as: ANTHROPIC_API_KEY=your-key-here +# Or set api_key directly below (not recommended for version control). + +# api_key = "" +default_provider = "anthropic-custom:https://api.z.ai/api/anthropic" +default_model = "claude-3-5-sonnet-20241022" +default_temperature = 0.4 +model_routes = [] +embedding_routes = [] + +[model_providers] + +[provider] + +[observability] +backend = "none" +runtime_trace_mode = "none" +runtime_trace_path = "state/runtime-trace.jsonl" +runtime_trace_max_entries = 200 + +[autonomy] +level = "full" +workspace_only = false +allowed_commands = [ + "git", + "npm", + "cargo", + "mkdir", + "touch", + "cp", + "mv", + "ls", + "cat", + "grep", + "find", + "echo", + "pwd", + "wc", + "head", + "tail", + "date", +] +command_context_rules = [] +forbidden_paths = [ + "/etc", + "/root", + "/home", + "/usr", + "/bin", + "/sbin", + "/lib", + "/opt", + "/boot", + "/dev", + "/proc", + "/sys", + "/var", + "/tmp", + "/mnt", + "~/.ssh", + "~/.gnupg", + "~/.aws", + "~/.config", +] +max_actions_per_hour = 100 +max_cost_per_day_cents = 1000 +require_approval_for_medium_risk = true +block_high_risk_commands = true +shell_env_passthrough = [] +allow_sensitive_file_reads = false +allow_sensitive_file_writes = false +auto_approve = [ + "file_read", + "memory_recall", +] +always_ask = [] +allowed_roots = [] +non_cli_excluded_tools = [ + "shell", + "process", + "file_write", + "file_edit", + "git_operations", + "browser", + "browser_open", + "http_request", + "schedule", + "cron_add", + "cron_remove", + "cron_update", + "cron_run", + "memory_store", + "memory_forget", + "proxy_config", + "web_search_config", + "web_access_config", + "model_routing_config", + "channel_ack_config", + "pushover", + "composio", + "delegate", + "screenshot", + "image_info", +] +non_cli_approval_approvers = [] +non_cli_natural_language_approval_mode = "direct" + +[autonomy.non_cli_natural_language_approval_mode_by_channel] + +[security] +roles = [] + +[security.sandbox] +backend = "auto" +firejail_args = [] + +[security.resources] +max_memory_mb = 512 +max_cpu_time_seconds = 60 +max_subprocesses = 10 +memory_monitoring = true + +[security.audit] +enabled = true +log_path = "audit.log" +max_size_mb = 100 +sign_events = false + +[security.otp] +enabled = true +method = "totp" +token_ttl_secs = 30 +cache_valid_secs = 300 +gated_actions = [ + "shell", + "file_write", + "browser_open", + "browser", + "memory_forget", +] +gated_domains = [] +gated_domain_categories = [] +challenge_delivery = "dm" +challenge_timeout_secs = 120 +challenge_max_attempts = 3 + +[security.estop] +enabled = false +state_file = "~/.zeroclaw/estop-state.json" +require_otp_to_resume = true + +[security.syscall_anomaly] +enabled = true +strict_mode = false +alert_on_unknown_syscall = true +max_denied_events_per_minute = 5 +max_total_events_per_minute = 120 +max_alerts_per_minute = 30 +alert_cooldown_secs = 20 +log_path = "syscall-anomalies.log" +baseline_syscalls = [ + "read", + "write", + "open", + "openat", + "close", + "stat", + "fstat", + "newfstatat", + "lseek", + "mmap", + "mprotect", + "munmap", + "brk", + "rt_sigaction", + "rt_sigprocmask", + "ioctl", + "fcntl", + "access", + "pipe2", + "dup", + "dup2", + "dup3", + "epoll_create1", + "epoll_ctl", + "epoll_wait", + "poll", + "ppoll", + "select", + "futex", + "clock_gettime", + "nanosleep", + "getpid", + "gettid", + "set_tid_address", + "set_robust_list", + "clone", + "clone3", + "fork", + "execve", + "wait4", + "exit", + "exit_group", + "socket", + "connect", + "accept", + "accept4", + "listen", + "sendto", + "recvfrom", + "sendmsg", + "recvmsg", + "getsockname", + "getpeername", + "setsockopt", + "getsockopt", + "getrandom", + "statx", +] + +[security.perplexity_filter] +enable_perplexity_filter = false +perplexity_threshold = 18.0 +suffix_window_chars = 64 +min_prompt_chars = 32 +symbol_ratio_threshold = 0.2 + +[security.outbound_leak_guard] +enabled = true +action = "redact" +sensitivity = 0.7 + +[security.url_access] +block_private_ip = true +allow_cidrs = [] +allow_domains = [] +allow_loopback = false +require_first_visit_approval = false +enforce_domain_allowlist = false +domain_allowlist = [] +domain_blocklist = [] +approved_domains = [] + +[runtime] +kind = "native" + +[runtime.docker] +image = "alpine:3.20" +network = "none" +memory_limit_mb = 512 +cpu_limit = 1.0 +read_only_rootfs = true +mount_workspace = true +allowed_workspace_roots = [] + +[runtime.wasm] +tools_dir = "tools/wasm" +fuel_limit = 1000000 +memory_limit_mb = 64 +max_module_size_mb = 50 +allow_workspace_read = false +allow_workspace_write = false +allowed_hosts = [] + +[runtime.wasm.security] +require_workspace_relative_tools_dir = true +reject_symlink_modules = true +reject_symlink_tools_dir = true +strict_host_validation = true +capability_escalation_mode = "deny" +module_hash_policy = "warn" + +[runtime.wasm.security.module_sha256] + +[research] +enabled = false +trigger = "never" +keywords = [ + "find", + "search", + "check", + "investigate", + "look", + "research", + "найди", + "проверь", + "исследуй", + "поищи", +] +min_message_length = 50 +max_iterations = 5 +show_progress = true +system_prompt_prefix = "" + +[reliability] +provider_retries = 2 +provider_backoff_ms = 500 +fallback_providers = [] +api_keys = [] +channel_initial_backoff_secs = 2 +channel_max_backoff_secs = 60 +scheduler_poll_secs = 15 +scheduler_retries = 2 + +[reliability.model_fallbacks] + +[scheduler] +enabled = true +max_tasks = 64 +max_concurrent = 4 + +[agent] +compact_context = true +max_tool_iterations = 20 +max_history_messages = 50 +parallel_tools = false +tool_dispatcher = "auto" +loop_detection_no_progress_threshold = 3 +loop_detection_ping_pong_cycles = 2 +loop_detection_failure_streak = 3 +safety_heartbeat_interval = 5 +safety_heartbeat_turn_interval = 10 + +[agent.session] +backend = "none" +strategy = "per-sender" +ttl_seconds = 3600 +max_messages = 50 + +[agent.teams] +enabled = true +auto_activate = true +max_agents = 32 +strategy = "adaptive" +load_window_secs = 120 +inflight_penalty = 8 +recent_selection_penalty = 2 +recent_failure_penalty = 12 + +[agent.subagents] +enabled = true +auto_activate = true +max_concurrent = 10 +strategy = "adaptive" +load_window_secs = 180 +inflight_penalty = 10 +recent_selection_penalty = 3 +recent_failure_penalty = 16 +queue_wait_ms = 15000 +queue_poll_ms = 200 + +[skills] +open_skills_enabled = false +trusted_skill_roots = [] +allow_scripts = false +prompt_injection_mode = "full" + +[query_classification] +enabled = false +rules = [] + +[heartbeat] +enabled = false +interval_minutes = 30 + +[cron] +enabled = true +max_run_history = 50 + +[goal_loop] +enabled = false +interval_minutes = 10 +step_timeout_secs = 120 +max_steps_per_cycle = 3 + +[channels_config] +cli = true +message_timeout_secs = 300 + +[channels_config.webhook] +port = 8080 +secret = "mytoken123" + +[channels_config.ack_reaction] + +[memory] +backend = "sqlite" +auto_save = true +hygiene_enabled = true +archive_after_days = 7 +purge_after_days = 30 +conversation_retention_days = 30 +embedding_provider = "none" +embedding_model = "text-embedding-3-small" +embedding_dimensions = 1536 +vector_weight = 0.7 +keyword_weight = 0.3 +min_relevance_score = 0.4 +embedding_cache_size = 10000 +chunk_max_tokens = 512 +response_cache_enabled = false +response_cache_ttl_minutes = 60 +response_cache_max_entries = 5000 +snapshot_enabled = false +snapshot_on_hygiene = false +auto_hydrate = true +sqlite_journal_mode = "wal" + +[memory.qdrant] +collection = "zeroclaw_memories" + +[storage.provider.config] +provider = "" +schema = "public" +table = "memories" +tls = false + +[tunnel] +provider = "none" + +[gateway] +port = 8080 +host = "0.0.0.0" +require_pairing = false +trusted_ips = ["0.0.0.0/0"] +allow_public_bind = true +paired_tokens = [] +pair_rate_limit_per_minute = 10 +webhook_rate_limit_per_minute = 60 +trust_forwarded_headers = false +rate_limit_max_keys = 10000 +idempotency_ttl_secs = 300 +idempotency_max_keys = 10000 +webhook_secret = "mytoken123" + +[gateway.node_control] +enabled = false +allowed_node_ids = [] + +[composio] +enabled = false +entity_id = "default" + +[secrets] +encrypt = true + +[browser] +enabled = false +allowed_domains = [] +browser_open = "default" +backend = "agent_browser" +auto_backend_priority = [] +agent_browser_command = "agent-browser" +agent_browser_extra_args = [] +agent_browser_timeout_ms = 30000 +native_headless = true +native_webdriver_url = "http://127.0.0.1:9515" + +[browser.computer_use] +endpoint = "http://127.0.0.1:8787/v1/actions" +timeout_ms = 15000 +allow_remote_endpoint = false +window_allowlist = [] + +[http_request] +enabled = false +allowed_domains = [] +max_response_size = 1000000 +timeout_secs = 30 +user_agent = "ZeroClaw/1.0" + +[http_request.credential_profiles] + +[multimodal] +max_images = 4 +max_image_size_mb = 5 +allow_remote_fetch = false + +[web_fetch] +enabled = false +provider = "fast_html2md" +allowed_domains = ["*"] +blocked_domains = [] +max_response_size = 500000 +timeout_secs = 30 +user_agent = "ZeroClaw/1.0" + +[web_search] +enabled = false +provider = "duckduckgo" +fallback_providers = [] +retries_per_provider = 0 +retry_backoff_ms = 250 +domain_filter = [] +language_filter = [] +exa_search_type = "auto" +exa_include_text = false +jina_site_filters = [] +max_results = 5 +timeout_secs = 15 +user_agent = "ZeroClaw/1.0" + +[proxy] +enabled = false +no_proxy = [] +scope = "zeroclaw" +services = [] + +[identity] +format = "openclaw" +extra_files = [] + +[cost] +enabled = false +daily_limit_usd = 10.0 +monthly_limit_usd = 100.0 +warn_at_percent = 80 +allow_override = false + +[cost.prices."anthropic/claude-opus-4-20250514"] +input = 15.0 +output = 75.0 + +[cost.prices."openai/gpt-4o"] +input = 5.0 +output = 15.0 + +[cost.prices."openai/gpt-4o-mini"] +input = 0.15 +output = 0.6 + +[cost.prices."anthropic/claude-sonnet-4-20250514"] +input = 3.0 +output = 15.0 + +[cost.prices."openai/o1-preview"] +input = 15.0 +output = 60.0 + +[cost.prices."anthropic/claude-3-haiku"] +input = 0.25 +output = 1.25 + +[cost.prices."google/gemini-2.0-flash"] +input = 0.1 +output = 0.4 + +[cost.prices."anthropic/claude-3.5-sonnet"] +input = 3.0 +output = 15.0 + +[cost.prices."google/gemini-1.5-pro"] +input = 1.25 +output = 5.0 + +[cost.enforcement] +mode = "warn" +route_down_model = "hint:fast" +reserve_percent = 10 + +[economic] +enabled = false +initial_balance = 1000.0 +min_evaluation_threshold = 0.6 + +[economic.token_pricing] +input_price_per_million = 3.0 +output_price_per_million = 15.0 + +[peripherals] +enabled = true +boards = [] + +[agents] + +[coordination] +enabled = true +lead_agent = "delegate-lead" +max_inbox_messages_per_agent = 256 +max_dead_letters = 256 +max_context_entries = 512 +max_seen_message_ids = 4096 + +[hooks] +enabled = true + +[hooks.builtin] +boot_script = false +command_logger = false +session_memory = false + +[plugins] +enabled = true +allow = [] +deny = [] +load_paths = [] + +[plugins.entries] + +[hardware] +enabled = true +transport = "None" +baud_rate = 115200 +workspace_datasheets = false + +[transcription] +enabled = false +api_url = "https://api.groq.com/openai/v1/audio/transcriptions" +model = "whisper-large-v3-turbo" +max_duration_secs = 120 + +[agents_ipc] +enabled = false +db_path = "~/.zeroclaw/agents.db" +staleness_secs = 300 + +[mcp] +enabled = false +servers = [] + +[wasm] +enabled = true +memory_limit_mb = 64 +fuel_limit = 1000000000 +registry_url = "https://zeromarket.vercel.app/api" diff --git a/scripts/zeroclaw.service b/scripts/zeroclaw.service new file mode 100644 index 0000000000..0320d4ac17 --- /dev/null +++ b/scripts/zeroclaw.service @@ -0,0 +1,22 @@ +[Unit] +Description=ZeroClaw AI Hardware Agent +Documentation=https://github.com/zeroclaw/zeroclaw +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=pi +SupplementaryGroups=gpio spi i2c +WorkingDirectory=/home/pi/zeroclaw +ExecStart=/home/pi/zeroclaw/zeroclaw gateway --host 0.0.0.0 --port 8080 +Restart=on-failure +RestartSec=5 +EnvironmentFile=/home/pi/zeroclaw/.env +Environment=RUST_LOG=info + +# Expand ~ in config path +Environment=HOME=/home/pi + +[Install] +WantedBy=multi-user.target diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index a163d5b852..a7fc1de218 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -202,6 +202,10 @@ pub(crate) const PROGRESS_MIN_INTERVAL_MS: u64 = 500; /// Used before streaming the final answer so progress lines are replaced by the clean response. pub(crate) const DRAFT_CLEAR_SENTINEL: &str = "\x00CLEAR\x00"; +tokio::task_local! { + pub(crate) static TOOL_CHOICE_OVERRIDE: Option; +} + /// Extract a short hint from tool call arguments for progress display. fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String { let hint = match name { diff --git a/src/gateway/hardware_context.rs b/src/gateway/hardware_context.rs new file mode 100644 index 0000000000..874eb658db --- /dev/null +++ b/src/gateway/hardware_context.rs @@ -0,0 +1,427 @@ +//! Hardware context management endpoints. +//! +//! These endpoints let remote callers (phone, laptop) register GPIO pins and +//! append context to the running agent's hardware knowledge base without SSH. +//! +//! ## Endpoints +//! +//! - `POST /api/hardware/pin` — register a single GPIO pin assignment +//! - `POST /api/hardware/context` — append raw markdown to a device file +//! - `GET /api/hardware/context` — read all current hardware context files +//! - `POST /api/hardware/reload` — verify on-disk context; report what will be +//! used on the next chat request +//! +//! ## Live update semantics +//! +//! ZeroClaw's agent loop calls [`crate::hardware::boot`] on **every** request, +//! which re-reads `~/.zeroclaw/hardware/` from disk. Writing to those files +//! therefore takes effect on the very next `/api/chat` call — no daemon restart +//! needed. The `/api/hardware/reload` endpoint verifies what is on disk and +//! reports what will be injected into the system prompt next time. +//! +//! ## Security +//! +//! - **Auth**: same `require_auth` helper used by all `/api/*` routes. +//! - **Path traversal**: device aliases are validated to be alphanumeric + +//! hyphens/underscores only; they are never used as raw path components. +//! - **Append-only**: all writes use `OpenOptions::append(true)` — existing +//! content cannot be truncated or overwritten through these endpoints. +//! - **Size limit**: individual append payloads are capped at 32 KB. + +use super::AppState; +use axum::{ + extract::{State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Json}, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tokio::fs; +use tokio::io::AsyncWriteExt as _; + +/// Maximum bytes allowed in a single append payload. +const MAX_APPEND_BYTES: usize = 32_768; // 32 KB + +// ── Auth helper (re-uses the pattern from api.rs) ───────────────────────────── + +fn require_auth( + state: &AppState, + headers: &HeaderMap, +) -> Result<(), (StatusCode, Json)> { + if !state.pairing.require_pairing() { + return Ok(()); + } + let token = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|auth| auth.strip_prefix("Bearer ")) + .unwrap_or(""); + if state.pairing.is_authenticated(token) { + Ok(()) + } else { + Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer " + })), + )) + } +} + +// ── Path helpers ────────────────────────────────────────────────────────────── + +/// Return `~/.zeroclaw/hardware/` or an error string. +fn hardware_dir() -> Result { + directories::BaseDirs::new() + .map(|b| b.home_dir().join(".zeroclaw").join("hardware")) + .ok_or_else(|| "Cannot determine home directory".to_string()) +} + +/// Validate a device alias: must be non-empty, ≤64 chars, and consist only of +/// alphanumerics, hyphens, and underscores. Returns an error message on failure. +fn validate_device_alias(alias: &str) -> Result<(), &'static str> { + if alias.is_empty() || alias.len() > 64 { + return Err("Device alias must be 1–64 characters"); + } + if !alias.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err("Device alias must contain only alphanumerics, hyphens, and underscores"); + } + Ok(()) +} + +/// Return the path to a device context file, after validating the alias. +fn device_file_path(hw_dir: &std::path::Path, alias: &str) -> Result { + validate_device_alias(alias)?; + Ok(hw_dir.join("devices").join(format!("{alias}.md"))) +} + +// ── POST /api/hardware/pin ──────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct PinRegistrationBody { + /// Device alias (default: "rpi0"). + #[serde(default = "default_device")] + pub device: String, + /// BCM GPIO number. + pub pin: u32, + /// Component type/name, e.g. "LED", "Button", "Servo". + pub component: String, + /// Optional human notes about this pin, e.g. "red LED, active HIGH". + #[serde(default)] + pub notes: String, +} + +fn default_device() -> String { + "rpi0".to_string() +} + +/// `POST /api/hardware/pin` — register a single GPIO pin assignment. +/// +/// Appends one line to `~/.zeroclaw/hardware/devices/.md`: +/// ```text +/// - GPIO : +/// ``` +pub async fn handle_hardware_pin( + State(state): State, + headers: HeaderMap, + body: Result, axum::extract::rejection::JsonRejection>, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + + let Json(req) = match body { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })), + ) + .into_response() + } + }; + + if req.component.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "\"component\" must not be empty" })), + ) + .into_response(); + } + // Sanitize component + notes: strip newlines to prevent line-injection. + let component = req.component.replace(['\n', '\r'], " "); + let notes = req.notes.replace(['\n', '\r'], " "); + + let hw_dir = match hardware_dir() { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ) + .into_response() + } + }; + + let device_path = match device_file_path(&hw_dir, &req.device) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ) + .into_response() + } + }; + + // Create devices dir + file if missing, then append. + if let Some(parent) = device_path.parent() { + if let Err(e) = fs::create_dir_all(parent).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })), + ) + .into_response(); + } + } + + let line = if notes.is_empty() { + format!("- GPIO {}: {}\n", req.pin, component) + } else { + format!("- GPIO {}: {} — {}\n", req.pin, component, notes) + }; + + match append_to_file(&device_path, &line).await { + Ok(()) => { + let message = format!( + "GPIO {} registered as {} on {}", + req.pin, component, req.device + ); + tracing::info!(device = %req.device, pin = req.pin, component = %component, "{}", message); + ( + StatusCode::OK, + Json(serde_json::json!({ "ok": true, "message": message })), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to write: {e}") })), + ) + .into_response(), + } +} + +// ── POST /api/hardware/context ──────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct ContextAppendBody { + /// Device alias (default: "rpi0"). + #[serde(default = "default_device")] + pub device: String, + /// Raw markdown string to append to the device file. + pub content: String, +} + +/// `POST /api/hardware/context` — append raw markdown to a device file. +pub async fn handle_hardware_context_post( + State(state): State, + headers: HeaderMap, + body: Result, axum::extract::rejection::JsonRejection>, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + + let Json(req) = match body { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })), + ) + .into_response() + } + }; + + if req.content.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "\"content\" must not be empty" })), + ) + .into_response(); + } + if req.content.len() > MAX_APPEND_BYTES { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(serde_json::json!({ + "error": format!("Content too large — max {} bytes", MAX_APPEND_BYTES) + })), + ) + .into_response(); + } + + let hw_dir = match hardware_dir() { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ) + .into_response() + } + }; + + let device_path = match device_file_path(&hw_dir, &req.device) { + Ok(p) => p, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ) + .into_response() + } + }; + + if let Some(parent) = device_path.parent() { + if let Err(e) = fs::create_dir_all(parent).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })), + ) + .into_response(); + } + } + + // Ensure content ends with a newline so successive appends don't merge lines. + let mut content = req.content.clone(); + if !content.ends_with('\n') { + content.push('\n'); + } + + match append_to_file(&device_path, &content).await { + Ok(()) => { + tracing::info!(device = %req.device, bytes = content.len(), "Hardware context appended"); + (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("Failed to write: {e}") })), + ) + .into_response(), + } +} + +// ── GET /api/hardware/context ───────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct HardwareContextResponse { + hardware_md: String, + devices: std::collections::HashMap, +} + +/// `GET /api/hardware/context` — return all current hardware context file contents. +pub async fn handle_hardware_context_get( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + + let hw_dir = match hardware_dir() { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ) + .into_response() + } + }; + + // Read HARDWARE.md + let hardware_md = fs::read_to_string(hw_dir.join("HARDWARE.md")) + .await + .unwrap_or_default(); + + // Read all device files + let devices_dir = hw_dir.join("devices"); + let mut devices = std::collections::HashMap::new(); + if let Ok(mut entries) = fs::read_dir(&devices_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("md") { + let alias = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + if !alias.is_empty() { + let content = fs::read_to_string(&path).await.unwrap_or_default(); + devices.insert(alias, content); + } + } + } + } + + let resp = HardwareContextResponse { + hardware_md, + devices, + }; + (StatusCode::OK, Json(resp)).into_response() +} + +// ── POST /api/hardware/reload ───────────────────────────────────────────────── + +/// `POST /api/hardware/reload` — verify on-disk hardware context and report what +/// will be loaded on the next chat request. +/// +/// Since [`crate::hardware::boot`] re-reads from disk on every agent invocation, +/// writing to the hardware files via the other endpoints already takes effect on +/// the next `/api/chat` call. This endpoint reads the same files and reports +/// the current state so callers can confirm the update landed. +pub async fn handle_hardware_reload( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + + // Count currently-registered tools in the gateway state + let tool_count = state.tools_registry.len(); + + // Reload hardware context from disk (same function used by the agent loop) + let context = crate::hardware::load_hardware_context_prompt(&[]); + let context_length = context.len(); + + tracing::info!( + context_length, + tool_count, + "Hardware context reloaded (on-disk read)" + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "ok": true, + "tools": tool_count, + "context_length": context_length, + })), + ) + .into_response() +} + +// ── File I/O helper ─────────────────────────────────────────────────────────── + +async fn append_to_file(path: &std::path::Path, content: &str) -> std::io::Result<()> { + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .await?; + file.write_all(content.as_bytes()).await?; + file.flush().await?; + Ok(()) +} diff --git a/src/hardware/aardvark.rs b/src/hardware/aardvark.rs new file mode 100644 index 0000000000..66de377ca8 --- /dev/null +++ b/src/hardware/aardvark.rs @@ -0,0 +1,225 @@ +//! AardvarkTransport — implements the Transport trait for Total Phase Aardvark USB adapters. +//! +//! The Aardvark is NOT a microcontroller firmware target; it is a USB bridge +//! that speaks I2C / SPI / GPIO directly. Unlike [`HardwareSerialTransport`], +//! this transport interprets [`ZcCommand`] locally and calls the Aardvark C +//! library (via [`aardvark_sys`]) rather than forwarding JSON over a serial wire. +//! +//! Lazy-open strategy: a fresh [`aardvark_sys::AardvarkHandle`] is opened at +//! the start of each [`send`](AardvarkTransport::send) call and automatically +//! closed (dropped) before the call returns. No persistent handle is held, +//! matching the design of [`HardwareSerialTransport`]. + +use super::protocol::{ZcCommand, ZcResponse}; +use super::transport::{Transport, TransportError, TransportKind}; +use aardvark_sys::AardvarkHandle; +use async_trait::async_trait; + +/// Transport implementation for Total Phase Aardvark USB adapters. +/// +/// Supports I2C, SPI, and direct GPIO operations via the Aardvark C library. +pub struct AardvarkTransport { + /// Aardvark port index (0 = first available adapter). + port: i32, + /// Default I2C / SPI bitrate in kHz (e.g. 100 for standard-mode I2C). + bitrate_khz: u32, +} + +impl AardvarkTransport { + /// Create a new transport for the given port and bitrate. + /// + /// The port number matches the index returned by + /// [`AardvarkHandle::find_devices`]. + pub fn new(port: i32, bitrate_khz: u32) -> Self { + Self { port, bitrate_khz } + } + + /// Return `true` when at least one Aardvark adapter is found by the SDK. + pub fn probe_connected(&self) -> bool { + AardvarkHandle::find_devices() + .into_iter() + .any(|p| i32::from(p) == self.port || self.port == 0) + } + + /// Open a fresh handle for one transaction. + fn open_handle(&self) -> Result { + AardvarkHandle::open_port(self.port) + .map_err(|e| TransportError::Other(format!("aardvark open: {e}"))) + } +} + +#[async_trait] +impl Transport for AardvarkTransport { + fn kind(&self) -> TransportKind { + TransportKind::Aardvark + } + + fn is_connected(&self) -> bool { + !AardvarkHandle::find_devices().is_empty() + } + + async fn send(&self, cmd: &ZcCommand) -> Result { + // Open a fresh handle per command — released when this scope ends. + let handle = self.open_handle()?; + + let result: serde_json::Value = match cmd.cmd.as_str() { + // ── I2C ────────────────────────────────────────────────────────── + "i2c_scan" => { + handle + .i2c_enable(self.bitrate_khz) + .map_err(|e| TransportError::Other(e.to_string()))?; + let devices: Vec = handle + .i2c_scan() + .into_iter() + .map(|a| format!("{a:#04x}")) + .collect(); + serde_json::json!({ "ok": true, "data": { "devices": devices } }) + } + + "i2c_read" => { + let addr = required_u8(&cmd.params, "addr")?; + let reg = optional_u8(&cmd.params, "register"); + let len: usize = cmd + .params + .get("len") + .and_then(|v| v.as_u64()) + .unwrap_or(1) + .try_into() + .unwrap_or(1); + + handle + .i2c_enable(self.bitrate_khz) + .map_err(|e| TransportError::Other(e.to_string()))?; + + let data = if let Some(r) = reg { + handle.i2c_write_read(addr, &[r], len) + } else { + handle.i2c_read(addr, len) + } + .map_err(|e| TransportError::Other(e.to_string()))?; + + let hex: Vec = data.iter().map(|b| format!("{b:#04x}")).collect(); + serde_json::json!({ + "ok": true, + "data": { "bytes": data, "hex": hex } + }) + } + + "i2c_write" => { + let addr = required_u8(&cmd.params, "addr")?; + let bytes = required_byte_array(&cmd.params, "bytes")?; + + handle + .i2c_enable(self.bitrate_khz) + .map_err(|e| TransportError::Other(e.to_string()))?; + handle + .i2c_write(addr, &bytes) + .map_err(|e| TransportError::Other(e.to_string()))?; + + serde_json::json!({ + "ok": true, + "data": { "bytes_written": bytes.len() } + }) + } + + // ── SPI ────────────────────────────────────────────────────────── + "spi_transfer" => { + let bytes = required_byte_array(&cmd.params, "bytes")?; + + handle + .spi_enable(self.bitrate_khz) + .map_err(|e| TransportError::Other(e.to_string()))?; + let recv = handle + .spi_transfer(&bytes) + .map_err(|e| TransportError::Other(e.to_string()))?; + + let hex: Vec = recv.iter().map(|b| format!("{b:#04x}")).collect(); + serde_json::json!({ + "ok": true, + "data": { "received": recv, "hex": hex } + }) + } + + // ── GPIO ───────────────────────────────────────────────────────── + "gpio_set" => { + let direction = required_u8(&cmd.params, "direction")?; + let value = required_u8(&cmd.params, "value")?; + + handle + .gpio_set(direction, value) + .map_err(|e| TransportError::Other(e.to_string()))?; + + serde_json::json!({ + "ok": true, + "data": { "direction": direction, "value": value } + }) + } + + "gpio_get" => { + let val = handle + .gpio_get() + .map_err(|e| TransportError::Other(e.to_string()))?; + + serde_json::json!({ + "ok": true, + "data": { "value": val } + }) + } + + unknown => serde_json::json!({ + "ok": false, + "error": format!("unknown Aardvark command: {unknown}") + }), + }; + + // Drop handle here (auto-close via Drop). + Ok(ZcResponse { + ok: result["ok"].as_bool().unwrap_or(false), + data: result["data"].clone(), + error: result["error"].as_str().map(String::from), + }) + } +} + +// ── Parameter helpers ───────────────────────────────────────────────────────── + +/// Extract a required `u8` field from JSON params, returning a `TransportError` +/// if missing or out of range. +fn required_u8(params: &serde_json::Value, key: &str) -> Result { + params + .get(key) + .and_then(|v| v.as_u64()) + .and_then(|n| u8::try_from(n).ok()) + .ok_or_else(|| { + TransportError::Protocol(format!("missing or out-of-range u8 parameter: '{key}'")) + }) +} + +/// Extract an optional `u8` field — returns `None` if absent or not representable as u8. +fn optional_u8(params: &serde_json::Value, key: &str) -> Option { + params + .get(key) + .and_then(|v| v.as_u64()) + .and_then(|n| u8::try_from(n).ok()) +} + +/// Extract a required JSON array of integers as `Vec`. +fn required_byte_array(params: &serde_json::Value, key: &str) -> Result, TransportError> { + let arr = params + .get(key) + .and_then(|v| v.as_array()) + .ok_or_else(|| TransportError::Protocol(format!("missing array parameter: '{key}'")))?; + + arr.iter() + .enumerate() + .map(|(i, v)| { + v.as_u64() + .and_then(|n| u8::try_from(n).ok()) + .ok_or_else(|| { + TransportError::Protocol(format!( + "byte at index {i} in '{key}' is not a valid u8" + )) + }) + }) + .collect() +} diff --git a/src/hardware/aardvark_tools.rs b/src/hardware/aardvark_tools.rs new file mode 100644 index 0000000000..9e49f249c4 --- /dev/null +++ b/src/hardware/aardvark_tools.rs @@ -0,0 +1,574 @@ +//! Aardvark hardware tools — I2C, SPI, and GPIO operations via the Total Phase +//! Aardvark USB adapter. +//! +//! All tools follow the same pattern as the built-in GPIO tools: +//! 1. Accept an optional `device` alias parameter. +//! 2. Resolve the Aardvark device from the [`DeviceRegistry`]. +//! 3. Build a [`ZcCommand`] and send it through the registered transport. +//! 4. Return a [`ToolResult`] with human-readable output. +//! +//! These tools are only registered when at least one Aardvark adapter is +//! detected at startup (see [`DeviceRegistry::has_aardvark`]). + +use super::device::DeviceRegistry; +use super::protocol::ZcCommand; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +// ── Factory ─────────────────────────────────────────────────────────────────── + +/// Build the five Aardvark hardware tools. +/// +/// Called from [`ToolRegistry::load`] when an Aardvark adapter is present. +pub fn aardvark_tools(devices: Arc>) -> Vec> { + vec![ + Box::new(I2cScanTool::new(devices.clone())), + Box::new(I2cReadTool::new(devices.clone())), + Box::new(I2cWriteTool::new(devices.clone())), + Box::new(SpiTransferTool::new(devices.clone())), + Box::new(GpioAardvarkTool::new(devices.clone())), + ] +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Resolve the Aardvark device from args and return an owned `DeviceContext`. +/// +/// Thin wrapper so individual tool `execute` methods don't duplicate the logic. +async fn resolve( + registry: &Arc>, + args: &serde_json::Value, +) -> Result<(String, super::device::DeviceContext), ToolResult> { + let reg = registry.read().await; + reg.resolve_aardvark_device(args).map_err(|msg| ToolResult { + success: false, + output: String::new(), + error: Some(msg), + }) +} + +// ── I2cScanTool ─────────────────────────────────────────────────────────────── + +/// Tool: scan the I2C bus for responding device addresses. +pub struct I2cScanTool { + registry: Arc>, +} + +impl I2cScanTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for I2cScanTool { + fn name(&self) -> &str { + "i2c_scan" + } + + fn description(&self) -> &str { + "Scan the I2C bus via the Aardvark USB adapter and return all responding \ + device addresses in hex (e.g. [0x48, 0x68])" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Aardvark device alias (e.g. aardvark0). Omit to auto-select." + } + }, + "required": [] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let (_alias, ctx) = match resolve(&self.registry, &args).await { + Ok(v) => v, + Err(result) => return Ok(result), + }; + + let cmd = ZcCommand::simple("i2c_scan"); + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let devices = resp + .data + .get("devices") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let output = if devices.is_empty() { + "I2C scan complete — no devices found on the bus.".to_string() + } else { + let addrs: Vec<&str> = devices.iter().filter_map(|v| v.as_str()).collect(); + format!( + "I2C scan found {} device(s): {}", + addrs.len(), + addrs.join(", ") + ) + }; + Ok(ToolResult { + success: true, + output, + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "i2c_scan: device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {e}")), + }), + } + } +} + +// ── I2cReadTool ─────────────────────────────────────────────────────────────── + +/// Tool: read bytes from an I2C device register. +pub struct I2cReadTool { + registry: Arc>, +} + +impl I2cReadTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for I2cReadTool { + fn name(&self) -> &str { + "i2c_read" + } + + fn description(&self) -> &str { + "Read bytes from an I2C device via the Aardvark USB adapter. \ + Provide the I2C address and optionally a register to read from." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Aardvark device alias (e.g. aardvark0). Omit to auto-select." + }, + "addr": { + "type": "integer", + "description": "I2C device address (e.g. 72 for 0x48)" + }, + "register": { + "type": "integer", + "description": "Register address to read from (optional)" + }, + "len": { + "type": "integer", + "description": "Number of bytes to read", + "default": 1 + } + }, + "required": ["addr"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let addr = match args.get("addr").and_then(|v| v.as_u64()) { + Some(a) => a, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: addr".to_string()), + }) + } + }; + let len = args.get("len").and_then(|v| v.as_u64()).unwrap_or(1); + + let (_alias, ctx) = match resolve(&self.registry, &args).await { + Ok(v) => v, + Err(result) => return Ok(result), + }; + + let mut params = json!({ "addr": addr, "len": len }); + if let Some(reg) = args.get("register").and_then(|v| v.as_u64()) { + params["register"] = json!(reg); + } + let cmd = ZcCommand::new("i2c_read", params); + + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let hex = resp + .data + .get("hex") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "?".to_string()); + Ok(ToolResult { + success: true, + output: format!("I2C read from addr {addr:#04x}: [{hex}]"), + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "i2c_read: device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {e}")), + }), + } + } +} + +// ── I2cWriteTool ────────────────────────────────────────────────────────────── + +/// Tool: write bytes to an I2C device. +pub struct I2cWriteTool { + registry: Arc>, +} + +impl I2cWriteTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for I2cWriteTool { + fn name(&self) -> &str { + "i2c_write" + } + + fn description(&self) -> &str { + "Write bytes to an I2C device via the Aardvark USB adapter" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Aardvark device alias (e.g. aardvark0). Omit to auto-select." + }, + "addr": { + "type": "integer", + "description": "I2C device address (e.g. 72 for 0x48)" + }, + "bytes": { + "type": "array", + "items": { "type": "integer" }, + "description": "Bytes to write (e.g. [1, 96] for register 0x01 config 0x60)" + } + }, + "required": ["addr", "bytes"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let addr = match args.get("addr").and_then(|v| v.as_u64()) { + Some(a) => a, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: addr".to_string()), + }) + } + }; + let bytes = match args.get("bytes").and_then(|v| v.as_array()) { + Some(b) => b.clone(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: bytes".to_string()), + }) + } + }; + + let (_alias, ctx) = match resolve(&self.registry, &args).await { + Ok(v) => v, + Err(result) => return Ok(result), + }; + + let cmd = ZcCommand::new("i2c_write", json!({ "addr": addr, "bytes": bytes })); + + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let n = resp + .data + .get("bytes_written") + .and_then(|v| v.as_u64()) + .unwrap_or(bytes.len() as u64); + Ok(ToolResult { + success: true, + output: format!("I2C write to addr {addr:#04x}: {n} byte(s) written"), + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "i2c_write: device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {e}")), + }), + } + } +} + +// ── SpiTransferTool ─────────────────────────────────────────────────────────── + +/// Tool: full-duplex SPI transfer. +pub struct SpiTransferTool { + registry: Arc>, +} + +impl SpiTransferTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for SpiTransferTool { + fn name(&self) -> &str { + "spi_transfer" + } + + fn description(&self) -> &str { + "Perform a full-duplex SPI transfer via the Aardvark USB adapter. \ + Sends the given bytes and returns the received bytes (same length)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Aardvark device alias (e.g. aardvark0). Omit to auto-select." + }, + "bytes": { + "type": "array", + "items": { "type": "integer" }, + "description": "Bytes to send (received bytes have the same length)" + } + }, + "required": ["bytes"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let bytes = match args.get("bytes").and_then(|v| v.as_array()) { + Some(b) => b.clone(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: bytes".to_string()), + }) + } + }; + + let (_alias, ctx) = match resolve(&self.registry, &args).await { + Ok(v) => v, + Err(result) => return Ok(result), + }; + + let cmd = ZcCommand::new("spi_transfer", json!({ "bytes": bytes })); + + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let hex = resp + .data + .get("hex") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "?".to_string()); + Ok(ToolResult { + success: true, + output: format!("SPI transfer complete. Received: [{hex}]"), + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "spi_transfer: device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {e}")), + }), + } + } +} + +// ── GpioAardvarkTool ────────────────────────────────────────────────────────── + +/// Tool: set or read the Aardvark adapter's GPIO pins. +/// +/// The Aardvark has 8 GPIO pins accessible via the 10-pin expansion header. +/// Each pin can be configured as input or output via bitmasks. +pub struct GpioAardvarkTool { + registry: Arc>, +} + +impl GpioAardvarkTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for GpioAardvarkTool { + fn name(&self) -> &str { + "gpio_aardvark" + } + + fn description(&self) -> &str { + "Set or read the Aardvark USB adapter GPIO pins via bitmasks. \ + Use action='set' with direction and value bitmasks to drive output pins, \ + or action='get' to read current pin states." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Aardvark device alias (e.g. aardvark0). Omit to auto-select." + }, + "action": { + "type": "string", + "enum": ["set", "get"], + "description": "'set' to write GPIO pins, 'get' to read pin states" + }, + "direction": { + "type": "integer", + "description": "For action='set': bitmask of output pins (1=output, 0=input)" + }, + "value": { + "type": "integer", + "description": "For action='set': bitmask of output pin levels (1=high, 0=low)" + } + }, + "required": ["action"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = match args.get("action").and_then(|v| v.as_str()) { + Some(a) => a.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: action".to_string()), + }) + } + }; + + let (_alias, ctx) = match resolve(&self.registry, &args).await { + Ok(v) => v, + Err(result) => return Ok(result), + }; + + let cmd = match action.as_str() { + "set" => { + let direction = args.get("direction").and_then(|v| v.as_u64()).unwrap_or(0); + let value = args.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + ZcCommand::new( + "gpio_set", + json!({ "direction": direction, "value": value }), + ) + } + "get" => ZcCommand::simple("gpio_get"), + other => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("unknown action '{other}'; use 'set' or 'get'")), + }) + } + }; + + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let output = if action == "get" { + let val = resp.data.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + format!("Aardvark GPIO pins: {val:#010b} (0x{val:02x})") + } else { + let dir = resp + .data + .get("direction") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let val = resp.data.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + format!("Aardvark GPIO set — direction: {dir:#010b}, value: {val:#010b}") + }; + Ok(ToolResult { + success: true, + output, + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "gpio_aardvark: device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {e}")), + }), + } + } +} diff --git a/src/hardware/datasheet.rs b/src/hardware/datasheet.rs new file mode 100644 index 0000000000..f1b36778d8 --- /dev/null +++ b/src/hardware/datasheet.rs @@ -0,0 +1,355 @@ +//! Datasheet management for industry devices connected via Aardvark. +//! +//! When a user identifies a new device (e.g. "I have an LM75 temperature +//! sensor"), the [`DatasheetTool`] calls [`DatasheetManager`] to: +//! +//! 1. **search** — query the web for the device datasheet PDF URL. +//! 2. **download** — fetch the PDF and save it to +//! `~/.zeroclaw/hardware/datasheets/.pdf`. +//! 3. **list** — enumerate all locally cached datasheets. +//! 4. **read** — return the local path of a cached datasheet so the LLM can +//! reference it with the `read_file` tool or a future RAG pipeline. +//! +//! # Note on PDF extraction +//! +//! Full in-process PDF parsing is available when the `rag-pdf` feature is +//! enabled (adds `pdf-extract`). Without that feature, the tool returns the +//! PDF file path and instructs the LLM to use a future RAG step. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use std::path::PathBuf; + +// ── DatasheetManager ───────────────────────────────────────────────────────── + +/// Manages device datasheet files in `~/.zeroclaw/hardware/datasheets/`. +pub struct DatasheetManager { + /// Root datasheet storage directory. + datasheet_dir: PathBuf, +} + +impl DatasheetManager { + /// Create a manager rooted at the default ZeroClaw datasheets directory. + pub fn new() -> Option { + let home = directories::BaseDirs::new()?.home_dir().to_path_buf(); + Some(Self { + datasheet_dir: home.join(".zeroclaw").join("hardware").join("datasheets"), + }) + } + + /// Check if a datasheet for `device_name` already exists locally. + /// + /// Searches for `.pdf` (case-insensitive stem match). + pub fn find_local(&self, device_name: &str) -> Option { + let target = format!("{}.pdf", device_name.to_lowercase().replace(' ', "_")); + let candidate = self.datasheet_dir.join(&target); + if candidate.exists() { + return Some(candidate); + } + // Broader scan: any filename containing the device name. + if let Ok(entries) = std::fs::read_dir(&self.datasheet_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_lowercase(); + let key = device_name.to_lowercase().replace(' ', "_"); + if name_str.contains(&key) && name_str.ends_with(".pdf") { + return Some(entry.path()); + } + } + } + None + } + + /// Download a datasheet PDF from `url` and save it locally. + /// + /// The file is saved as `~/.zeroclaw/hardware/datasheets/.pdf`. + /// Returns the path to the saved file. + pub async fn download_datasheet( + &self, + url: &str, + device_name: &str, + ) -> anyhow::Result { + std::fs::create_dir_all(&self.datasheet_dir)?; + + let filename = format!("{}.pdf", device_name.to_lowercase().replace(' ', "_")); + let dest = self.datasheet_dir.join(&filename); + + let client = reqwest::Client::builder() + .user_agent("ZeroClaw/0.1 (datasheet downloader)") + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let response = client.get(url).send().await?; + if !response.status().is_success() { + anyhow::bail!( + "HTTP {} downloading datasheet from {url}", + response.status() + ); + } + let bytes = response.bytes().await?; + std::fs::write(&dest, &bytes)?; + + tracing::info!(device = %device_name, path = %dest.display(), "datasheet downloaded"); + Ok(dest) + } + + /// List all locally cached datasheet filenames. + pub fn list_datasheets(&self) -> Vec { + if let Ok(entries) = std::fs::read_dir(&self.datasheet_dir) { + let mut names: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.ends_with(".pdf")) + .collect(); + names.sort(); + return names; + } + Vec::new() + } + + /// Build a web search query for a device datasheet. + /// + /// Returns a suggested search query string the LLM (or a search tool) can + /// use to find the datasheet. + pub fn search_query(device_name: &str) -> String { + format!("{device_name} datasheet filetype:pdf site:ti.com OR site:nxp.com OR site:st.com OR site:microchip.com OR site:infineon.com OR site:analog.com") + } +} + +impl Default for DatasheetManager { + fn default() -> Self { + Self::new().unwrap_or_else(|| Self { + datasheet_dir: PathBuf::from(".zeroclaw/hardware/datasheets"), + }) + } +} + +// ── DatasheetTool ───────────────────────────────────────────────────────────── + +/// Tool: search for, download, and manage device datasheets. +/// +/// Invoked by the LLM when a user identifies a new device connected via +/// Aardvark (e.g. "I have an LM75 temperature sensor on the I2C bus"). +pub struct DatasheetTool; + +impl DatasheetTool { + pub fn new() -> Self { + Self + } +} + +impl Default for DatasheetTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for DatasheetTool { + fn name(&self) -> &str { + "datasheet" + } + + fn description(&self) -> &str { + "Search for, download, and manage device datasheets. \ + Use when the user identifies a new device connected via the Aardvark adapter \ + (e.g. 'I have an LM75 sensor'). \ + Actions: 'search' returns a web search query; \ + 'download' fetches a PDF from a URL; \ + 'list' shows cached datasheets; \ + 'read' returns the local path of a cached datasheet." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["search", "download", "list", "read"], + "description": "Operation to perform" + }, + "device_name": { + "type": "string", + "description": "Device name (e.g. 'LM75', 'PSoC6', 'MPU6050')" + }, + "url": { + "type": "string", + "description": "For action='download': direct URL to the datasheet PDF" + } + }, + "required": ["action"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = match args.get("action").and_then(|v| v.as_str()) { + Some(a) => a.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: action".to_string()), + }) + } + }; + + let mgr = DatasheetManager::default(); + + match action.as_str() { + "search" => { + let device = match args.get("device_name").and_then(|v| v.as_str()) { + Some(d) => d.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "missing required parameter: device_name for action 'search'" + .to_string(), + ), + }) + } + }; + + // Check if we already have a cached copy. + if let Some(path) = mgr.find_local(&device) { + return Ok(ToolResult { + success: true, + output: format!( + "Datasheet for '{device}' already cached at: {}\n\ + Use action='read' to get the local path.", + path.display() + ), + error: None, + }); + } + + let query = DatasheetManager::search_query(&device); + Ok(ToolResult { + success: true, + output: format!( + "Suggested web search for '{device}' datasheet:\n{query}\n\n\ + Once you have a direct PDF URL, use:\n\ + datasheet(action=\"download\", device_name=\"{device}\", url=\"\")" + ), + error: None, + }) + } + + "download" => { + let device = match args.get("device_name").and_then(|v| v.as_str()) { + Some(d) => d.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "missing required parameter: device_name for action 'download'" + .to_string(), + ), + }) + } + }; + let url = match args.get("url").and_then(|v| v.as_str()) { + Some(u) => u.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "missing required parameter: url for action 'download'".to_string(), + ), + }) + } + }; + + match mgr.download_datasheet(&url, &device).await { + Ok(path) => Ok(ToolResult { + success: true, + output: format!( + "Datasheet for '{device}' downloaded successfully.\n\ + Saved to: {}\n\n\ + Next step: create a device profile at \ + ~/.zeroclaw/hardware/devices/aardvark0.md with the key \ + registers, I2C address, and protocol notes from this datasheet.", + path.display() + ), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("download failed: {e}")), + }), + } + } + + "list" => { + let datasheets = mgr.list_datasheets(); + let output = if datasheets.is_empty() { + "No datasheets cached yet.\n\ + Use datasheet(action=\"search\", device_name=\"...\") to find one." + .to_string() + } else { + format!( + "{} cached datasheet(s) in ~/.zeroclaw/hardware/datasheets/:\n{}", + datasheets.len(), + datasheets + .iter() + .map(|n| format!(" - {n}")) + .collect::>() + .join("\n") + ) + }; + Ok(ToolResult { + success: true, + output, + error: None, + }) + } + + "read" => { + let device = match args.get("device_name").and_then(|v| v.as_str()) { + Some(d) => d.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "missing required parameter: device_name for action 'read'" + .to_string(), + ), + }) + } + }; + match mgr.find_local(&device) { + Some(path) => Ok(ToolResult { + success: true, + output: format!( + "Datasheet for '{device}' is available at: {}", + path.display() + ), + error: None, + }), + None => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "no datasheet found for '{device}'. \ + Use action='search' to find one." + )), + }), + } + } + + other => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "unknown action '{other}'. Valid: search, download, list, read" + )), + }), + } + } +} diff --git a/src/hardware/device.rs b/src/hardware/device.rs new file mode 100644 index 0000000000..c48767747a --- /dev/null +++ b/src/hardware/device.rs @@ -0,0 +1,864 @@ +//! Device types and registry — stable aliases for discovered hardware. +//! +//! The LLM always refers to devices by alias (`"pico0"`, `"arduino0"`), never +//! by raw `/dev/` paths. The `DeviceRegistry` assigns these aliases at startup +//! and provides lookup + context building for tool execution. + +use super::transport::Transport; +use std::collections::HashMap; +use std::sync::Arc; + +// ── DeviceRuntime ───────────────────────────────────────────────────────────── + +/// The software runtime / execution environment of a device. +/// +/// Determines which host-side tooling is used for code deployment and execution. +/// Currently only [`MicroPython`](DeviceRuntime::MicroPython) is implemented; +/// other variants return a clear "not yet supported" error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceRuntime { + /// MicroPython — uses `mpremote` for code read/write/exec. + MicroPython, + /// CircuitPython — `mpremote`-compatible (future). + CircuitPython, + /// Arduino — `arduino-cli` for sketch upload (future). + Arduino, + /// STM32 / probe-rs based flashing and debugging (future). + Nucleus, + /// Linux / Raspberry Pi — ssh/shell execution (future). + Linux, + /// Total Phase Aardvark I2C/SPI/GPIO USB adapter. + Aardvark, +} + +impl DeviceRuntime { + /// Derive the default runtime from a [`DeviceKind`]. + pub fn from_kind(kind: &DeviceKind) -> Self { + match kind { + DeviceKind::Pico | DeviceKind::Esp32 | DeviceKind::Generic => Self::MicroPython, + DeviceKind::Arduino => Self::Arduino, + DeviceKind::Nucleo => Self::Nucleus, + DeviceKind::Aardvark => Self::Aardvark, + } + } +} + +impl std::fmt::Display for DeviceRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MicroPython => write!(f, "MicroPython"), + Self::CircuitPython => write!(f, "CircuitPython"), + Self::Arduino => write!(f, "Arduino"), + Self::Nucleus => write!(f, "Nucleus"), + Self::Linux => write!(f, "Linux"), + Self::Aardvark => write!(f, "Aardvark"), + } + } +} + +// ── DeviceKind ──────────────────────────────────────────────────────────────── + +/// The category of a discovered hardware device. +/// +/// Derived from USB Vendor ID or, for unknown VIDs, from a successful +/// ping handshake (which yields `Generic`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceKind { + /// Raspberry Pi Pico / Pico W (VID `0x2E8A`). + Pico, + /// Arduino Uno, Mega, etc. (VID `0x2341`). + Arduino, + /// ESP32 via CP2102 bridge (VID `0x10C4`). + Esp32, + /// STM32 Nucleo (VID `0x0483`). + Nucleo, + /// Unknown VID that passed the ZeroClaw firmware ping handshake. + Generic, + /// Total Phase Aardvark USB adapter (VID `0x2B76`). + Aardvark, +} + +impl DeviceKind { + /// Derive the device kind from a USB Vendor ID. + /// Returns `None` if the VID is unknown (0 or unrecognised). + pub fn from_vid(vid: u16) -> Option { + match vid { + 0x2e8a => Some(Self::Pico), + 0x2341 => Some(Self::Arduino), + 0x10c4 => Some(Self::Esp32), + 0x0483 => Some(Self::Nucleo), + 0x2b76 => Some(Self::Aardvark), + _ => None, + } + } +} + +impl std::fmt::Display for DeviceKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pico => write!(f, "pico"), + Self::Arduino => write!(f, "arduino"), + Self::Esp32 => write!(f, "esp32"), + Self::Nucleo => write!(f, "nucleo"), + Self::Generic => write!(f, "generic"), + Self::Aardvark => write!(f, "aardvark"), + } + } +} + +/// Capability flags for a connected device. +/// +/// Populated from device handshake or static board metadata. +/// Tools can check capabilities before attempting unsupported operations. +#[derive(Debug, Clone, Default)] +#[allow(clippy::struct_excessive_bools)] +pub struct DeviceCapabilities { + pub gpio: bool, + pub i2c: bool, + pub spi: bool, + pub swd: bool, + pub uart: bool, + pub adc: bool, + pub pwm: bool, +} + +/// A discovered and registered hardware device. +#[derive(Debug, Clone)] +pub struct Device { + /// Stable session alias (e.g. `"pico0"`, `"arduino0"`, `"nucleo0"`). + pub alias: String, + /// Board name from registry (e.g. `"raspberry-pi-pico"`, `"arduino-uno"`). + pub board_name: String, + /// Device category derived from VID or ping handshake. + pub kind: DeviceKind, + /// Software runtime that determines how code is deployed/executed. + pub runtime: DeviceRuntime, + /// USB Vendor ID (if USB-connected). + pub vid: Option, + /// USB Product ID (if USB-connected). + pub pid: Option, + /// Raw device path (e.g. `"/dev/ttyACM0"`) — internal use only. + /// Tools MUST NOT use this directly; always go through Transport. + pub device_path: Option, + /// Architecture description (e.g. `"ARM Cortex-M0+"`). + pub architecture: Option, + /// Firmware identifier reported by device during ping handshake. + pub firmware: Option, +} + +impl Device { + /// Convenience accessor — same as `device_path` (matches the Phase 2 spec naming). + pub fn port(&self) -> Option<&str> { + self.device_path.as_deref() + } +} + +/// Context passed to hardware tools during execution. +/// +/// Provides the tool with access to the device identity, transport layer, +/// and capability flags without the tool managing connections itself. +pub struct DeviceContext { + /// The device this tool is operating on. + pub device: Arc, + /// Transport for sending commands to the device. + pub transport: Arc, + /// Device capabilities (gpio, i2c, spi, etc.). + pub capabilities: DeviceCapabilities, +} + +/// A registered device entry with its transport and capabilities. +struct RegisteredDevice { + device: Arc, + transport: Option>, + capabilities: DeviceCapabilities, +} + +/// Summary string returned by [`DeviceRegistry::prompt_summary`] when no +/// devices are registered. Exported so callers can compare against it without +/// duplicating the literal. +pub const NO_HW_DEVICES_SUMMARY: &str = "No hardware devices connected."; + +/// Registry of discovered devices with stable session aliases. +/// +/// - Scans at startup (via `hardware::discover`) +/// - Assigns aliases: `pico0`, `pico1`, `arduino0`, `nucleo0`, `device0`, etc. +/// - Provides alias-based lookup for tool dispatch +/// - Generates prompt summaries for LLM context +pub struct DeviceRegistry { + devices: HashMap, + alias_counters: HashMap, +} + +impl DeviceRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + devices: HashMap::new(), + alias_counters: HashMap::new(), + } + } + + /// Register a discovered device and assign a stable alias. + /// + /// Returns the assigned alias (e.g. `"pico0"`). + pub fn register( + &mut self, + board_name: &str, + vid: Option, + pid: Option, + device_path: Option, + architecture: Option, + ) -> String { + let prefix = alias_prefix(board_name); + let counter = self.alias_counters.entry(prefix.clone()).or_insert(0); + let alias = format!("{}{}", prefix, counter); + *counter += 1; + + let kind = vid + .and_then(DeviceKind::from_vid) + .unwrap_or(DeviceKind::Generic); + let runtime = DeviceRuntime::from_kind(&kind); + + let device = Arc::new(Device { + alias: alias.clone(), + board_name: board_name.to_string(), + kind, + runtime, + vid, + pid, + device_path, + architecture, + firmware: None, + }); + + self.devices.insert( + alias.clone(), + RegisteredDevice { + device, + transport: None, + capabilities: DeviceCapabilities::default(), + }, + ); + + alias + } + + /// Attach a transport and capabilities to a previously registered device. + /// + /// Returns `Err` when `alias` is not found in the registry (should not + /// happen in normal usage because callers pass aliases from `register`). + pub fn attach_transport( + &mut self, + alias: &str, + transport: Arc, + capabilities: DeviceCapabilities, + ) -> anyhow::Result<()> { + if let Some(entry) = self.devices.get_mut(alias) { + entry.transport = Some(transport); + entry.capabilities = capabilities; + Ok(()) + } else { + Err(anyhow::anyhow!("unknown device alias: {}", alias)) + } + } + + /// Look up a device by alias. + pub fn get_device(&self, alias: &str) -> Option> { + self.devices.get(alias).map(|e| e.device.clone()) + } + + /// Build a `DeviceContext` for a device by alias. + /// + /// Returns `None` if the alias is unknown or no transport is attached. + pub fn context(&self, alias: &str) -> Option { + self.devices.get(alias).and_then(|e| { + e.transport.as_ref().map(|t| DeviceContext { + device: e.device.clone(), + transport: t.clone(), + capabilities: e.capabilities.clone(), + }) + }) + } + + /// List all registered device aliases. + pub fn aliases(&self) -> Vec<&str> { + self.devices.keys().map(|s| s.as_str()).collect() + } + + /// Return a summary of connected devices for the LLM system prompt. + pub fn prompt_summary(&self) -> String { + if self.devices.is_empty() { + return NO_HW_DEVICES_SUMMARY.to_string(); + } + + let mut lines = vec!["Connected devices:".to_string()]; + let mut sorted_aliases: Vec<&String> = self.devices.keys().collect(); + sorted_aliases.sort(); + for alias in sorted_aliases { + let entry = &self.devices[alias]; + let status = entry + .transport + .as_ref() + .map(|t| { + if t.is_connected() { + "connected" + } else { + "disconnected" + } + }) + .unwrap_or("no transport"); + let arch = entry + .device + .architecture + .as_deref() + .unwrap_or("unknown arch"); + lines.push(format!( + " {} — {} ({}) [{}]", + alias, entry.device.board_name, arch, status + )); + } + lines.join("\n") + } + + /// Resolve a GPIO-capable device alias from tool arguments. + /// + /// If `args["device"]` is provided, uses that alias directly. + /// Otherwise, auto-selects the single GPIO-capable device, returning an + /// error description if zero or multiple GPIO devices are available. + /// + /// On success returns `(alias, DeviceContext)` — both are owned / Arc-based + /// so the caller can drop the registry lock before doing async I/O. + pub fn resolve_gpio_device( + &self, + args: &serde_json::Value, + ) -> Result<(String, DeviceContext), String> { + let device_alias: String = match args.get("device").and_then(|v| v.as_str()) { + Some(a) => a.to_string(), + None => { + let gpio_aliases: Vec = self + .aliases() + .into_iter() + .filter(|a| { + self.context(a) + .map(|c| c.capabilities.gpio) + .unwrap_or(false) + }) + .map(|a| a.to_string()) + .collect(); + match gpio_aliases.as_slice() { + [single] => single.clone(), + [] => { + return Err("no GPIO-capable device found; specify \"device\" parameter" + .to_string()); + } + _ => { + return Err(format!( + "multiple devices available ({}); specify \"device\" parameter", + gpio_aliases.join(", ") + )); + } + } + } + }; + + let ctx = self.context(&device_alias).ok_or_else(|| { + format!( + "device '{}' not found or has no transport attached", + device_alias + ) + })?; + + // Verify the device advertises GPIO capability. + if !ctx.capabilities.gpio { + return Err(format!( + "device '{}' does not support GPIO; specify a GPIO-capable device", + device_alias + )); + } + + Ok((device_alias, ctx)) + } + + /// Return `true` when at least one Aardvark adapter is registered. + pub fn has_aardvark(&self) -> bool { + self.devices + .values() + .any(|e| e.device.kind == DeviceKind::Aardvark) + } + + /// Resolve an Aardvark device from tool arguments. + /// + /// If `args["device"]` is provided, uses that alias directly. + /// Otherwise auto-selects the single Aardvark device, returning an error + /// description if zero or multiple Aardvark devices are available. + /// + /// Returns `(alias, DeviceContext)` — both are owned/Arc-based so the + /// caller can drop the registry lock before doing async I/O. + pub fn resolve_aardvark_device( + &self, + args: &serde_json::Value, + ) -> Result<(String, DeviceContext), String> { + let device_alias: String = match args.get("device").and_then(|v| v.as_str()) { + Some(a) => a.to_string(), + None => { + let aardvark_aliases: Vec = self + .aliases() + .into_iter() + .filter(|a| { + self.devices + .get(*a) + .map(|e| e.device.kind == DeviceKind::Aardvark) + .unwrap_or(false) + }) + .map(|a| a.to_string()) + .collect(); + match aardvark_aliases.as_slice() { + [single] => single.clone(), + [] => { + return Err("no Aardvark adapter found; is it plugged in?".to_string()); + } + _ => { + return Err(format!( + "multiple Aardvark adapters available ({}); \ + specify \"device\" parameter", + aardvark_aliases.join(", ") + )); + } + } + } + }; + + let ctx = self.context(&device_alias).ok_or_else(|| { + format!("device '{device_alias}' not found or has no transport attached") + })?; + + Ok((device_alias, ctx)) + } + + /// Number of registered devices. + pub fn len(&self) -> usize { + self.devices.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.devices.is_empty() + } + + /// Look up a device by alias (alias for `get_device` matching the Phase 2 spec). + pub fn get(&self, alias: &str) -> Option> { + self.get_device(alias) + } + + /// Return all registered devices. + pub fn all(&self) -> Vec> { + self.devices.values().map(|e| e.device.clone()).collect() + } + + /// One-line summary per device: `"pico0: raspberry-pi-pico /dev/ttyACM0"`. + /// + /// Suitable for CLI output and debug logging. + pub fn summary(&self) -> String { + if self.devices.is_empty() { + return String::new(); + } + let mut lines: Vec = self + .devices + .values() + .map(|e| { + let path = e.device.port().unwrap_or("(native)"); + format!("{}: {} {}", e.device.alias, e.device.board_name, path) + }) + .collect(); + lines.sort(); // deterministic for tests + lines.join("\n") + } + + /// Discover all connected serial devices and populate the registry. + /// + /// Steps: + /// 1. Call `discover::scan_serial_devices()` to enumerate port paths + VID/PID. + /// 2. For each device with a recognised VID: register and attach a transport. + /// 3. For unknown VID (`0`): attempt a 300 ms ping handshake; register only + /// if the device responds with ZeroClaw firmware. + /// 4. Return the populated registry. + /// + /// Returns an empty registry when no devices are found or the `hardware` + /// feature is disabled. + #[cfg(feature = "hardware")] + pub async fn discover() -> Self { + use super::{ + discover::scan_serial_devices, + serial::{HardwareSerialTransport, DEFAULT_BAUD}, + }; + + let mut registry = Self::new(); + + for info in scan_serial_devices() { + let is_known_vid = info.vid != 0; + + // For unknown VIDs, run the ping handshake before registering. + // This avoids registering random USB-serial adapters. + // If the probe succeeds we reuse the same transport instance below. + let probe_transport = if !is_known_vid { + let probe = HardwareSerialTransport::new(&info.port_path, DEFAULT_BAUD); + if !probe.ping_handshake().await { + tracing::debug!( + port = %info.port_path, + "skipping unknown device: no ZeroClaw firmware response" + ); + continue; + } + Some(probe) + } else { + None + }; + + let board_name = info.board_name.as_deref().unwrap_or("unknown").to_string(); + + let alias = registry.register( + &board_name, + if info.vid != 0 { Some(info.vid) } else { None }, + if info.pid != 0 { Some(info.pid) } else { None }, + Some(info.port_path.clone()), + info.architecture, + ); + + // For unknown-VID devices that passed ping: mark as Generic. + // (register() will have already set kind = Generic for vid=None) + + let transport: Arc = + if let Some(probe) = probe_transport { + Arc::new(probe) + } else { + Arc::new(HardwareSerialTransport::new(&info.port_path, DEFAULT_BAUD)) + }; + let caps = DeviceCapabilities { + gpio: true, // assume GPIO; Phase 3 will populate via capabilities handshake + ..DeviceCapabilities::default() + }; + registry.attach_transport(&alias, transport, caps) + .unwrap_or_else(|e| tracing::warn!(alias = %alias, err = %e, "attach_transport: unexpected unknown alias")); + + tracing::info!( + alias = %alias, + port = %info.port_path, + vid = %info.vid, + "device registered" + ); + } + + registry + } +} + +impl DeviceRegistry { + /// Reconnect a device after reboot/reflash. + /// + /// Drops the old transport, creates a fresh [`HardwareSerialTransport`] for + /// the given (or existing) port path, runs the ping handshake to confirm + /// ZeroClaw firmware is alive, and re-attaches the transport. + /// + /// Pass `new_port` when the OS assigned a different path after reboot; + /// pass `None` to reuse the device's current path. + #[cfg(feature = "hardware")] + pub async fn reconnect(&mut self, alias: &str, new_port: Option<&str>) -> anyhow::Result<()> { + use super::serial::{HardwareSerialTransport, DEFAULT_BAUD}; + + let entry = self + .devices + .get_mut(alias) + .ok_or_else(|| anyhow::anyhow!("unknown device alias: {alias}"))?; + + // Determine the port path — prefer the caller's override. + let port_path = match new_port { + Some(p) => { + // Update the device record with the new path. + let mut updated = (*entry.device).clone(); + updated.device_path = Some(p.to_string()); + entry.device = Arc::new(updated); + p.to_string() + } + None => entry + .device + .device_path + .clone() + .ok_or_else(|| anyhow::anyhow!("device {alias} has no port path"))?, + }; + + // Drop the stale transport. + entry.transport = None; + + // Create a fresh transport and verify firmware is alive. + let transport = HardwareSerialTransport::new(&port_path, DEFAULT_BAUD); + if !transport.ping_handshake().await { + anyhow::bail!( + "ping handshake failed after reconnect on {port_path} — \ + firmware may not be running" + ); + } + + entry.transport = Some(Arc::new(transport) as Arc); + entry.capabilities.gpio = true; + + tracing::info!(alias = %alias, port = %port_path, "device reconnected"); + Ok(()) + } +} + +impl Default for DeviceRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Derive alias prefix from board name. +fn alias_prefix(board_name: &str) -> String { + match board_name { + s if s.starts_with("raspberry-pi-pico") || s.starts_with("pico") => "pico".to_string(), + s if s.starts_with("arduino") => "arduino".to_string(), + s if s.starts_with("esp32") || s.starts_with("esp") => "esp".to_string(), + s if s.starts_with("nucleo") || s.starts_with("stm32") => "nucleo".to_string(), + s if s.starts_with("rpi") || s == "raspberry-pi" => "rpi".to_string(), + _ => "device".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alias_prefix_pico_variants() { + assert_eq!(alias_prefix("raspberry-pi-pico"), "pico"); + assert_eq!(alias_prefix("pico-w"), "pico"); + assert_eq!(alias_prefix("pico"), "pico"); + } + + #[test] + fn alias_prefix_arduino() { + assert_eq!(alias_prefix("arduino-uno"), "arduino"); + assert_eq!(alias_prefix("arduino-mega"), "arduino"); + } + + #[test] + fn alias_prefix_esp() { + assert_eq!(alias_prefix("esp32"), "esp"); + assert_eq!(alias_prefix("esp32-s3"), "esp"); + } + + #[test] + fn alias_prefix_nucleo() { + assert_eq!(alias_prefix("nucleo-f401re"), "nucleo"); + assert_eq!(alias_prefix("stm32-discovery"), "nucleo"); + } + + #[test] + fn alias_prefix_rpi() { + assert_eq!(alias_prefix("rpi-gpio"), "rpi"); + assert_eq!(alias_prefix("raspberry-pi"), "rpi"); + } + + #[test] + fn alias_prefix_unknown() { + assert_eq!(alias_prefix("custom-board"), "device"); + } + + #[test] + fn registry_assigns_sequential_aliases() { + let mut reg = DeviceRegistry::new(); + let a1 = reg.register("raspberry-pi-pico", Some(0x2E8A), Some(0x000A), None, None); + let a2 = reg.register("raspberry-pi-pico", Some(0x2E8A), Some(0x000A), None, None); + let a3 = reg.register("arduino-uno", Some(0x2341), Some(0x0043), None, None); + + assert_eq!(a1, "pico0"); + assert_eq!(a2, "pico1"); + assert_eq!(a3, "arduino0"); + assert_eq!(reg.len(), 3); + } + + #[test] + fn registry_get_device_by_alias() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register( + "nucleo-f401re", + Some(0x0483), + Some(0x374B), + Some("/dev/ttyACM0".to_string()), + Some("ARM Cortex-M4".to_string()), + ); + + let device = reg.get_device(&alias).unwrap(); + assert_eq!(device.alias, "nucleo0"); + assert_eq!(device.board_name, "nucleo-f401re"); + assert_eq!(device.vid, Some(0x0483)); + assert_eq!(device.architecture.as_deref(), Some("ARM Cortex-M4")); + } + + #[test] + fn registry_unknown_alias_returns_none() { + let reg = DeviceRegistry::new(); + assert!(reg.get_device("nonexistent").is_none()); + assert!(reg.context("nonexistent").is_none()); + } + + #[test] + fn registry_context_none_without_transport() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register("pico", None, None, None, None); + // No transport attached → context returns None. + assert!(reg.context(&alias).is_none()); + } + + #[test] + fn registry_prompt_summary_empty() { + let reg = DeviceRegistry::new(); + assert_eq!(reg.prompt_summary(), NO_HW_DEVICES_SUMMARY); + } + + #[test] + fn registry_prompt_summary_with_devices() { + let mut reg = DeviceRegistry::new(); + reg.register( + "raspberry-pi-pico", + Some(0x2E8A), + None, + None, + Some("ARM Cortex-M0+".to_string()), + ); + let summary = reg.prompt_summary(); + assert!(summary.contains("pico0")); + assert!(summary.contains("raspberry-pi-pico")); + assert!(summary.contains("ARM Cortex-M0+")); + assert!(summary.contains("no transport")); + } + + #[test] + fn device_capabilities_default_all_false() { + let caps = DeviceCapabilities::default(); + assert!(!caps.gpio); + assert!(!caps.i2c); + assert!(!caps.spi); + assert!(!caps.swd); + assert!(!caps.uart); + assert!(!caps.adc); + assert!(!caps.pwm); + } + + #[test] + fn registry_default_is_empty() { + let reg = DeviceRegistry::default(); + assert!(reg.is_empty()); + assert_eq!(reg.len(), 0); + } + + #[test] + fn registry_aliases_returns_all() { + let mut reg = DeviceRegistry::new(); + reg.register("pico", None, None, None, None); + reg.register("arduino-uno", None, None, None, None); + let mut aliases = reg.aliases(); + aliases.sort_unstable(); + assert_eq!(aliases, vec!["arduino0", "pico0"]); + } + + // ── Phase 2 new tests ──────────────────────────────────────────────────── + + #[test] + fn device_kind_from_vid_known() { + assert_eq!(DeviceKind::from_vid(0x2e8a), Some(DeviceKind::Pico)); + assert_eq!(DeviceKind::from_vid(0x2341), Some(DeviceKind::Arduino)); + assert_eq!(DeviceKind::from_vid(0x10c4), Some(DeviceKind::Esp32)); + assert_eq!(DeviceKind::from_vid(0x0483), Some(DeviceKind::Nucleo)); + } + + #[test] + fn device_kind_from_vid_unknown() { + assert_eq!(DeviceKind::from_vid(0x0000), None); + assert_eq!(DeviceKind::from_vid(0xffff), None); + } + + #[test] + fn device_kind_display() { + assert_eq!(DeviceKind::Pico.to_string(), "pico"); + assert_eq!(DeviceKind::Arduino.to_string(), "arduino"); + assert_eq!(DeviceKind::Esp32.to_string(), "esp32"); + assert_eq!(DeviceKind::Nucleo.to_string(), "nucleo"); + assert_eq!(DeviceKind::Generic.to_string(), "generic"); + } + + #[test] + fn register_sets_kind_from_vid() { + let mut reg = DeviceRegistry::new(); + let a = reg.register("raspberry-pi-pico", Some(0x2e8a), Some(0x000a), None, None); + assert_eq!(reg.get(&a).unwrap().kind, DeviceKind::Pico); + + let b = reg.register("arduino-uno", Some(0x2341), Some(0x0043), None, None); + assert_eq!(reg.get(&b).unwrap().kind, DeviceKind::Arduino); + + let c = reg.register("unknown-device", None, None, None, None); + assert_eq!(reg.get(&c).unwrap().kind, DeviceKind::Generic); + } + + #[test] + fn device_port_returns_device_path() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register( + "raspberry-pi-pico", + Some(0x2e8a), + None, + Some("/dev/ttyACM0".to_string()), + None, + ); + let device = reg.get(&alias).unwrap(); + assert_eq!(device.port(), Some("/dev/ttyACM0")); + } + + #[test] + fn device_port_none_without_path() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register("pico", None, None, None, None); + assert!(reg.get(&alias).unwrap().port().is_none()); + } + + #[test] + fn registry_get_is_alias_for_get_device() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register("raspberry-pi-pico", Some(0x2e8a), None, None, None); + let via_get = reg.get(&alias); + let via_get_device = reg.get_device(&alias); + assert!(via_get.is_some()); + assert!(via_get_device.is_some()); + assert_eq!(via_get.unwrap().alias, via_get_device.unwrap().alias); + } + + #[test] + fn registry_all_returns_every_device() { + let mut reg = DeviceRegistry::new(); + reg.register("raspberry-pi-pico", Some(0x2e8a), None, None, None); + reg.register("arduino-uno", Some(0x2341), None, None, None); + assert_eq!(reg.all().len(), 2); + } + + #[test] + fn registry_summary_one_liner_per_device() { + let mut reg = DeviceRegistry::new(); + reg.register( + "raspberry-pi-pico", + Some(0x2e8a), + None, + Some("/dev/ttyACM0".to_string()), + None, + ); + let s = reg.summary(); + assert!(s.contains("pico0")); + assert!(s.contains("raspberry-pi-pico")); + assert!(s.contains("/dev/ttyACM0")); + } + + #[test] + fn registry_summary_empty_when_no_devices() { + let reg = DeviceRegistry::new(); + assert_eq!(reg.summary(), ""); + } +} diff --git a/src/hardware/gpio.rs b/src/hardware/gpio.rs new file mode 100644 index 0000000000..fafd6ba53f --- /dev/null +++ b/src/hardware/gpio.rs @@ -0,0 +1,628 @@ +//! GPIO tools — `gpio_read` and `gpio_write` for LLM-driven hardware control. +//! +//! These are the first built-in hardware tools. They implement the standard +//! [`Tool`](crate::tools::Tool) trait so the LLM can call them via function +//! calling, and dispatch commands to physical devices via the +//! [`Transport`](super::Transport) layer. +//! +//! Wire protocol (ZeroClaw serial JSON): +//! ```text +//! gpio_write: +//! Host → Device: {"cmd":"gpio_write","params":{"pin":25,"value":1}}\n +//! Device → Host: {"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n +//! +//! gpio_read: +//! Host → Device: {"cmd":"gpio_read","params":{"pin":25}}\n +//! Device → Host: {"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n +//! ``` + +use super::device::DeviceRegistry; +use super::protocol::ZcCommand; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +// ── GpioWriteTool ───────────────────────────────────────────────────────────── + +/// Tool: set a GPIO pin HIGH or LOW on a connected hardware device. +/// +/// The LLM provides `device` (alias), `pin`, and `value` (0 or 1). +/// The tool builds a `ZcCommand`, sends it via the device's transport, +/// and returns a human-readable result. +pub struct GpioWriteTool { + registry: Arc>, +} + +impl GpioWriteTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for GpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin HIGH (1) or LOW (0) on a connected hardware device" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, arduino0" + }, + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "enum": [0, 1], + "description": "1 = HIGH (on), 0 = LOW (off)" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let pin = match args.get("pin").and_then(|v| v.as_u64()) { + Some(p) => p, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: pin".to_string()), + }) + } + }; + let value = match args.get("value").and_then(|v| v.as_u64()) { + Some(v) => v, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: value".to_string()), + }) + } + }; + + if value > 1 { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("value must be 0 or 1".to_string()), + }); + } + + // Resolve device alias and obtain an owned context (Arc-based) before + // dropping the registry read guard — avoids holding the lock across async I/O. + let (device_alias, ctx) = { + let registry = self.registry.read().await; + match registry.resolve_gpio_device(&args) { + Ok(resolved) => resolved, + Err(msg) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(msg), + }); + } + } + // registry read guard dropped here + }; + + let cmd = ZcCommand::new("gpio_write", json!({ "pin": pin, "value": value })); + + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let state = resp + .data + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or(if value == 1 { "HIGH" } else { "LOW" }); + Ok(ToolResult { + success: true, + output: format!("GPIO {} set {} on {}", pin, state, device_alias), + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {}", e)), + }), + } + } +} + +// ── GpioReadTool ────────────────────────────────────────────────────────────── + +/// Tool: read the current HIGH/LOW state of a GPIO pin on a connected device. +/// +/// The LLM provides `device` (alias) and `pin`. The tool builds a `ZcCommand`, +/// sends it via the device's transport, and returns the pin state. +pub struct GpioReadTool { + registry: Arc>, +} + +impl GpioReadTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for GpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the current HIGH/LOW state of a GPIO pin on a connected device" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, arduino0" + }, + "pin": { + "type": "integer", + "description": "GPIO pin number to read" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let pin = match args.get("pin").and_then(|v| v.as_u64()) { + Some(p) => p, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: pin".to_string()), + }) + } + }; + + // Resolve device alias and obtain an owned context (Arc-based) before + // dropping the registry read guard — avoids holding the lock across async I/O. + let (device_alias, ctx) = { + let registry = self.registry.read().await; + match registry.resolve_gpio_device(&args) { + Ok(resolved) => resolved, + Err(msg) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(msg), + }); + } + } + // registry read guard dropped here + }; + + let cmd = ZcCommand::new("gpio_read", json!({ "pin": pin })); + + match ctx.transport.send(&cmd).await { + Ok(resp) if resp.ok => { + let value = resp.data.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + let state = resp + .data + .get("state") + .and_then(|v| v.as_str()) + .unwrap_or(if value == 1 { "HIGH" } else { "LOW" }); + Ok(ToolResult { + success: true, + output: format!("GPIO {} is {} ({}) on {}", pin, state, value, device_alias), + error: None, + }) + } + Ok(resp) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + resp.error + .unwrap_or_else(|| "device returned ok:false".to_string()), + ), + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("transport error: {}", e)), + }), + } + } +} + +// ── Factory ─────────────────────────────────────────────────────────────────── + +/// Create the built-in GPIO tools for a given device registry. +/// +/// Returns `[GpioWriteTool, GpioReadTool]` ready for registration in the +/// agent's tool list or a future `ToolRegistry`. +pub fn gpio_tools(registry: Arc>) -> Vec> { + vec![ + Box::new(GpioWriteTool::new(registry.clone())), + Box::new(GpioReadTool::new(registry)), + ] +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::hardware::{ + device::{DeviceCapabilities, DeviceRegistry}, + protocol::ZcResponse, + transport::{Transport, TransportError, TransportKind}, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// Mock transport that returns configurable responses. + struct MockTransport { + response: tokio::sync::Mutex, + connected: AtomicBool, + last_cmd: tokio::sync::Mutex>, + } + + impl MockTransport { + fn new(response: ZcResponse) -> Self { + Self { + response: tokio::sync::Mutex::new(response), + connected: AtomicBool::new(true), + last_cmd: tokio::sync::Mutex::new(None), + } + } + + fn disconnected() -> Self { + let t = Self::new(ZcResponse::error("mock: disconnected")); + t.connected.store(false, Ordering::SeqCst); + t + } + + async fn last_command(&self) -> Option { + self.last_cmd.lock().await.clone() + } + } + + #[async_trait] + impl Transport for MockTransport { + async fn send(&self, cmd: &ZcCommand) -> Result { + if !self.connected.load(Ordering::SeqCst) { + return Err(TransportError::Disconnected); + } + *self.last_cmd.lock().await = Some(cmd.clone()); + Ok(self.response.lock().await.clone()) + } + + fn kind(&self) -> TransportKind { + TransportKind::Serial + } + + fn is_connected(&self) -> bool { + self.connected.load(Ordering::SeqCst) + } + } + + /// Helper: build a registry with one device + mock transport. + fn registry_with_mock(transport: Arc) -> Arc> { + let mut reg = DeviceRegistry::new(); + let alias = reg.register( + "raspberry-pi-pico", + Some(0x2e8a), + Some(0x000a), + Some("/dev/ttyACM0".to_string()), + Some("ARM Cortex-M0+".to_string()), + ); + reg.attach_transport( + &alias, + transport as Arc, + DeviceCapabilities { + gpio: true, + ..Default::default() + }, + ) + .expect("alias was just registered"); + Arc::new(RwLock::new(reg)) + } + + // ── GpioWriteTool tests ────────────────────────────────────────────── + + #[tokio::test] + async fn gpio_write_success() { + let mock = Arc::new(MockTransport::new(ZcResponse::success( + json!({"pin": 25, "value": 1, "state": "HIGH"}), + ))); + let reg = registry_with_mock(mock.clone()); + let tool = GpioWriteTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 25, "value": 1})) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "GPIO 25 set HIGH on pico0"); + assert!(result.error.is_none()); + + // Verify the command sent to the device + let cmd = mock.last_command().await.unwrap(); + assert_eq!(cmd.cmd, "gpio_write"); + assert_eq!(cmd.params["pin"], 25); + assert_eq!(cmd.params["value"], 1); + } + + #[tokio::test] + async fn gpio_write_low() { + let mock = Arc::new(MockTransport::new(ZcResponse::success( + json!({"pin": 13, "value": 0, "state": "LOW"}), + ))); + let reg = registry_with_mock(mock.clone()); + let tool = GpioWriteTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 13, "value": 0})) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "GPIO 13 set LOW on pico0"); + } + + #[tokio::test] + async fn gpio_write_device_error() { + let mock = Arc::new(MockTransport::new(ZcResponse::error( + "pin 99 not available", + ))); + let reg = registry_with_mock(mock); + let tool = GpioWriteTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 99, "value": 1})) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!(result.error.as_deref(), Some("pin 99 not available")); + } + + #[tokio::test] + async fn gpio_write_transport_disconnected() { + let mock = Arc::new(MockTransport::disconnected()); + let reg = registry_with_mock(mock); + let tool = GpioWriteTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 25, "value": 1})) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_deref().unwrap().contains("transport")); + } + + #[tokio::test] + async fn gpio_write_unknown_device() { + let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({})))); + let reg = registry_with_mock(mock); + let tool = GpioWriteTool::new(reg); + + let result = tool + .execute(json!({"device": "nonexistent", "pin": 25, "value": 1})) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_deref().unwrap().contains("not found")); + } + + #[tokio::test] + async fn gpio_write_invalid_value() { + let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({})))); + let reg = registry_with_mock(mock); + let tool = GpioWriteTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 25, "value": 5})) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!(result.error.as_deref(), Some("value must be 0 or 1")); + } + + #[tokio::test] + async fn gpio_write_missing_params() { + let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({})))); + let reg = registry_with_mock(mock); + let tool = GpioWriteTool::new(reg); + + // Missing pin + let result = tool + .execute(json!({"device": "pico0", "value": 1})) + .await + .unwrap(); + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("missing required parameter: pin")); + + // Missing device with empty registry — auto-select finds no GPIO device → Ok(failure) + let empty_reg = Arc::new(RwLock::new(DeviceRegistry::new())); + let tool_no_reg = GpioWriteTool::new(empty_reg); + let result = tool_no_reg + .execute(json!({"pin": 25, "value": 1})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("no GPIO")); + + // Missing value + let result = tool + .execute(json!({"device": "pico0", "pin": 25})) + .await + .unwrap(); + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("missing required parameter: value")); + } + + // ── GpioReadTool tests ─────────────────────────────────────────────── + + #[tokio::test] + async fn gpio_read_success() { + let mock = Arc::new(MockTransport::new(ZcResponse::success( + json!({"pin": 25, "value": 1, "state": "HIGH"}), + ))); + let reg = registry_with_mock(mock.clone()); + let tool = GpioReadTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 25})) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "GPIO 25 is HIGH (1) on pico0"); + assert!(result.error.is_none()); + + let cmd = mock.last_command().await.unwrap(); + assert_eq!(cmd.cmd, "gpio_read"); + assert_eq!(cmd.params["pin"], 25); + } + + #[tokio::test] + async fn gpio_read_low() { + let mock = Arc::new(MockTransport::new(ZcResponse::success( + json!({"pin": 13, "value": 0, "state": "LOW"}), + ))); + let reg = registry_with_mock(mock); + let tool = GpioReadTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 13})) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.output, "GPIO 13 is LOW (0) on pico0"); + } + + #[tokio::test] + async fn gpio_read_device_error() { + let mock = Arc::new(MockTransport::new(ZcResponse::error("pin not configured"))); + let reg = registry_with_mock(mock); + let tool = GpioReadTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 99})) + .await + .unwrap(); + + assert!(!result.success); + assert_eq!(result.error.as_deref(), Some("pin not configured")); + } + + #[tokio::test] + async fn gpio_read_transport_disconnected() { + let mock = Arc::new(MockTransport::disconnected()); + let reg = registry_with_mock(mock); + let tool = GpioReadTool::new(reg); + + let result = tool + .execute(json!({"device": "pico0", "pin": 25})) + .await + .unwrap(); + + assert!(!result.success); + assert!(result.error.as_deref().unwrap().contains("transport")); + } + + #[tokio::test] + async fn gpio_read_missing_params() { + let mock = Arc::new(MockTransport::new(ZcResponse::success(json!({})))); + let reg = registry_with_mock(mock); + let tool = GpioReadTool::new(reg); + + // Missing pin + let result = tool.execute(json!({"device": "pico0"})).await.unwrap(); + assert!(!result.success); + assert!(result + .error + .as_deref() + .unwrap_or("") + .contains("missing required parameter: pin")); + + // Missing device with empty registry — auto-select finds no GPIO device → Ok(failure) + let empty_reg = Arc::new(RwLock::new(DeviceRegistry::new())); + let tool_no_reg = GpioReadTool::new(empty_reg); + let result = tool_no_reg.execute(json!({"pin": 25})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("no GPIO")); + } + + // ── Factory / spec tests ───────────────────────────────────────────── + + #[test] + fn gpio_tools_factory_returns_two() { + let reg = Arc::new(RwLock::new(DeviceRegistry::new())); + let tools = gpio_tools(reg); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].name(), "gpio_write"); + assert_eq!(tools[1].name(), "gpio_read"); + } + + #[test] + fn gpio_write_spec_is_valid() { + let reg = Arc::new(RwLock::new(DeviceRegistry::new())); + let tool = GpioWriteTool::new(reg); + let spec = tool.spec(); + assert_eq!(spec.name, "gpio_write"); + assert!(spec.parameters["properties"]["device"].is_object()); + assert!(spec.parameters["properties"]["pin"].is_object()); + assert!(spec.parameters["properties"]["value"].is_object()); + let required = spec.parameters["required"].as_array().unwrap(); + assert_eq!(required.len(), 2, "required should be [pin, value]"); + } + + #[test] + fn gpio_read_spec_is_valid() { + let reg = Arc::new(RwLock::new(DeviceRegistry::new())); + let tool = GpioReadTool::new(reg); + let spec = tool.spec(); + assert_eq!(spec.name, "gpio_read"); + assert!(spec.parameters["properties"]["device"].is_object()); + assert!(spec.parameters["properties"]["pin"].is_object()); + let required = spec.parameters["required"].as_array().unwrap(); + assert_eq!(required.len(), 1, "required should be [pin]"); + } +} diff --git a/src/hardware/loader.rs b/src/hardware/loader.rs new file mode 100644 index 0000000000..ae475042c7 --- /dev/null +++ b/src/hardware/loader.rs @@ -0,0 +1,327 @@ +//! Plugin manifest loader — scans `~/.zeroclaw/tools/` at startup. +//! +//! Layout expected on disk: +//! ```text +//! ~/.zeroclaw/tools/ +//! ├── i2c_scan/ +//! │ ├── tool.toml +//! │ └── i2c_scan.py +//! └── pwm_set/ +//! ├── tool.toml +//! └── pwm_set +//! ``` +//! +//! Rules: +//! - The directory is **created** if it does not exist. +//! - Each subdirectory is scanned for a `tool.toml`. +//! - Manifests that fail to parse or validate are **skipped with a warning**; +//! they must not crash startup. +//! - Non-directory entries at the top level are silently ignored. + +use super::manifest::ToolManifest; +use super::subprocess::SubprocessTool; +use crate::tools::traits::Tool; +use anyhow::Result; +use std::fs; +use std::path::{Path, PathBuf}; + +/// A successfully loaded plugin, ready for registration. +pub struct LoadedPlugin { + /// Tool name from the manifest (unique key in [`ToolRegistry`]). + pub name: String, + /// Semantic version string from the manifest. + pub version: String, + /// The constructed tool, boxed for dynamic dispatch. + pub tool: Box, +} + +/// Scan `~/.zeroclaw/tools/` and return all valid plugins. +/// +/// - Creates the directory if absent. +/// - Skips broken manifests with a `tracing::warn!` — does not propagate errors. +/// - Returns an empty `Vec` when no plugins are installed. +pub fn scan_plugin_dir() -> Vec { + let tools_dir = match plugin_tools_dir() { + Ok(p) => p, + Err(e) => { + tracing::warn!("[registry] cannot resolve plugin tools dir: {}", e); + return Vec::new(); + } + }; + + // Create the directory tree if it is missing. + if !tools_dir.exists() { + if let Err(e) = fs::create_dir_all(&tools_dir) { + tracing::warn!( + "[registry] could not create {:?}: {}", + tools_dir.display(), + e + ); + return Vec::new(); + } + tracing::info!( + "[registry] created plugin directory: {}", + tools_dir.display() + ); + } + + println!( + "[registry] scanning {}...", + match dirs_home().as_deref().filter(|s| !s.is_empty()) { + Some(home) => tools_dir + .to_str() + .unwrap_or("~/.zeroclaw/tools") + .replace(home, "~"), + None => tools_dir + .to_str() + .unwrap_or("~/.zeroclaw/tools") + .to_string(), + } + ); + + let mut plugins = Vec::new(); + + let entries = match fs::read_dir(&tools_dir) { + Ok(e) => e, + Err(e) => { + tracing::warn!("[registry] cannot read tools dir: {}", e); + return Vec::new(); + } + }; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + tracing::warn!("[registry] skipping unreadable dir entry: {}", e); + continue; + } + }; + + let plugin_dir = entry.path(); + + // Only descend into subdirectories. + if !plugin_dir.is_dir() { + continue; + } + + let manifest_path = plugin_dir.join("tool.toml"); + + if !manifest_path.exists() { + tracing::debug!( + "[registry] no tool.toml in {:?} — skipping", + plugin_dir.file_name().unwrap_or_default() + ); + continue; + } + + match load_one_plugin(&plugin_dir, &manifest_path) { + Ok(plugin) => plugins.push(plugin), + Err(e) => { + tracing::warn!( + "[registry] skipping plugin in {:?}: {}", + plugin_dir.file_name().unwrap_or_default(), + e + ); + } + } + } + + plugins +} + +/// Parse and validate a single plugin directory. +/// +/// Returns `Err` on any validation failure so the caller can log and continue. +fn load_one_plugin(plugin_dir: &Path, manifest_path: &Path) -> Result { + let raw = fs::read_to_string(manifest_path) + .map_err(|e| anyhow::anyhow!("cannot read tool.toml: {}", e))?; + + let manifest: ToolManifest = toml::from_str(&raw) + .map_err(|e| anyhow::anyhow!("TOML parse error in tool.toml: {}", e))?; + + // Validate required fields — fail fast with a descriptive error. + if manifest.tool.name.trim().is_empty() { + anyhow::bail!("manifest missing [tool] name"); + } + if manifest.tool.description.trim().is_empty() { + anyhow::bail!("manifest missing [tool] description"); + } + if manifest.exec.binary.trim().is_empty() { + anyhow::bail!("manifest missing [exec] binary"); + } + + // Validate binary path: must exist, be a regular file, and reside within plugin_dir. + let canonical_plugin_dir = plugin_dir.canonicalize().map_err(|e| { + anyhow::anyhow!( + "cannot canonicalize plugin dir {}: {}", + plugin_dir.display(), + e + ) + })?; + let raw_binary_path = plugin_dir.join(&manifest.exec.binary); + if !raw_binary_path.exists() { + anyhow::bail!( + "manifest exec binary not found: {}", + raw_binary_path.display() + ); + } + let binary_path = raw_binary_path.canonicalize().map_err(|e| { + anyhow::anyhow!( + "cannot canonicalize binary path {}: {}", + raw_binary_path.display(), + e + ) + })?; + if !binary_path.starts_with(&canonical_plugin_dir) { + anyhow::bail!( + "manifest exec binary escapes plugin directory: {} is not under {}", + binary_path.display(), + canonical_plugin_dir.display() + ); + } + if !binary_path.is_file() { + anyhow::bail!( + "manifest exec binary is not a regular file: {}", + binary_path.display() + ); + } + + let name = manifest.tool.name.clone(); + let version = manifest.tool.version.clone(); + let tool: Box = Box::new(SubprocessTool::new(manifest, binary_path)); + + Ok(LoadedPlugin { + name, + version, + tool, + }) +} + +/// Return the path `~/.zeroclaw/tools/` using the `directories` crate. +pub fn plugin_tools_dir() -> Result { + use directories::BaseDirs; + let base = BaseDirs::new() + .ok_or_else(|| anyhow::anyhow!("cannot determine the user home directory"))?; + Ok(base.home_dir().join(".zeroclaw").join("tools")) +} + +/// Best-effort home dir string for display purposes only. +fn dirs_home() -> Option { + use directories::BaseDirs; + BaseDirs::new().map(|b| b.home_dir().to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn write_valid_manifest(dir: &Path) { + let toml = r#" +[tool] +name = "test_plugin" +version = "1.0.0" +description = "A deterministic test plugin" + +[exec] +binary = "tool.sh" + +[[parameters]] +name = "device" +type = "string" +description = "Device alias" +required = true +"#; + fs::write(dir.join("tool.toml"), toml).unwrap(); + // Write a dummy binary (content doesn't matter for manifest loading). + fs::write( + dir.join("tool.sh"), + "#!/bin/sh\necho '{\"success\":true,\"output\":\"ok\",\"error\":null}'\n", + ) + .unwrap(); + } + + #[test] + fn load_one_plugin_succeeds_for_valid_manifest() { + let dir = tempfile::tempdir().unwrap(); + write_valid_manifest(dir.path()); + + let manifest_path = dir.path().join("tool.toml"); + let plugin = load_one_plugin(dir.path(), &manifest_path).unwrap(); + + assert_eq!(plugin.name, "test_plugin"); + assert_eq!(plugin.version, "1.0.0"); + assert_eq!(plugin.tool.name(), "test_plugin"); + } + + #[test] + fn load_one_plugin_fails_on_missing_name() { + let dir = tempfile::tempdir().unwrap(); + let toml = r#" +[tool] +name = "" +version = "1.0.0" +description = "Missing name test" + +[exec] +binary = "tool.sh" +"#; + fs::write(dir.path().join("tool.toml"), toml).unwrap(); + + let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml")); + match result { + Err(e) => assert!(e.to_string().contains("name"), "unexpected error: {}", e), + Ok(_) => panic!("expected an error for missing name"), + } + } + + #[test] + fn load_one_plugin_fails_on_parse_error() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("tool.toml"), "not valid toml {{{{").unwrap(); + + let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml")); + match result { + Err(e) => assert!( + e.to_string().contains("TOML parse error"), + "unexpected error: {}", + e + ), + Ok(_) => panic!("expected a parse error"), + } + } + + #[test] + fn scan_plugin_dir_skips_broken_manifests_without_panicking() { + // We can't redirect scan_plugin_dir to an arbitrary directory (it + // always uses ~/.zeroclaw/tools), but we can verify load_one_plugin + // behaviour under broken input without affecting the real directory. + let dir = tempfile::tempdir().unwrap(); + + // Plugin 1: valid + let p1 = dir.path().join("good"); + fs::create_dir_all(&p1).unwrap(); + write_valid_manifest(&p1); + + // Plugin 2: broken TOML + let p2 = dir.path().join("bad"); + fs::create_dir_all(&p2).unwrap(); + fs::write(p2.join("tool.toml"), "{{broken").unwrap(); + + // Load manually to simulate what scan_plugin_dir does. + let good = load_one_plugin(&p1, &p1.join("tool.toml")); + let bad = load_one_plugin(&p2, &p2.join("tool.toml")); + + assert!(good.is_ok(), "good plugin should load"); + assert!(bad.is_err(), "bad plugin should error, not panic"); + } + + #[test] + fn plugin_tools_dir_returns_path_ending_in_zeroclaw_tools() { + let path = plugin_tools_dir().expect("should resolve"); + let display = path.to_string_lossy(); + let expected = std::path::Path::new(".zeroclaw").join("tools"); + assert!(path.ends_with(&expected), "unexpected path: {}", display); + } +} diff --git a/src/hardware/manifest.rs b/src/hardware/manifest.rs new file mode 100644 index 0000000000..c5f61143d0 --- /dev/null +++ b/src/hardware/manifest.rs @@ -0,0 +1,194 @@ +//! Plugin manifest — `~/.zeroclaw/tools//tool.toml` schema. +//! +//! Each user plugin lives in its own subdirectory and carries a `tool.toml` +//! that describes the tool, how to invoke it, and what parameters it accepts. +//! +//! Example `tool.toml`: +//! ```toml +//! [tool] +//! name = "i2c_scan" +//! version = "1.0.0" +//! description = "Scan the I2C bus for connected devices" +//! +//! [exec] +//! binary = "i2c_scan.py" +//! +//! [transport] +//! preferred = "serial" +//! device_required = true +//! +//! [[parameters]] +//! name = "device" +//! type = "string" +//! description = "Device alias e.g. pico0" +//! required = true +//! +//! [[parameters]] +//! name = "bus" +//! type = "integer" +//! description = "I2C bus number (default 0)" +//! required = false +//! default = 0 +//! ``` + +use serde::Deserialize; + +/// Full plugin manifest — parsed from `tool.toml`. +#[derive(Debug, Deserialize)] +pub struct ToolManifest { + /// Tool identity and human-readable metadata. + pub tool: ToolMeta, + /// How to invoke the tool binary. + pub exec: ExecConfig, + /// Optional transport preference and device requirement. + pub transport: Option, + /// Parameter definitions used to build the JSON Schema for the LLM. + #[serde(default)] + pub parameters: Vec, +} + +/// Tool identity metadata. +#[derive(Debug, Deserialize)] +pub struct ToolMeta { + /// Unique tool name, used as the function-call key by the LLM. + pub name: String, + /// Semantic version string (e.g. `"1.0.0"`). + pub version: String, + /// Human-readable description injected into the LLM system prompt. + pub description: String, +} + +/// Execution configuration — how ZeroClaw spawns the tool. +#[derive(Debug, Deserialize)] +pub struct ExecConfig { + /// Path to the binary, relative to the plugin directory. + /// + /// Can be a Python script (`"tool.py"`), a shell script (`"run.sh"`), + /// a compiled binary (`"i2c_scan"`), or any executable. + pub binary: String, +} + +/// Optional transport hint for the tool. +/// +/// When present, ZeroClaw will prefer the named transport kind +/// and can enforce device presence before calling the tool. +#[derive(Debug, Deserialize)] +pub struct TransportConfig { + /// Preferred transport kind: `"serial"` | `"swd"` | `"native"` | `"any"`. + pub preferred: String, + /// Whether the tool requires a hardware device to be connected. + pub device_required: bool, +} + +/// A single parameter definition for a plugin tool. +#[derive(Debug, Deserialize)] +pub struct ParameterDef { + /// Parameter name (matches the JSON key passed to the tool via stdin). + pub name: String, + /// JSON Schema primitive type: `"string"` | `"integer"` | `"boolean"`. + #[serde(rename = "type")] + pub r#type: String, + /// Human-readable description shown to the LLM. + pub description: String, + /// Whether the LLM must supply this parameter. + pub required: bool, + /// Optional default value serialized as a JSON Value. + pub default: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL_TOML: &str = r#" +[tool] +name = "i2c_scan" +version = "1.0.0" +description = "Scan the I2C bus" + +[exec] +binary = "i2c_scan.py" + +[[parameters]] +name = "device" +type = "string" +description = "Device alias" +required = true +"#; + + #[test] + fn manifest_parses_minimal_toml() { + let m: ToolManifest = toml::from_str(MINIMAL_TOML).expect("parse failed"); + assert_eq!(m.tool.name, "i2c_scan"); + assert_eq!(m.tool.version, "1.0.0"); + assert_eq!(m.exec.binary, "i2c_scan.py"); + assert!(m.transport.is_none()); + assert_eq!(m.parameters.len(), 1); + assert_eq!(m.parameters[0].name, "device"); + assert!(m.parameters[0].required); + } + + const FULL_TOML: &str = r#" +[tool] +name = "pwm_set" +version = "1.0.0" +description = "Set PWM duty cycle on a pin" + +[exec] +binary = "pwm_set" + +[transport] +preferred = "serial" +device_required = true + +[[parameters]] +name = "device" +type = "string" +description = "Device alias" +required = true + +[[parameters]] +name = "pin" +type = "integer" +description = "PWM pin number" +required = true + +[[parameters]] +name = "duty" +type = "integer" +description = "Duty cycle 0–100" +required = false +default = 50 +"#; + + #[test] + fn manifest_parses_full_toml_with_transport_and_defaults() { + let m: ToolManifest = toml::from_str(FULL_TOML).expect("parse failed"); + assert_eq!(m.tool.name, "pwm_set"); + let transport = m.transport.as_ref().expect("transport missing"); + assert_eq!(transport.preferred, "serial"); + assert!(transport.device_required); + let duty = m + .parameters + .iter() + .find(|p| p.name == "duty") + .expect("duty param missing"); + assert!(!duty.required); + assert_eq!(duty.default, Some(serde_json::json!(50))); + } + + #[test] + fn manifest_empty_parameters_default_to_empty_vec() { + let raw = r#" +[tool] +name = "noop" +version = "0.1.0" +description = "No-op tool" + +[exec] +binary = "noop" +"#; + let m: ToolManifest = toml::from_str(raw).expect("parse failed"); + assert!(m.parameters.is_empty()); + } +} diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index a1fa82314e..e6b672085d 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -2,7 +2,11 @@ //! //! See `docs/hardware-peripherals-design.md` for the full design. +pub mod device; +pub mod gpio; +pub mod protocol; pub mod registry; +pub mod transport; #[cfg(all( feature = "hardware", @@ -16,12 +20,377 @@ pub mod discover; ))] pub mod introspect; +#[cfg(feature = "hardware")] +pub mod serial; + +#[cfg(feature = "hardware")] +pub mod uf2; + +#[cfg(feature = "hardware")] +pub mod pico_flash; + +#[cfg(feature = "hardware")] +pub mod pico_code; + +/// Aardvark USB adapter transport (I2C / SPI / GPIO via aardvark-sys). +#[cfg(feature = "hardware")] +pub mod aardvark; + +/// Tools backed by the Aardvark transport (i2c_scan, i2c_read, i2c_write, +/// spi_transfer, gpio_aardvark). +#[cfg(feature = "hardware")] +pub mod aardvark_tools; + +/// Datasheet management — search, download, and manage device datasheets. +/// Used by DatasheetTool when an Aardvark is connected. +#[cfg(feature = "hardware")] +pub mod datasheet; + +/// Raspberry Pi self-discovery and native GPIO tools. +/// Only compiled on Linux with the `peripheral-rpi` feature. +#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] +pub mod rpi; + +// ── Phase 4: ToolRegistry + plugin system ───────────────────────────────────── +pub mod loader; +pub mod manifest; +pub mod subprocess; +pub mod tool_registry; + +#[cfg(feature = "hardware")] +#[allow(unused_imports)] +pub use aardvark::AardvarkTransport; + use crate::config::Config; +use crate::hardware::device::DeviceRegistry; use anyhow::Result; +#[allow(unused_imports)] +pub use tool_registry::{ToolError, ToolRegistry}; // Re-export config types so wizard can use `hardware::HardwareConfig` etc. pub use crate::config::{HardwareConfig, HardwareTransport}; +// ── Phase 5: boot() — hardware tool integration into agent loop ─────────────── + +/// Merge hardware tools from a [`HardwareBootResult`] into an existing tool +/// registry, deduplicating by name. +/// +/// Returns a tuple of `(device_summary, added_tool_names)`. +pub fn merge_hardware_tools( + tools: &mut Vec>, + hw_boot: HardwareBootResult, +) -> (String, Vec) { + let device_summary = hw_boot.device_summary.clone(); + let mut added_tool_names: Vec = Vec::new(); + if !hw_boot.tools.is_empty() { + let existing: std::collections::HashSet = + tools.iter().map(|t| t.name().to_string()).collect(); + let new_hw_tools: Vec> = hw_boot + .tools + .into_iter() + .filter(|t| !existing.contains(t.name())) + .collect(); + if !new_hw_tools.is_empty() { + added_tool_names = new_hw_tools.iter().map(|t| t.name().to_string()).collect(); + tracing::info!(count = new_hw_tools.len(), "Hardware registry tools added"); + tools.extend(new_hw_tools); + } + } + (device_summary, added_tool_names) +} + +/// Result of [`boot`]: tools to merge into the agent + device summary for the +/// system prompt. +pub struct HardwareBootResult { + /// Tools to extend into the agent's `tools_registry`. + pub tools: Vec>, + /// Human-readable device summary for the LLM system prompt. + pub device_summary: String, + /// Content of `~/.zeroclaw/hardware/` context files (HARDWARE.md, device + /// profiles, and skills) for injection into the system prompt. + pub context_files_prompt: String, +} + +/// Load hardware context files from `~/.zeroclaw/hardware/` and return them +/// concatenated as a single markdown string ready for system-prompt injection. +/// +/// Reads (if they exist): +/// 1. `~/.zeroclaw/hardware/HARDWARE.md` +/// 2. `~/.zeroclaw/hardware/devices/.md` for each discovered alias +/// 3. All `~/.zeroclaw/hardware/skills/*.md` files (sorted by name) +/// +/// Missing files are silently skipped. Returns an empty string when no files +/// are found. +pub fn load_hardware_context_prompt(aliases: &[&str]) -> String { + let home = match directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) { + Some(h) => h, + None => return String::new(), + }; + load_hardware_context_from_dir(&home.join(".zeroclaw").join("hardware"), aliases) +} + +/// Inner helper that reads hardware context from an explicit base directory. +/// Separated from [`load_hardware_context_prompt`] to allow unit-testing with +/// a temporary directory. +fn load_hardware_context_from_dir(hw_dir: &std::path::Path, aliases: &[&str]) -> String { + let mut sections: Vec = Vec::new(); + + // 1. Global HARDWARE.md + let global = hw_dir.join("HARDWARE.md"); + if let Ok(content) = std::fs::read_to_string(&global) { + if !content.trim().is_empty() { + sections.push(content.trim().to_string()); + } + } + + // 2. Per-device profile + let devices_dir = hw_dir.join("devices"); + for alias in aliases { + let path = devices_dir.join(format!("{alias}.md")); + tracing::info!("loading device file: {:?}", path); + if let Ok(content) = std::fs::read_to_string(&path) { + if !content.trim().is_empty() { + sections.push(content.trim().to_string()); + } + } + } + + // 3. Skills directory (*.md files, sorted) + let skills_dir = hw_dir.join("skills"); + if let Ok(entries) = std::fs::read_dir(&skills_dir) { + let mut skill_paths: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md")) + .collect(); + skill_paths.sort(); + for path in skill_paths { + if let Ok(content) = std::fs::read_to_string(&path) { + if !content.trim().is_empty() { + sections.push(content.trim().to_string()); + } + } + } + } + + if sections.is_empty() { + return String::new(); + } + sections.join("\n\n") +} + +/// Inject RPi self-discovery tools and system prompt context into the boot result. +/// +/// Called from both `boot()` variants when the `peripheral-rpi` feature is active +/// and the binary is running on Linux. If `/proc/device-tree/model` (or +/// `/proc/cpuinfo`) identifies a Raspberry Pi, the four built-in GPIO/info +/// tools are added to `tools` and the board description is appended to +/// `context_files_prompt` so the LLM knows it is running on the device. +#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] +fn inject_rpi_context( + tools: &mut Vec>, + context_files_prompt: &mut String, +) { + if let Some(ctx) = rpi::RpiSystemContext::discover() { + tracing::info!(board = %ctx.model.display_name(), ip = %ctx.ip_address, "RPi self-discovery complete"); + if let Some(led) = ctx.model.onboard_led_gpio() { + tracing::info!(gpio = led, "Onboard ACT LED"); + } + println!("[registry] rpi0 ready \u{2192} /dev/gpiomem"); + if ctx.gpio_available { + tools.push(Box::new(rpi::GpioRpiWriteTool)); + tools.push(Box::new(rpi::GpioRpiReadTool)); + tools.push(Box::new(rpi::GpioRpiBlinkTool)); + println!("[registry] loaded built-in: gpio_rpi_write"); + println!("[registry] loaded built-in: gpio_rpi_read"); + println!("[registry] loaded built-in: gpio_rpi_blink"); + } + tools.push(Box::new(rpi::RpiSystemInfoTool)); + println!("[registry] loaded built-in: rpi_system_info"); + ctx.write_hardware_context_file(); + // Load the device profile (rpi0.md) that was just written so its full + // GPIO reference and tool-usage rules appear in the system prompt. + let device_ctx = load_hardware_context_prompt(&["rpi0"]); + if !device_ctx.is_empty() { + if !context_files_prompt.is_empty() { + context_files_prompt.push_str("\n\n"); + } + context_files_prompt.push_str("## Connected Hardware Devices\n\n"); + context_files_prompt.push_str(&device_ctx); + } + let rpi_prompt = ctx.to_system_prompt(); + if !context_files_prompt.is_empty() { + context_files_prompt.push_str("\n\n"); + } + context_files_prompt.push_str(&rpi_prompt); + } +} + +/// Boot the hardware subsystem: discover devices + load tool registry. +/// +/// With the `hardware` feature: enumerates USB-serial devices, then +/// pre-registers any config-specified serial boards not already found by +/// discovery. [`HardwareSerialTransport`] opens the port lazily per-send, +/// so this succeeds even when the port doesn't exist at startup. +/// +/// Without the feature: loads plugin tools from `~/.zeroclaw/tools/` only, +/// with an empty device registry (GPIO tools will report "no device found" +/// if called, which is correct). +#[cfg(feature = "hardware")] +#[allow(unused_mut)] // tools and context_files_prompt are mutated on Linux+peripheral-rpi +pub async fn boot( + peripherals: &crate::config::PeripheralsConfig, +) -> anyhow::Result { + use device::DeviceCapabilities; + + let mut registry_inner = DeviceRegistry::discover().await; + + // Pre-register config-specified serial boards not already found by USB + // discovery. Transport opens lazily, so the port need not exist at boot. + if peripherals.enabled { + let mut discovered_paths: std::collections::HashSet = registry_inner + .all() + .iter() + .filter_map(|d| d.device_path.clone()) + .collect(); + + for board in &peripherals.boards { + if board.transport != "serial" { + continue; + } + let path = match &board.path { + Some(p) if !p.is_empty() => p.clone(), + _ => continue, + }; + if discovered_paths.contains(&path) { + continue; // already registered by USB discovery or a previous config entry + } + let alias = registry_inner.register(&board.board, None, None, Some(path.clone()), None); + let transport = std::sync::Arc::new(HardwareSerialTransport::new(&path, board.baud)) + as std::sync::Arc; + let caps = DeviceCapabilities { + gpio: true, + ..DeviceCapabilities::default() + }; + registry_inner.attach_transport(&alias, transport, caps) + .unwrap_or_else(|e| tracing::warn!(alias = %alias, err = %e, "attach_transport: unexpected unknown alias")); + // Mark path as registered so duplicate config entries are skipped. + discovered_paths.insert(path.clone()); + tracing::info!( + board = %board.board, + path = %path, + alias = %alias, + "pre-registered config board with lazy serial transport" + ); + } + } + + // BOOTSEL auto-detect: warn the user if a Pico is in BOOTSEL mode at startup. + if uf2::find_rpi_rp2_mount().is_some() { + tracing::info!("Pico detected in BOOTSEL mode (RPI-RP2 drive found)"); + tracing::info!("Say \"flash my pico\" to install ZeroClaw firmware automatically"); + } + + // Aardvark discovery: scan for Total Phase Aardvark USB adapters and + // register each one with AardvarkTransport + full I2C/SPI/GPIO capabilities. + { + use aardvark::AardvarkTransport; + use device::DeviceCapabilities; + + let aardvark_ports = aardvark_sys::AardvarkHandle::find_devices(); + for (i, &port) in aardvark_ports.iter().enumerate() { + let alias = registry_inner.register( + "aardvark", + Some(0x2b76), + None, + None, + Some("Total Phase Aardvark".to_string()), + ); + let transport = std::sync::Arc::new(AardvarkTransport::new(i32::from(port), 100)) + as std::sync::Arc; + let caps = DeviceCapabilities { + gpio: true, + i2c: true, + spi: true, + ..DeviceCapabilities::default() + }; + registry_inner + .attach_transport(&alias, transport, caps) + .unwrap_or_else(|e| { + tracing::warn!(alias = %alias, err = %e, "aardvark attach_transport failed") + }); + tracing::info!( + alias = %alias, + port_index = %i, + "aardvark adapter registered" + ); + println!("[registry] {alias} ready \u{2192} Total Phase port {i}"); + } + } + + let devices = std::sync::Arc::new(tokio::sync::RwLock::new(registry_inner)); + let registry = ToolRegistry::load(devices.clone()).await?; + let device_summary = { + let reg = devices.read().await; + reg.prompt_summary() + }; + let mut tools = registry.into_tools(); + if !tools.is_empty() { + tracing::info!(count = tools.len(), "Hardware registry tools loaded"); + } + let alias_strings: Vec = { + let reg = devices.read().await; + reg.aliases() + .into_iter() + .map(|s: &str| s.to_string()) + .collect() + }; + let alias_refs: Vec<&str> = alias_strings.iter().map(|s: &String| s.as_str()).collect(); + let mut context_files_prompt = load_hardware_context_prompt(&alias_refs); + if !context_files_prompt.is_empty() { + tracing::info!("Hardware context files loaded"); + } + // RPi self-discovery: detect board model and inject GPIO tools + prompt context. + #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] + inject_rpi_context(&mut tools, &mut context_files_prompt); + Ok(HardwareBootResult { + tools, + device_summary, + context_files_prompt, + }) +} + +/// Fallback when the `hardware` feature is disabled — plugins only. +#[cfg(not(feature = "hardware"))] +#[allow(unused_mut)] // tools and context_files_prompt are mutated on Linux+peripheral-rpi +pub async fn boot( + _peripherals: &crate::config::PeripheralsConfig, +) -> anyhow::Result { + let devices = std::sync::Arc::new(tokio::sync::RwLock::new(DeviceRegistry::new())); + let registry = ToolRegistry::load(devices.clone()).await?; + let device_summary = { + let reg = devices.read().await; + reg.prompt_summary() + }; + let mut tools = registry.into_tools(); + if !tools.is_empty() { + tracing::info!( + count = tools.len(), + "Hardware registry tools loaded (plugins only)" + ); + } + // No discovered devices in no-hardware fallback; still load global files. + let mut context_files_prompt = load_hardware_context_prompt(&[]); + // RPi self-discovery: detect board model and inject GPIO tools + prompt context. + #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] + inject_rpi_context(&mut tools, &mut context_files_prompt); + Ok(HardwareBootResult { + tools, + device_summary, + context_files_prompt, + }) +} + /// A hardware device discovered during auto-scan. #[derive(Debug, Clone)] pub struct DiscoveredDevice { @@ -265,3 +634,97 @@ fn info_via_probe(chip: &str) -> anyhow::Result<()> { println!("Info read via USB (SWD) — no firmware on target needed."); Ok(()) } + +#[cfg(test)] +mod tests { + use super::load_hardware_context_from_dir; + use std::fs; + + fn write(path: &std::path::Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + #[test] + fn empty_dir_returns_empty_string() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(load_hardware_context_from_dir(tmp.path(), &[]), ""); + } + + #[test] + fn hardware_md_only_returns_its_content() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("HARDWARE.md"), "# Global HW\npin 25 = LED"); + let result = load_hardware_context_from_dir(tmp.path(), &[]); + assert!(result.contains("pin 25 = LED"), "got: {result}"); + } + + #[test] + fn device_profile_loaded_for_matching_alias() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("devices").join("pico0.md"), + "# pico0\nPort: /dev/cu.usbmodem1101", + ); + let result = load_hardware_context_from_dir(tmp.path(), &["pico0"]); + assert!(result.contains("/dev/cu.usbmodem1101"), "got: {result}"); + } + + #[test] + fn device_profile_skipped_for_non_matching_alias() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("devices").join("pico0.md"), + "# pico0\nPort: /dev/cu.usbmodem1101", + ); + // No alias provided — device profile must not appear + let result = load_hardware_context_from_dir(tmp.path(), &[]); + assert!(!result.contains("pico0"), "got: {result}"); + } + + #[test] + fn skills_loaded_and_sorted() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("skills").join("blink.md"), + "# Skill: Blink\nuse device_exec", + ); + write( + &tmp.path().join("skills").join("gpio.md"), + "# Skill: GPIO\ngpio_write", + ); + let result = load_hardware_context_from_dir(tmp.path(), &[]); + // blink.md sorts before gpio.md + let blink_pos = result.find("device_exec").unwrap(); + let gpio_pos = result.find("gpio_write").unwrap(); + assert!(blink_pos < gpio_pos, "skills not sorted; got: {result}"); + } + + #[test] + fn sections_joined_with_double_newline() { + let tmp = tempfile::tempdir().unwrap(); + write(&tmp.path().join("HARDWARE.md"), "global"); + write(&tmp.path().join("devices").join("pico0.md"), "device"); + let result = load_hardware_context_from_dir(tmp.path(), &["pico0"]); + assert!(result.contains("global\n\ndevice"), "got: {result}"); + } + + #[test] + fn hardware_context_contains_device_exec_rule() { + // Verify that the installed HARDWARE.md (from Section 3) contains + // the device_exec rule so the LLM knows to use it for blink/loops. + // This acts as the Section 5 BUG-2 behavioral gate. + if let Some(home) = directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) { + let hw_md = home.join(".zeroclaw").join("hardware").join("HARDWARE.md"); + if hw_md.exists() { + let content = fs::read_to_string(&hw_md).unwrap_or_default(); + assert!( + content.contains("device_exec"), + "HARDWARE.md must mention device_exec for blink/loop operations; got: {content}" + ); + } + } + } +} diff --git a/src/hardware/pico_code.rs b/src/hardware/pico_code.rs new file mode 100644 index 0000000000..6a21e63a7b --- /dev/null +++ b/src/hardware/pico_code.rs @@ -0,0 +1,723 @@ +//! Phase 7 — Dynamic code tools: `device_read_code`, `device_write_code`, `device_exec`. +//! +//! These tools let the LLM read, write, and execute code on any connected +//! hardware device. The `DeviceRuntime` on each device determines which +//! host-side tooling is used: +//! +//! - **MicroPython / CircuitPython** — `mpremote` for code read/write/exec. +//! - **Arduino / Nucleus / Linux** — not yet implemented; returns a clear error. +//! +//! When the `device` parameter is omitted, each tool auto-selects the device +//! only when **exactly one** device is registered. If multiple devices are +//! present the tool returns an error and requires an explicit `device` parameter. + +use super::device::{DeviceRegistry, DeviceRuntime}; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Default timeout for `mpremote` operations (seconds). +const MPREMOTE_TIMEOUT_SECS: u64 = 30; + +/// Maximum time to wait for the serial port after a reset (seconds). +const PORT_WAIT_SECS: u64 = 15; + +/// Polling interval when waiting for a serial port (ms). +const PORT_POLL_MS: u64 = 200; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// Resolve the serial port path and runtime for a device. +/// +/// If `device_alias` is provided, look it up; otherwise auto-selects the device +/// only when exactly one device is registered. With multiple devices present, +/// returns an error requiring an explicit alias. +/// Returns `(alias, port, runtime)` or an error `ToolResult`. +async fn resolve_device_port( + registry: &RwLock, + device_alias: Option<&str>, +) -> Result<(String, String, DeviceRuntime), ToolResult> { + let reg = registry.read().await; + + let alias: String = match device_alias { + Some(a) => a.to_string(), + None => { + // Auto-select the first device. + let all_aliases: Vec = + reg.aliases().into_iter().map(|a| a.to_string()).collect(); + match all_aliases.as_slice() { + [single] => single.clone(), + [] => { + return Err(ToolResult { + success: false, + output: String::new(), + error: Some("no device found — is a board connected via USB?".to_string()), + }); + } + multiple => { + return Err(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "multiple devices found ({}); specify the \"device\" parameter", + multiple.join(", ") + )), + }); + } + } + } + }; + + let device = reg.get_device(&alias).ok_or_else(|| ToolResult { + success: false, + output: String::new(), + error: Some(format!("device '{alias}' not found in registry")), + })?; + + let runtime = device.runtime; + + let port = device.port().ok_or_else(|| ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "device '{alias}' has no serial port — is it connected?" + )), + })?; + + Ok((alias, port.to_string(), runtime)) +} + +/// Return an unsupported-runtime error `ToolResult` for a given tool name. +fn unsupported_runtime(runtime: &DeviceRuntime, tool: &str) -> ToolResult { + ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "{runtime} runtime is not yet supported for {tool} — coming soon" + )), + } +} + +/// Run an `mpremote` command with a timeout and return (stdout, stderr). +async fn run_mpremote(args: &[&str], timeout_secs: u64) -> Result<(String, String), String> { + use tokio::time::timeout; + + let result = timeout( + std::time::Duration::from_secs(timeout_secs), + tokio::process::Command::new("mpremote").args(args).output(), + ) + .await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + Ok((stdout, stderr)) + } else { + Err(format!( + "mpremote failed (exit {}): {}", + output.status, + stderr.trim() + )) + } + } + Ok(Err(e)) => Err(format!( + "mpremote not found or could not start ({e}). \ + Install it with: pip install mpremote" + )), + Err(_) => Err(format!( + "mpremote timed out after {timeout_secs}s — \ + the device may be unresponsive" + )), + } +} + +// ── DeviceReadCodeTool ──────────────────────────────────────────────────────── + +/// Tool: read the current `main.py` from a connected device. +/// +/// The LLM uses this to understand the current program before modifying it. +pub struct DeviceReadCodeTool { + registry: Arc>, +} + +impl DeviceReadCodeTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for DeviceReadCodeTool { + fn name(&self) -> &str { + "device_read_code" + } + + fn description(&self) -> &str { + "Read the current program (main.py) running on a connected device. \ + Use this before writing new code so you understand the current state." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, esp0. Auto-selected if only one device is connected." + } + }, + "required": [] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let device_alias = args.get("device").and_then(|v| v.as_str()); + + let (alias, port, runtime) = match resolve_device_port(&self.registry, device_alias).await { + Ok(v) => v, + Err(tool_result) => return Ok(tool_result), + }; + + // Runtime dispatch. + match runtime { + DeviceRuntime::MicroPython | DeviceRuntime::CircuitPython => {} + other => return Ok(unsupported_runtime(&other, "device_read_code")), + } + + tracing::info!(alias = %alias, port = %port, runtime = %runtime, "reading main.py from device"); + + match run_mpremote( + &["connect", &port, "cat", ":main.py"], + MPREMOTE_TIMEOUT_SECS, + ) + .await + { + Ok((stdout, _stderr)) => Ok(ToolResult { + success: true, + output: if stdout.trim().is_empty() { + format!("main.py on {alias} is empty or not found.") + } else { + format!( + "Current main.py on {alias}:\n\n```python\n{}\n```", + stdout.trim() + ) + }, + error: None, + }), + Err(e) => { + // mpremote cat fails if main.py doesn't exist — not a fatal error. + if e.contains("OSError") || e.contains("no such file") || e.contains("ENOENT") { + Ok(ToolResult { + success: true, + output: format!( + "No main.py found on {alias} — the device has no program yet." + ), + error: None, + }) + } else { + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to read code from {alias}: {e}")), + }) + } + } + } + } +} + +// ── DeviceWriteCodeTool ─────────────────────────────────────────────────────── + +/// Tool: write a complete program to a device as `main.py`. +/// +/// This replaces the current `main.py` on the device and resets it so the new +/// program starts executing immediately. +pub struct DeviceWriteCodeTool { + registry: Arc>, +} + +impl DeviceWriteCodeTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for DeviceWriteCodeTool { + fn name(&self) -> &str { + "device_write_code" + } + + fn description(&self) -> &str { + "Write a complete program to a device — replaces main.py and restarts the device. \ + Always read the current code first with device_read_code." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, esp0. Auto-selected if only one device is connected." + }, + "code": { + "type": "string", + "description": "Complete program to write as main.py" + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let code = match args.get("code").and_then(|v| v.as_str()) { + Some(c) => c, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: code".to_string()), + }); + } + }; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("code parameter is empty — provide a program to write".to_string()), + }); + } + + let device_alias = args.get("device").and_then(|v| v.as_str()); + + let (alias, port, runtime) = match resolve_device_port(&self.registry, device_alias).await { + Ok(v) => v, + Err(tool_result) => return Ok(tool_result), + }; + + // Runtime dispatch. + match runtime { + DeviceRuntime::MicroPython | DeviceRuntime::CircuitPython => {} + other => return Ok(unsupported_runtime(&other, "device_write_code")), + } + + tracing::info!(alias = %alias, port = %port, runtime = %runtime, code_len = code.len(), "writing main.py to device"); + + // Write code to an atomic, owner-only temp file via tempfile crate. + let named_tmp = match tokio::task::spawn_blocking(|| { + tempfile::Builder::new() + .prefix("zeroclaw_main_") + .suffix(".py") + .tempfile() + }) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to create temp file: {e}")), + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("temp file task failed: {e}")), + }); + } + }; + let tmp_path = named_tmp.path().to_path_buf(); + let tmp_str = tmp_path.to_string_lossy().to_string(); + + if let Err(e) = tokio::fs::write(&tmp_path, code).await { + // named_tmp dropped here — auto-removes the file. + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to write temp file: {e}")), + }); + } + + // Deploy via mpremote: copy + reset. + let result = run_mpremote( + &["connect", &port, "cp", &tmp_str, ":main.py", "+", "reset"], + MPREMOTE_TIMEOUT_SECS, + ) + .await; + + // Explicit cleanup — log if removal fails rather than silently ignoring. + if let Err(e) = named_tmp.close() { + tracing::warn!(path = %tmp_str, err = %e, "failed to clean up temp file"); + } + + match result { + Ok((_stdout, _stderr)) => { + tracing::info!(alias = %alias, "main.py deployed and device reset"); + + // Wait for the serial port to reappear after reset. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let port_reappeared = wait_for_port( + &port, + std::time::Duration::from_secs(PORT_WAIT_SECS), + std::time::Duration::from_millis(PORT_POLL_MS), + ) + .await; + + if port_reappeared { + Ok(ToolResult { + success: true, + output: format!( + "Code deployed to {alias} — main.py updated and device reset. \ + {alias} is back online." + ), + error: None, + }) + } else { + Ok(ToolResult { + success: true, + output: format!( + "Code deployed to {alias} — main.py updated and device reset. \ + Note: serial port did not reappear within {PORT_WAIT_SECS}s; \ + the device may still be booting." + ), + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to deploy code to {alias}: {e}")), + }), + } + } +} + +// ── DeviceExecTool ──────────────────────────────────────────────────────────── + +/// Tool: run a one-off code snippet on a device without modifying `main.py`. +/// +/// Good for one-time commands, sensor reads, and testing code before committing. +pub struct DeviceExecTool { + registry: Arc>, +} + +impl DeviceExecTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for DeviceExecTool { + fn name(&self) -> &str { + "device_exec" + } + + fn description(&self) -> &str { + "Execute a code snippet on a connected device without modifying main.py. \ + Good for one-time actions, sensor reads, and testing before writing permanent code." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, esp0. Auto-selected if only one device is connected." + }, + "code": { + "type": "string", + "description": "Code to execute. Output is captured and returned." + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let code = match args.get("code").and_then(|v| v.as_str()) { + Some(c) => c, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: code".to_string()), + }); + } + }; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "code parameter is empty — provide a code snippet to execute".to_string(), + ), + }); + } + + let device_alias = args.get("device").and_then(|v| v.as_str()); + + let (alias, port, runtime) = match resolve_device_port(&self.registry, device_alias).await { + Ok(v) => v, + Err(tool_result) => return Ok(tool_result), + }; + + // Runtime dispatch. + match runtime { + DeviceRuntime::MicroPython | DeviceRuntime::CircuitPython => {} + other => return Ok(unsupported_runtime(&other, "device_exec")), + } + + tracing::info!(alias = %alias, port = %port, runtime = %runtime, code_len = code.len(), "executing snippet on device"); + + // Write snippet to an atomic, owner-only temp file via tempfile crate. + let named_tmp = match tokio::task::spawn_blocking(|| { + tempfile::Builder::new() + .prefix("zeroclaw_exec_") + .suffix(".py") + .tempfile() + }) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to create temp file: {e}")), + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("temp file task failed: {e}")), + }); + } + }; + let tmp_path = named_tmp.path().to_path_buf(); + let tmp_str = tmp_path.to_string_lossy().to_string(); + + if let Err(e) = tokio::fs::write(&tmp_path, code).await { + // named_tmp dropped here — auto-removes the file. + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to write temp file: {e}")), + }); + } + + // Execute via mpremote run (does NOT modify main.py). + let result = + run_mpremote(&["connect", &port, "run", &tmp_str], MPREMOTE_TIMEOUT_SECS).await; + + // Explicit cleanup — log if removal fails rather than silently ignoring. + if let Err(e) = named_tmp.close() { + tracing::warn!(path = %tmp_str, err = %e, "failed to clean up temp file"); + } + + match result { + Ok((stdout, stderr)) => { + let output = if stdout.trim().is_empty() && !stderr.trim().is_empty() { + // Some MicroPython output goes to stderr (e.g. exceptions). + stderr.trim().to_string() + } else { + stdout.trim().to_string() + }; + + Ok(ToolResult { + success: true, + output: if output.is_empty() { + format!("Code executed on {alias} — no output produced.") + } else { + format!("Output from {alias}:\n{output}") + }, + error: None, + }) + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to execute code on {alias}: {e}")), + }), + } + } +} + +// ── port wait helper ────────────────────────────────────────────────────────── + +/// Poll for a specific serial port to reappear after a device reset. +/// +/// Returns `true` if the port exists within the timeout, `false` otherwise. +async fn wait_for_port( + port_path: &str, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + while tokio::time::Instant::now() < deadline { + if std::path::Path::new(port_path).exists() { + return true; + } + tokio::time::sleep(interval).await; + } + false +} + +/// Factory function: create all Phase 7 dynamic code tools. +pub fn device_code_tools(registry: Arc>) -> Vec> { + vec![ + Box::new(DeviceReadCodeTool::new(registry.clone())), + Box::new(DeviceWriteCodeTool::new(registry.clone())), + Box::new(DeviceExecTool::new(registry)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_registry() -> Arc> { + Arc::new(RwLock::new(DeviceRegistry::new())) + } + + // ── DeviceReadCodeTool ─────────────────────────────────────────── + + #[test] + fn device_read_code_name() { + let tool = DeviceReadCodeTool::new(empty_registry()); + assert_eq!(tool.name(), "device_read_code"); + } + + #[test] + fn device_read_code_schema_valid() { + let tool = DeviceReadCodeTool::new(empty_registry()); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"]["device"].is_object()); + } + + #[tokio::test] + async fn device_read_code_no_device_returns_error() { + let tool = DeviceReadCodeTool::new(empty_registry()); + let result = tool.execute(json!({})).await.unwrap(); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or("").contains("no device"), + "expected 'no device' error; got: {:?}", + result.error + ); + } + + // ── DeviceWriteCodeTool ────────────────────────────────────────── + + #[test] + fn device_write_code_name() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + assert_eq!(tool.name(), "device_write_code"); + } + + #[test] + fn device_write_code_schema_requires_code() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!( + required.iter().any(|v| v.as_str() == Some("code")), + "code should be required" + ); + } + + #[tokio::test] + async fn device_write_code_empty_code_rejected() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + let result = tool.execute(json!({"code": ""})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("empty")); + } + + #[tokio::test] + async fn device_write_code_no_device_returns_error() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + let result = tool + .execute(json!({"code": "print('hello')"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("no device"),); + } + + // ── DeviceExecTool ─────────────────────────────────────────────── + + #[test] + fn device_exec_name() { + let tool = DeviceExecTool::new(empty_registry()); + assert_eq!(tool.name(), "device_exec"); + } + + #[test] + fn device_exec_schema_requires_code() { + let tool = DeviceExecTool::new(empty_registry()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!( + required.iter().any(|v| v.as_str() == Some("code")), + "code should be required" + ); + } + + #[tokio::test] + async fn device_exec_empty_code_rejected() { + let tool = DeviceExecTool::new(empty_registry()); + let result = tool.execute(json!({"code": " "})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("empty")); + } + + #[tokio::test] + async fn device_exec_no_device_returns_error() { + let tool = DeviceExecTool::new(empty_registry()); + let result = tool.execute(json!({"code": "print(1+1)"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("no device"),); + } + + // ── Factory ────────────────────────────────────────────────────── + + #[test] + fn factory_returns_three_tools() { + let reg = empty_registry(); + let tools = device_code_tools(reg); + assert_eq!(tools.len(), 3); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"device_read_code")); + assert!(names.contains(&"device_write_code")); + assert!(names.contains(&"device_exec")); + } + + #[test] + fn all_specs_valid() { + let reg = empty_registry(); + let tools = device_code_tools(reg); + for tool in &tools { + let spec = tool.spec(); + assert!(!spec.name.is_empty()); + assert!(!spec.description.is_empty()); + assert_eq!(spec.parameters["type"], "object"); + } + } +} diff --git a/src/hardware/pico_flash.rs b/src/hardware/pico_flash.rs new file mode 100644 index 0000000000..f604ae51fa --- /dev/null +++ b/src/hardware/pico_flash.rs @@ -0,0 +1,296 @@ +//! `pico_flash` tool — flash ZeroClaw firmware to a Pico in BOOTSEL mode. +//! +//! # Happy path +//! 1. User holds BOOTSEL while plugging in Pico → RPI-RP2 drive appears. +//! 2. User asks "flash my pico". +//! 3. LLM calls `pico_flash(confirm=true)`. +//! 4. Tool copies UF2 to RPI-RP2 drive; Pico reboots into MicroPython. +//! 5. Tool waits up to 20 s for `/dev/cu.usbmodem*` to appear. +//! 6. Tool deploys `main.py` via `mpremote` and resets the Pico. +//! 7. Tool waits for the serial port to reappear after reset. +//! 8. Tool returns success; user restarts ZeroClaw to get `pico0`. + +use super::device::DeviceRegistry; +use super::uf2; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// How long to wait for the Pico serial port after flashing (seconds). +const PORT_WAIT_SECS: u64 = 20; + +/// How often to poll for the serial port. +const PORT_POLL_MS: u64 = 500; + +// ── PicoFlashTool ───────────────────────────────────────────────────────────── + +/// Tool: flash ZeroClaw MicroPython firmware to a Pico in BOOTSEL mode. +/// +/// The Pico must be connected with BOOTSEL held so it mounts as `RPI-RP2`. +/// After flashing, the tool deploys `main.py` via `mpremote`, then reconnects +/// the serial transport in the [`DeviceRegistry`] so subsequent `gpio_write` +/// calls work immediately without restarting ZeroClaw. +pub struct PicoFlashTool { + registry: Arc>, +} + +impl PicoFlashTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for PicoFlashTool { + fn name(&self) -> &str { + "pico_flash" + } + + fn description(&self) -> &str { + "Flash ZeroClaw firmware to a Raspberry Pi Pico in BOOTSEL mode. \ + The Pico must be connected with the BOOTSEL button held (shows as RPI-RP2 drive in Finder). \ + After flashing the Pico reboots, main.py is deployed, and the serial \ + connection is refreshed automatically — no restart needed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "description": "Set to true to confirm flashing the Pico firmware" + } + }, + "required": ["confirm"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + // ── 1. Require explicit confirmation ────────────────────────────── + let confirmed = args + .get("confirm") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !confirmed { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "Set confirm=true to proceed with flashing. \ + This will overwrite the firmware on the connected Pico." + .to_string(), + ), + }); + } + + // ── 2. Detect BOOTSEL-mode Pico ─────────────────────────────────── + let mount = match uf2::find_rpi_rp2_mount() { + Some(m) => m, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No Pico in BOOTSEL mode found (RPI-RP2 drive not detected). \ + Hold the BOOTSEL button while plugging the Pico in via USB, \ + then try again." + .to_string(), + ), + }); + } + }; + + tracing::info!(mount = %mount.display(), "RPI-RP2 volume found"); + + // ── 3. Ensure firmware files are extracted ──────────────────────── + let firmware_dir = match uf2::ensure_firmware_dir() { + Ok(d) => d, + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("firmware error: {e}")), + }); + } + }; + + // ── 4. Flash UF2 ───────────────────────────────────────────────── + if let Err(e) = uf2::flash_uf2(&mount, &firmware_dir).await { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("flash failed: {e}")), + }); + } + + // ── 5. Wait for serial port to appear ───────────────────────────── + let port = uf2::wait_for_serial_port( + std::time::Duration::from_secs(PORT_WAIT_SECS), + std::time::Duration::from_millis(PORT_POLL_MS), + ) + .await; + + let port = match port { + Some(p) => p, + None => { + // Flash likely succeeded even if port didn't appear in time — + // some host systems are slower to enumerate the new port. + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "UF2 copied to {} but serial port did not appear within {PORT_WAIT_SECS}s. \ + Unplug and replug the Pico, then run:\n \ + mpremote connect cp ~/.zeroclaw/firmware/pico/main.py :main.py + reset", + mount.display() + )), + }); + } + }; + + tracing::info!(port = %port.display(), "Pico serial port online after UF2 flash"); + + // ── 6. Deploy main.py via mpremote ──────────────────────────────── + if let Err(e) = uf2::deploy_main_py(&port, &firmware_dir).await { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("main.py deploy failed: {e}")), + }); + } + + // ── 7. Wait for serial port after mpremote reset ────────────────── + // + // mpremote resets the Pico so the serial port disappears briefly. + // Give the OS a moment to drop the old entry before polling. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let final_port = uf2::wait_for_serial_port( + std::time::Duration::from_secs(PORT_WAIT_SECS), + std::time::Duration::from_millis(PORT_POLL_MS), + ) + .await; + + // ── 8. Reconnect serial transport in DeviceRegistry ────────────── + // + // The old transport still points at a stale port handle from before + // the flash. Reconnect so gpio_write works immediately. + let reconnect_result = match &final_port { + Some(p) => { + let port_str = p.to_string_lossy(); + let mut reg = self.registry.write().await; + // Try to find a pico alias in the registry. + match reg.aliases().into_iter().find(|a| a.starts_with("pico")) { + Some(a) => { + let alias = a.to_string(); + reg.reconnect(&alias, Some(&port_str)).await + } + None => Err(anyhow::anyhow!( + "no pico alias found in registry; cannot reconnect transport" + )), + } + } + None => Err(anyhow::anyhow!("no serial port to reconnect")), + }; + + // ── 9. Return result ────────────────────────────────────────────── + match final_port { + Some(p) => { + let port_str = p.display().to_string(); + let reconnected = reconnect_result.is_ok(); + if reconnected { + tracing::info!(port = %port_str, "Pico online with main.py — transport reconnected"); + } else { + let err = reconnect_result.unwrap_err(); + tracing::warn!(port = %port_str, err = %err, "Pico online but reconnect failed"); + } + let suffix = if reconnected { + "pico0 is ready — you can use gpio_write immediately." + } else { + "Restart ZeroClaw to reconnect as pico0." + }; + Ok(ToolResult { + success: true, + output: format!( + "Pico flashed and main.py deployed successfully. \ + Firmware is online at {port_str}. {suffix}" + ), + error: None, + }) + } + None => Ok(ToolResult { + success: true, + output: format!( + "Pico flashed and main.py deployed. \ + Serial port did not reappear within {PORT_WAIT_SECS}s after reset — \ + unplug and replug the Pico, then restart ZeroClaw to connect as pico0." + ), + error: None, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::device::DeviceRegistry; + use super::*; + + fn tool() -> PicoFlashTool { + let registry = Arc::new(RwLock::new(DeviceRegistry::new())); + PicoFlashTool::new(registry) + } + + #[test] + fn name_is_pico_flash() { + let t = tool(); + assert_eq!(t.name(), "pico_flash"); + } + + #[test] + fn schema_requires_confirm() { + let schema = tool().parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!( + required.iter().any(|v| v.as_str() == Some("confirm")), + "confirm should be required" + ); + } + + #[tokio::test] + async fn execute_without_confirm_returns_error() { + let result = tool() + .execute(serde_json::json!({"confirm": false})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.is_some()); + let err = result.error.unwrap(); + assert!( + err.contains("confirm=true"), + "error should mention confirm=true; got: {err}" + ); + } + + #[tokio::test] + async fn execute_missing_confirm_returns_error() { + let result = tool().execute(serde_json::json!({})).await.unwrap(); + assert!(!result.success); + } + + #[tokio::test] + async fn execute_with_confirm_true_but_no_pico_returns_error() { + // In CI there's no Pico attached — the tool should report missing device, not panic. + let result = tool() + .execute(serde_json::json!({"confirm": true})) + .await + .unwrap(); + // Either success (if a Pico happens to be connected) or the BOOTSEL error. + // What must NOT happen: panic or anyhow error propagation. + let _ = result; // just verify it didn't panic + } +} diff --git a/src/hardware/protocol.rs b/src/hardware/protocol.rs new file mode 100644 index 0000000000..892ed34445 --- /dev/null +++ b/src/hardware/protocol.rs @@ -0,0 +1,148 @@ +//! ZeroClaw serial JSON protocol — the firmware contract. +//! +//! These types define the newline-delimited JSON wire format shared between +//! the ZeroClaw host and device firmware (Pico, Arduino, ESP32, Nucleo). +//! +//! Wire format: +//! Host → Device: `{"cmd":"gpio_write","params":{"pin":25,"value":1}}\n` +//! Device → Host: `{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n` +//! +//! Both sides MUST agree on these struct definitions. Any change here is a +//! breaking firmware contract change. + +use serde::{Deserialize, Serialize}; + +/// Host-to-device command. +/// +/// Serialized as one JSON line terminated by `\n`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ZcCommand { + /// Command name (e.g. `"gpio_read"`, `"ping"`, `"reboot_bootsel"`). + pub cmd: String, + /// Command parameters — schema depends on the command. + #[serde(default)] + pub params: serde_json::Value, +} + +impl ZcCommand { + /// Create a new command with the given name and parameters. + pub fn new(cmd: impl Into, params: serde_json::Value) -> Self { + Self { + cmd: cmd.into(), + params, + } + } + + /// Create a parameterless command (e.g. `ping`, `capabilities`). + pub fn simple(cmd: impl Into) -> Self { + Self { + cmd: cmd.into(), + params: serde_json::Value::Object(serde_json::Map::new()), + } + } +} + +/// Device-to-host response. +/// +/// Serialized as one JSON line terminated by `\n`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ZcResponse { + /// Whether the command succeeded. + pub ok: bool, + /// Response payload — schema depends on the command executed. + #[serde(default)] + pub data: serde_json::Value, + /// Human-readable error message when `ok` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ZcResponse { + /// Create a success response with data. + pub fn success(data: serde_json::Value) -> Self { + Self { + ok: true, + data, + error: None, + } + } + + /// Create an error response. + pub fn error(message: impl Into) -> Self { + Self { + ok: false, + data: serde_json::Value::Null, + error: Some(message.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn zc_command_serialization_roundtrip() { + let cmd = ZcCommand::new("gpio_write", json!({"pin": 25, "value": 1})); + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: ZcCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.cmd, "gpio_write"); + assert_eq!(parsed.params["pin"], 25); + assert_eq!(parsed.params["value"], 1); + } + + #[test] + fn zc_command_simple_has_empty_params() { + let cmd = ZcCommand::simple("ping"); + assert_eq!(cmd.cmd, "ping"); + assert!(cmd.params.is_object()); + } + + #[test] + fn zc_response_success_roundtrip() { + let resp = ZcResponse::success(json!({"value": 1})); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: ZcResponse = serde_json::from_str(&json).unwrap(); + assert!(parsed.ok); + assert_eq!(parsed.data["value"], 1); + assert!(parsed.error.is_none()); + } + + #[test] + fn zc_response_error_roundtrip() { + let resp = ZcResponse::error("pin not available"); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: ZcResponse = serde_json::from_str(&json).unwrap(); + assert!(!parsed.ok); + assert_eq!(parsed.error.as_deref(), Some("pin not available")); + } + + #[test] + fn zc_command_wire_format_matches_spec() { + // Verify the exact JSON shape the firmware expects. + let cmd = ZcCommand::new("gpio_write", json!({"pin": 25, "value": 1})); + let v: serde_json::Value = serde_json::to_value(&cmd).unwrap(); + assert!(v.get("cmd").is_some()); + assert!(v.get("params").is_some()); + } + + #[test] + fn zc_response_from_firmware_json() { + // Simulate a raw firmware response line. + let raw = r#"{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}"#; + let resp: ZcResponse = serde_json::from_str(raw).unwrap(); + assert!(resp.ok); + assert_eq!(resp.data["state"], "HIGH"); + } + + #[test] + fn zc_response_missing_optional_fields() { + // Firmware may omit `data` and `error` on success. + let raw = r#"{"ok":true}"#; + let resp: ZcResponse = serde_json::from_str(raw).unwrap(); + assert!(resp.ok); + assert!(resp.data.is_null()); + assert!(resp.error.is_none()); + } +} diff --git a/src/hardware/rpi.rs b/src/hardware/rpi.rs new file mode 100644 index 0000000000..ea7dfa9537 --- /dev/null +++ b/src/hardware/rpi.rs @@ -0,0 +1,646 @@ +//! Raspberry Pi self-discovery and native GPIO tools. +//! +//! Only compiled on Linux with the `peripheral-rpi` feature enabled. +//! +//! Provides two capabilities: +//! +//! 1. **Board detection** — `RpiModel` / `RpiSystemContext` detect which Pi model +//! is running, its IP address, temperature, and GPIO availability. The result is +//! injected into the system prompt so the LLM knows it is running *on* the device. +//! +//! 2. **Tool registration** — Four tools are auto-registered when an RPi board is +//! detected at boot (no `[[peripherals.boards]]` config entry required): +//! - `gpio_rpi_write` — set a GPIO pin HIGH / LOW +//! - `gpio_rpi_read` — read a GPIO pin value +//! - `gpio_rpi_blink` — blink a GPIO pin N times +//! - `rpi_system_info` — return board model, RAM, temp, IP + +use crate::tools::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::fmt::Write as _; +use std::fs; +use std::time::Duration; + +// ─── LED sysfs helpers ────────────────────────────────────────────────────── + +/// The Linux LED subsystem paths for the onboard ACT LED. +/// On RPi 3B/4B/5/Zero2W the ACT LED is wired through the kernel LED driver, +/// not directly accessible via rppal GPIO. We must use sysfs instead. +const LED_SYSFS_PATHS: &[&str] = &[ + "/sys/class/leds/ACT/brightness", + "/sys/class/leds/led0/brightness", +]; + +const LED_TRIGGER_PATHS: &[&str] = &[ + "/sys/class/leds/ACT/trigger", + "/sys/class/leds/led0/trigger", +]; + +/// Returns true if `pin` is the onboard ACT LED for the detected RPi model. +fn is_onboard_led(pin: u8) -> bool { + RpiModel::detect() + .and_then(|m| m.onboard_led_gpio()) + .is_some_and(|led| led == pin) +} + +/// Find the first existing sysfs brightness path for the ACT LED. +fn led_brightness_path() -> Option<&'static str> { + LED_SYSFS_PATHS + .iter() + .copied() + .find(|p| std::path::Path::new(p).exists()) +} + +/// Ensure the ACT LED trigger is set to "none" so we can control it. +fn ensure_led_trigger_none() { + for path in LED_TRIGGER_PATHS { + if std::path::Path::new(path).exists() { + let _ = fs::write(path, "none"); + return; + } + } +} + +// ─── Board model ──────────────────────────────────────────────────────────── + +/// Detected Raspberry Pi model variant. +#[derive(Debug, Clone, PartialEq)] +pub enum RpiModel { + Rpi3B, + Rpi3BPlus, + Rpi4B, + Rpi5, + RpiZero2W, + Unknown(String), +} + +impl RpiModel { + /// Detect RPi model from device-tree or /proc/cpuinfo. + pub fn detect() -> Option { + // Device tree model string is the most reliable source. + if let Ok(raw) = fs::read_to_string("/proc/device-tree/model") { + let model = raw.trim_end_matches('\0'); + return Some(Self::from_model_string(model)); + } + // Fallback: scan /proc/cpuinfo for a "Model" line. + if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") { + if cpuinfo.contains("Raspberry Pi") { + for line in cpuinfo.lines() { + if let Some(rest) = line.strip_prefix("Model") { + let model = rest.trim_start_matches(':').trim(); + return Some(Self::from_model_string(model)); + } + } + return Some(Self::Unknown("Raspberry Pi (unknown model)".into())); + } + } + None + } + + fn from_model_string(s: &str) -> Self { + let lower = s.to_lowercase(); + if lower.contains("3 model b plus") || lower.contains("3b+") { + Self::Rpi3BPlus + } else if lower.contains("3 model b") || lower.contains("3b") { + Self::Rpi3B + } else if lower.contains("4 model b") || lower.contains("4b") { + Self::Rpi4B + } else if lower.contains("raspberry pi 5") || lower.contains(" 5 ") { + Self::Rpi5 + } else if lower.contains("zero 2") { + Self::RpiZero2W + } else { + Self::Unknown(s.to_string()) + } + } + + /// BCM GPIO number of the on-board activity LED, if known. + pub fn onboard_led_gpio(&self) -> Option { + match self { + Self::Rpi3B | Self::Rpi3BPlus => Some(47), + Self::Rpi4B => Some(42), + Self::Rpi5 => Some(9), + Self::RpiZero2W => Some(29), + Self::Unknown(_) => None, + } + } + + /// Human-readable display name. + pub fn display_name(&self) -> &str { + match self { + Self::Rpi3B => "Raspberry Pi 3 Model B", + Self::Rpi3BPlus => "Raspberry Pi 3 Model B+", + Self::Rpi4B => "Raspberry Pi 4 Model B", + Self::Rpi5 => "Raspberry Pi 5", + Self::RpiZero2W => "Raspberry Pi Zero 2 W", + Self::Unknown(s) => s.as_str(), + } + } +} + +// ─── System context ────────────────────────────────────────────────────────── + +/// System information discovered at boot when running on a Raspberry Pi. +#[derive(Debug, Clone)] +pub struct RpiSystemContext { + pub model: RpiModel, + pub hostname: String, + pub ip_address: String, + pub wifi_interface: Option, + pub total_ram_mb: u64, + pub free_ram_mb: u64, + pub cpu_temp_celsius: Option, + pub gpio_available: bool, +} + +impl RpiSystemContext { + /// Attempt to detect the current board and collect system info. + /// Returns `None` when not running on a Raspberry Pi. + pub fn discover() -> Option { + let model = RpiModel::detect()?; + + let hostname = fs::read_to_string("/etc/hostname") + .unwrap_or_default() + .trim() + .to_string(); + + let ip_address = Self::get_ip_address(); + let wifi_interface = Self::get_wifi_interface(); + let (total_ram_mb, free_ram_mb) = Self::get_memory_info(); + let cpu_temp_celsius = Self::get_cpu_temp(); + let gpio_available = std::path::Path::new("/dev/gpiomem").exists(); + + Some(Self { + model, + hostname, + ip_address, + wifi_interface, + total_ram_mb, + free_ram_mb, + cpu_temp_celsius, + gpio_available, + }) + } + + /// Determine the primary non-loopback IPv4 address using a UDP routing trick. + /// No packet is ever sent — we just resolve the outbound route. + fn get_ip_address() -> String { + use std::net::UdpSocket; + UdpSocket::bind("0.0.0.0:0") + .and_then(|s| { + s.connect("8.8.8.8:80")?; + s.local_addr() + }) + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "unknown".to_string()) + } + + /// Returns the first wireless interface name listed in /proc/net/wireless, if any. + fn get_wifi_interface() -> Option { + let text = fs::read_to_string("/proc/net/wireless").ok()?; + text.lines() + .skip(2) // header rows + .find(|l| l.contains(':')) + .map(|l| l.split(':').next().unwrap_or("").trim().to_string()) + .filter(|s| !s.is_empty()) + } + + /// Read MemTotal and MemAvailable from /proc/meminfo and return (total_mb, free_mb). + fn get_memory_info() -> (u64, u64) { + let meminfo = fs::read_to_string("/proc/meminfo").unwrap_or_default(); + let mut total = 0u64; + let mut available = 0u64; + for line in meminfo.lines() { + if line.starts_with("MemTotal:") { + total = line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) + / 1024; + } + if line.starts_with("MemAvailable:") { + available = line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) + / 1024; + } + } + (total, available) + } + + /// Read CPU temperature from the thermal zone sysfs file (millidegrees → °C). + fn get_cpu_temp() -> Option { + fs::read_to_string("/sys/class/thermal/thermal_zone0/temp") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|t| t / 1000.0) + } + + /// Generate the system prompt section that describes this device to the LLM. + pub fn to_system_prompt(&self) -> String { + let mut s = String::new(); + let _ = writeln!(s, "## Running On Device (Raspberry Pi)"); + let _ = writeln!(s); + let _ = writeln!(s, "- Board: {}", self.model.display_name()); + let _ = writeln!(s, "- Hostname: {}", self.hostname); + let _ = writeln!(s, "- IP Address: {}", self.ip_address); + if let Some(ref iface) = self.wifi_interface { + let _ = writeln!(s, "- WiFi interface: {}", iface); + } + let _ = writeln!( + s, + "- RAM: {}MB total, {}MB available", + self.total_ram_mb, self.free_ram_mb + ); + if let Some(temp) = self.cpu_temp_celsius { + let _ = writeln!(s, "- CPU Temperature: {:.1}°C", temp); + } + if let Some(led_pin) = self.model.onboard_led_gpio() { + let _ = writeln!(s, "- Onboard ACT LED: BCM GPIO {}", led_pin); + } + if self.gpio_available { + let _ = writeln!(s, "- GPIO: available via rppal (/dev/gpiomem)"); + let _ = writeln!(s); + s.push_str( + "Use `gpio_rpi_write`, `gpio_rpi_read`, and `gpio_rpi_blink` for all GPIO \ + operations — they access /dev/gpiomem directly, no serial port or mpremote needed.\n", + ); + } + s + } + + /// Write an `rpi0.md` hardware context file to `~/.zeroclaw/hardware/devices/`. + /// Silently skips on failure so boot is never blocked. + pub fn write_hardware_context_file(&self) { + let Some(home) = directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()) else { + return; + }; + let devices_dir = home.join(".zeroclaw").join("hardware").join("devices"); + if let Err(e) = fs::create_dir_all(&devices_dir) { + tracing::warn!("Failed to create hardware devices dir: {e}"); + return; + } + + let path = devices_dir.join("rpi0.md"); + let content = self.device_profile_markdown(); + if let Err(e) = fs::write(&path, &content) { + tracing::warn!("Failed to write rpi0.md: {e}"); + } else { + tracing::debug!(path = %path.display(), "Wrote rpi0.md hardware context file"); + } + } + + fn device_profile_markdown(&self) -> String { + let mut s = String::new(); + let _ = writeln!(s, "# rpi0 — {}", self.model.display_name()); + let _ = writeln!(s); + let _ = writeln!(s, "## System"); + let _ = writeln!(s, "- Hostname: {}", self.hostname); + let _ = writeln!(s, "- IP: {} (at last boot)", self.ip_address); + let _ = writeln!(s, "- RAM: {}MB total", self.total_ram_mb); + let _ = writeln!( + s, + "- Runtime: ZeroClaw native (rppal — no serial, no mpremote)" + ); + if let Some(ref iface) = self.wifi_interface { + let _ = writeln!(s, "- WiFi interface: {}", iface); + } + let _ = writeln!(s); + let _ = writeln!(s, "## GPIO — BCM numbering"); + if let Some(led_pin) = self.model.onboard_led_gpio() { + let _ = writeln!( + s, + "- GPIO {led_pin}: ACT LED (onboard green LED) — use gpio_rpi_write/blink" + ); + } + let _ = writeln!(s, "- GPIO 2/3: I2C SDA/SCL"); + let _ = writeln!(s, "- GPIO 7-11: SPI"); + let _ = writeln!(s, "- All other BCM pins: general purpose"); + let _ = writeln!(s); + let _ = writeln!(s, "## Tool Usage Rules"); + let _ = writeln!(s, "- Single pin on/off → `gpio_rpi_write(pin, value)`"); + let _ = writeln!( + s, + "- Blink/repeat → `gpio_rpi_blink(pin, times, on_ms, off_ms)`" + ); + let _ = writeln!(s, "- Read pin → `gpio_rpi_read(pin)`"); + let _ = writeln!(s, "- System stats → `rpi_system_info()`"); + let _ = writeln!( + s, + "- DO NOT use `device_exec` or `mpremote` — not available on this board" + ); + let _ = writeln!( + s, + "- DO NOT use `gpio_write` (serial JSON) — use `gpio_rpi_write` instead" + ); + s + } +} + +// ─── Tool: gpio_rpi_write ──────────────────────────────────────────────────── + +/// Set a GPIO pin HIGH or LOW directly on this Raspberry Pi via rppal. +pub struct GpioRpiWriteTool; + +#[async_trait] +impl Tool for GpioRpiWriteTool { + fn name(&self) -> &str { + "gpio_rpi_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin HIGH (1) or LOW (0) directly on this Raspberry Pi. \ + Uses BCM pin numbers (e.g. 47 for the ACT LED on RPi 3B). \ + No serial port needed — accesses /dev/gpiomem via rppal." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO number (e.g. 47 for ACT LED on RPi 3B)" + }, + "value": { + "type": "integer", + "description": "1 for HIGH, 0 for LOW" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))? as u8; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + let state = if value == 0 { "LOW" } else { "HIGH" }; + + // Onboard ACT LED → Linux LED subsystem (sysfs) + if is_onboard_led(pin) { + let brightness = if value == 0 { "0" } else { "1" }; + let path = led_brightness_path() + .ok_or_else(|| anyhow::anyhow!("ACT LED sysfs path not found"))?; + ensure_led_trigger_none(); + fs::write(path, brightness)?; + return Ok(ToolResult { + success: true, + output: format!("ACT LED (GPIO {}) → {} (via sysfs)", pin, state), + error: None, + }); + } + + // Regular GPIO pin → rppal + let level = if value == 0 { + rppal::gpio::Level::Low + } else { + rppal::gpio::Level::High + }; + + tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let mut p = gpio.get(pin)?.into_output(); + p.write(level); + Ok::<_, anyhow::Error>(()) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("GPIO {} → {}", pin, state), + error: None, + }) + } +} + +// ─── Tool: gpio_rpi_read ───────────────────────────────────────────────────── + +/// Read a GPIO pin value on this Raspberry Pi via rppal. +pub struct GpioRpiReadTool; + +#[async_trait] +impl Tool for GpioRpiReadTool { + fn name(&self) -> &str { + "gpio_rpi_read" + } + + fn description(&self) -> &str { + "Read the current state (0 or 1) of a GPIO pin on this Raspberry Pi. \ + Uses BCM pin numbers." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO number" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))? as u8; + + // Onboard ACT LED → read from sysfs + if is_onboard_led(pin) { + let path = led_brightness_path() + .ok_or_else(|| anyhow::anyhow!("ACT LED sysfs path not found"))?; + let raw = fs::read_to_string(path)?.trim().to_string(); + let value: u8 = if raw == "0" { 0 } else { 1 }; + let state = if value == 0 { "LOW" } else { "HIGH" }; + return Ok(ToolResult { + success: true, + output: json!({ "pin": pin, "value": value, "state": state, "source": "sysfs" }) + .to_string(), + error: None, + }); + } + + // Regular GPIO pin → rppal + let value = tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let p = gpio.get(pin)?.into_input(); + Ok::<_, anyhow::Error>(match p.read() { + rppal::gpio::Level::Low => 0u8, + rppal::gpio::Level::High => 1u8, + }) + }) + .await??; + + Ok(ToolResult { + success: true, + output: json!({ "pin": pin, "value": value, "state": if value == 0 { "LOW" } else { "HIGH" } }).to_string(), + error: None, + }) + } +} + +// ─── Tool: gpio_rpi_blink ──────────────────────────────────────────────────── + +/// Blink a GPIO pin N times with configurable on/off timing via rppal. +pub struct GpioRpiBlinkTool; + +#[async_trait] +impl Tool for GpioRpiBlinkTool { + fn name(&self) -> &str { + "gpio_rpi_blink" + } + + fn description(&self) -> &str { + "Blink a GPIO pin N times with configurable on/off durations on this Raspberry Pi. \ + Suitable for LEDs, buzzers, or any repeated toggle. Uses BCM pin numbers." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO number (e.g. 47 for ACT LED on RPi 3B)" + }, + "times": { + "type": "integer", + "description": "Number of blink cycles (default 3)" + }, + "on_ms": { + "type": "integer", + "description": "Milliseconds pin stays HIGH per cycle (default 500)" + }, + "off_ms": { + "type": "integer", + "description": "Milliseconds pin stays LOW between cycles (default 500)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))? as u8; + let times = args + .get("times") + .and_then(|v| v.as_u64()) + .unwrap_or(3) + .min(100); // cap at 100 blinks to prevent runaway + let on_ms = args + .get("on_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(500) + .min(10_000); // cap at 10s + let off_ms = args + .get("off_ms") + .and_then(|v| v.as_u64()) + .unwrap_or(500) + .min(10_000); + + // Onboard ACT LED → Linux LED subsystem (async-friendly, no spawn_blocking) + if is_onboard_led(pin) { + let path = led_brightness_path() + .ok_or_else(|| anyhow::anyhow!("ACT LED sysfs path not found"))?; + ensure_led_trigger_none(); + for _ in 0..times { + fs::write(path, "1")?; + tokio::time::sleep(Duration::from_millis(on_ms)).await; + fs::write(path, "0")?; + tokio::time::sleep(Duration::from_millis(off_ms)).await; + } + return Ok(ToolResult { + success: true, + output: format!( + "Blinked ACT LED (GPIO {}) × {} ({}/{}ms) via sysfs", + pin, times, on_ms, off_ms + ), + error: None, + }); + } + + // Regular GPIO pin → rppal + tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let mut p = gpio.get(pin)?.into_output(); + for _ in 0..times { + p.set_high(); + std::thread::sleep(Duration::from_millis(on_ms)); + p.set_low(); + std::thread::sleep(Duration::from_millis(off_ms)); + } + Ok::<_, anyhow::Error>(()) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("Blinked GPIO {} × {} ({}/{}ms)", pin, times, on_ms, off_ms), + error: None, + }) + } +} + +// ─── Tool: rpi_system_info ─────────────────────────────────────────────────── + +/// Return current Raspberry Pi system information as JSON. +pub struct RpiSystemInfoTool; + +#[async_trait] +impl Tool for RpiSystemInfoTool { + fn name(&self) -> &str { + "rpi_system_info" + } + + fn description(&self) -> &str { + "Get current system information for this Raspberry Pi: model, RAM, \ + CPU temperature, IP address, and WiFi interface." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute(&self, _args: Value) -> anyhow::Result { + let ctx = RpiSystemContext::discover() + .ok_or_else(|| anyhow::anyhow!("Not running on a Raspberry Pi"))?; + + let info = json!({ + "model": ctx.model.display_name(), + "hostname": ctx.hostname, + "ip_address": ctx.ip_address, + "wifi_interface": ctx.wifi_interface, + "ram_total_mb": ctx.total_ram_mb, + "ram_free_mb": ctx.free_ram_mb, + "cpu_temp_celsius": ctx.cpu_temp_celsius, + "gpio_available": ctx.gpio_available, + "onboard_led_gpio": ctx.model.onboard_led_gpio(), + }); + + Ok(ToolResult { + success: true, + output: info.to_string(), + error: None, + }) + } +} diff --git a/src/hardware/serial.rs b/src/hardware/serial.rs new file mode 100644 index 0000000000..960bed2b5f --- /dev/null +++ b/src/hardware/serial.rs @@ -0,0 +1,298 @@ +//! Hardware serial transport — newline-delimited JSON over USB CDC. +//! +//! Implements the [`Transport`] trait with **lazy port opening**: the port is +//! opened for each `send()` call and closed immediately after the response is +//! received. This means multiple tools can use the same device path without +//! one holding the port exclusively. +//! +//! Wire protocol (ZeroClaw serial JSON): +//! ```text +//! Host → Device: {"cmd":"gpio_write","params":{"pin":25,"value":1}}\n +//! Device → Host: {"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n +//! ``` +//! +//! All I/O is wrapped in `tokio::time::timeout` — no blocking reads. + +use super::{ + protocol::{ZcCommand, ZcResponse}, + transport::{Transport, TransportError, TransportKind}, +}; +use async_trait::async_trait; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio_serial::SerialPortBuilderExt; + +/// Default timeout for a single send→receive round-trip (seconds). +const SEND_TIMEOUT_SECS: u64 = 5; + +/// Default baud rate for ZeroClaw serial devices. +pub const DEFAULT_BAUD: u32 = 115_200; + +/// Timeout for the ping handshake during device discovery (milliseconds). +const PING_TIMEOUT_MS: u64 = 300; + +/// Allowed serial device path prefixes — reject arbitrary paths for security. +/// Uses the shared allowlist from `crate::util`. +use crate::util::is_serial_path_allowed as is_path_allowed; + +/// Serial transport for ZeroClaw hardware devices. +/// +/// The port is **opened lazily** on each `send()` call and released immediately +/// after the response is read. This avoids exclusive-hold conflicts between +/// multiple tools or processes. +pub struct HardwareSerialTransport { + port_path: String, + baud_rate: u32, +} + +impl HardwareSerialTransport { + /// Create a new lazy-open serial transport. + /// + /// Does NOT open the port — that happens on the first `send()` call. + pub fn new(port_path: impl Into, baud_rate: u32) -> Self { + Self { + port_path: port_path.into(), + baud_rate, + } + } + + /// Create with the default baud rate (115 200). + pub fn with_default_baud(port_path: impl Into) -> Self { + Self::new(port_path, DEFAULT_BAUD) + } + + /// Port path this transport is bound to. + pub fn port_path(&self) -> &str { + &self.port_path + } + + /// Attempt a ping handshake to verify ZeroClaw firmware is running. + /// + /// Opens the port, sends `{"cmd":"ping","params":{}}`, waits up to + /// `PING_TIMEOUT_MS` for a response with `data.firmware == "zeroclaw"`. + /// + /// Returns `true` if a ZeroClaw device responds, `false` otherwise. + /// This method never returns an error — discovery must not hang on failure. + pub async fn ping_handshake(&self) -> bool { + let ping = ZcCommand::simple("ping"); + let json = match serde_json::to_string(&ping) { + Ok(j) => j, + Err(_) => return false, + }; + let result = tokio::time::timeout( + std::time::Duration::from_millis(PING_TIMEOUT_MS), + do_send(&self.port_path, self.baud_rate, &json), + ) + .await; + + match result { + Ok(Ok(resp)) => { + // Accept if firmware field is "zeroclaw" (in data or top-level) + resp.ok + && resp + .data + .get("firmware") + .and_then(|v| v.as_str()) + .map(|s| s == "zeroclaw") + .unwrap_or(false) + } + _ => false, + } + } +} + +#[async_trait] +impl Transport for HardwareSerialTransport { + async fn send(&self, cmd: &ZcCommand) -> Result { + if !is_path_allowed(&self.port_path) { + return Err(TransportError::Other(format!( + "serial path not allowed: {}", + self.port_path + ))); + } + + let json = serde_json::to_string(cmd) + .map_err(|e| TransportError::Protocol(format!("failed to serialize command: {e}")))?; + // Log command name only — never log the full payload (may contain large or sensitive data). + tracing::info!(port = %self.port_path, cmd = %cmd.cmd, "serial send"); + + tokio::time::timeout( + std::time::Duration::from_secs(SEND_TIMEOUT_SECS), + do_send(&self.port_path, self.baud_rate, &json), + ) + .await + .map_err(|_| TransportError::Timeout(SEND_TIMEOUT_SECS))? + } + + fn kind(&self) -> TransportKind { + TransportKind::Serial + } + + fn is_connected(&self) -> bool { + // Lightweight connectivity check: the device file must exist. + std::path::Path::new(&self.port_path).exists() + } +} + +/// Open the port, write the command, read one response line, return the parsed response. +/// +/// This is the inner function wrapped with `tokio::time::timeout` by the caller. +/// Do NOT add a timeout here — the outer caller owns the deadline. +async fn do_send(path: &str, baud: u32, json: &str) -> Result { + // Open port lazily — released when this function returns + let mut port = tokio_serial::new(path, baud) + .open_native_async() + .map_err(|e| { + // Match on the error kind for robust cross-platform disconnect detection. + match e.kind { + tokio_serial::ErrorKind::NoDevice => TransportError::Disconnected, + tokio_serial::ErrorKind::Io(io_kind) if io_kind == std::io::ErrorKind::NotFound => { + TransportError::Disconnected + } + _ => TransportError::Other(format!("failed to open {path}: {e}")), + } + })?; + + // Write command line + port.write_all(format!("{json}\n").as_bytes()) + .await + .map_err(TransportError::Io)?; + port.flush().await.map_err(TransportError::Io)?; + + // Read response line — port is moved into BufReader; write phase complete + let mut reader = BufReader::new(port); + let mut response_line = String::new(); + reader + .read_line(&mut response_line) + .await + .map_err(|e: std::io::Error| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + TransportError::Disconnected + } else { + TransportError::Io(e) + } + })?; + + let trimmed = response_line.trim(); + if trimmed.is_empty() { + return Err(TransportError::Protocol( + "empty response from device".to_string(), + )); + } + + serde_json::from_str(trimmed).map_err(|e| { + TransportError::Protocol(format!("invalid JSON response: {e} — got: {trimmed:?}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serial_transport_new_stores_path_and_baud() { + let t = HardwareSerialTransport::new("/dev/ttyACM0", 115_200); + assert_eq!(t.port_path(), "/dev/ttyACM0"); + assert_eq!(t.baud_rate, 115_200); + } + + #[test] + fn serial_transport_default_baud() { + let t = HardwareSerialTransport::with_default_baud("/dev/ttyACM0"); + assert_eq!(t.baud_rate, DEFAULT_BAUD); + } + + #[test] + fn serial_transport_kind_is_serial() { + let t = HardwareSerialTransport::with_default_baud("/dev/ttyACM0"); + assert_eq!(t.kind(), TransportKind::Serial); + } + + #[test] + fn is_connected_false_for_nonexistent_path() { + let t = HardwareSerialTransport::with_default_baud("/dev/ttyACM_does_not_exist_99"); + assert!(!t.is_connected()); + } + + #[test] + fn allowed_paths_accept_valid_prefixes() { + // Linux-only paths + #[cfg(target_os = "linux")] + { + assert!(is_path_allowed("/dev/ttyACM0")); + assert!(is_path_allowed("/dev/ttyUSB1")); + } + // macOS-only paths + #[cfg(target_os = "macos")] + { + assert!(is_path_allowed("/dev/tty.usbmodem14101")); + assert!(is_path_allowed("/dev/cu.usbmodem14201")); + assert!(is_path_allowed("/dev/tty.usbserial-1410")); + assert!(is_path_allowed("/dev/cu.usbserial-1410")); + } + // Windows-only paths + #[cfg(target_os = "windows")] + assert!(is_path_allowed("COM3")); + // Cross-platform: macOS paths always work on macOS, Linux paths on Linux + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + assert!(is_path_allowed("/dev/ttyACM0")); + assert!(is_path_allowed("/dev/tty.usbmodem14101")); + assert!(is_path_allowed("COM3")); + } + } + + #[test] + fn allowed_paths_reject_invalid_prefixes() { + assert!(!is_path_allowed("/dev/sda")); + assert!(!is_path_allowed("/etc/passwd")); + assert!(!is_path_allowed("/tmp/evil")); + assert!(!is_path_allowed("")); + } + + #[tokio::test] + async fn send_rejects_disallowed_path() { + let t = HardwareSerialTransport::new("/dev/sda", 115_200); + let result = t.send(&ZcCommand::simple("ping")).await; + assert!(matches!(result, Err(TransportError::Other(_)))); + } + + #[tokio::test] + async fn send_returns_disconnected_for_missing_device() { + // Use a platform-appropriate path that passes the serialpath allowlist + // but refers to a device that doesn't actually exist. + #[cfg(target_os = "linux")] + let path = "/dev/ttyACM_phase2_test_99"; + #[cfg(target_os = "macos")] + let path = "/dev/tty.usbmodemfake9900"; + #[cfg(target_os = "windows")] + let path = "COM99"; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + let path = "/dev/ttyACM_phase2_test_99"; + + let t = HardwareSerialTransport::new(path, 115_200); + let result = t.send(&ZcCommand::simple("ping")).await; + // Missing device → Disconnected or Timeout (system-dependent) + assert!( + matches!( + result, + Err(TransportError::Disconnected | TransportError::Timeout(_)) + ), + "expected Disconnected or Timeout, got {result:?}" + ); + } + + #[tokio::test] + async fn ping_handshake_returns_false_for_missing_device() { + #[cfg(target_os = "linux")] + let path = "/dev/ttyACM_phase2_test_99"; + #[cfg(target_os = "macos")] + let path = "/dev/tty.usbmodemfake9900"; + #[cfg(target_os = "windows")] + let path = "COM99"; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + let path = "/dev/ttyACM_phase2_test_99"; + + let t = HardwareSerialTransport::new(path, 115_200); + assert!(!t.ping_handshake().await); + } +} diff --git a/src/hardware/subprocess.rs b/src/hardware/subprocess.rs new file mode 100644 index 0000000000..d3b7027190 --- /dev/null +++ b/src/hardware/subprocess.rs @@ -0,0 +1,461 @@ +//! SubprocessTool — wraps any external binary as a [`Tool`]. +//! +//! Plugins do not need to be written in Rust. Any executable that follows the +//! ZeroClaw subprocess protocol is a valid tool: +//! +//! **Protocol (stdin/stdout, one line each):** +//! ```text +//! Host → binary stdin: {"device":"pico0","pin":5}\n +//! Binary → stdout: {"success":true,"output":"done","error":null}\n +//! ``` +//! +//! Error protocol: +//! - **Timeout (10 s)** — process is killed; `ToolResult::error` contains timeout message. +//! - **Non-zero exit** — process is killed; `ToolResult::error` contains stderr. +//! - **Empty / unparseable stdout** — `ToolResult::error` describes the failure. +//! +//! The schema advertised to the LLM is auto-generated from [`ToolManifest::parameters`]. + +use super::manifest::ToolManifest; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +/// Subprocess timeout — kill the child process after this many seconds. +const SUBPROCESS_TIMEOUT_SECS: u64 = 10; + +/// Timeout for waiting on child process exit after stdout has been read. +/// Prevents a hung cleanup phase from blocking indefinitely. +const PROCESS_EXIT_TIMEOUT_SECS: u64 = 5; + +/// A tool backed by an external subprocess. +/// +/// The binary receives the LLM-supplied JSON arguments on stdin (one line, +/// `\n`-terminated) and must write a single `ToolResult`-compatible JSON +/// object to stdout before exiting. +pub struct SubprocessTool { + /// Parsed plugin manifest (tool metadata + parameter definitions). + manifest: ToolManifest, + /// Resolved absolute path to the entry-point binary. + binary_path: PathBuf, +} + +impl SubprocessTool { + /// Create a new `SubprocessTool` from a manifest and resolved binary path. + pub fn new(manifest: ToolManifest, binary_path: PathBuf) -> Self { + Self { + manifest, + binary_path, + } + } + + /// Build JSON Schema `properties` and `required` arrays from the manifest. + fn build_schema_properties( + &self, + ) -> ( + serde_json::Map, + Vec, + ) { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for param in &self.manifest.parameters { + let mut prop = json!({ + "type": param.r#type, + "description": param.description, + }); + + if let Some(default) = ¶m.default { + prop["default"] = default.clone(); + } + + properties.insert(param.name.clone(), prop); + + if param.required { + required.push(serde_json::Value::String(param.name.clone())); + } + } + + (properties, required) + } +} + +#[async_trait] +impl Tool for SubprocessTool { + fn name(&self) -> &str { + &self.manifest.tool.name + } + + fn description(&self) -> &str { + &self.manifest.tool.description + } + + /// JSON Schema Draft 7 — auto-generated from `manifest.parameters`. + fn parameters_schema(&self) -> serde_json::Value { + let (properties, required) = self.build_schema_properties(); + json!({ + "type": "object", + "properties": properties, + "required": required, + }) + } + + /// Spawn the binary, write args to stdin, read `ToolResult` from stdout. + /// + /// Steps: + /// 1. Serialize `args` to a JSON string. + /// 2. Spawn `binary_path` with piped stdin/stdout/stderr. + /// 3. Write `\n` to child stdin; close stdin (signal EOF). + /// 4. Read one line from child stdout (10 s timeout). + /// 5. Kill the child process. + /// 6. Deserialize the line to `ToolResult`. + /// 7. On timeout → return error `ToolResult`; on empty/bad output → error. + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let args_json = serde_json::to_string(&args) + .map_err(|e| anyhow::anyhow!("failed to serialise args: {}", e))?; + + // Spawn child process. + let mut child = Command::new(&self.binary_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| { + anyhow::anyhow!( + "failed to spawn plugin '{}' at {}: {}", + self.manifest.tool.name, + self.binary_path.display(), + e + ) + })?; + + // Write JSON args + newline to stdin, then drop stdin to signal EOF. + if let Some(mut stdin) = child.stdin.take() { + if let Err(e) = stdin.write_all(args_json.as_bytes()).await { + let _ = child.kill().await; + return Err(anyhow::anyhow!( + "failed to write args to plugin '{}' stdin: {}", + self.manifest.tool.name, + e + )); + } + if let Err(e) = stdin.write_all(b"\n").await { + let _ = child.kill().await; + return Err(anyhow::anyhow!( + "failed to write newline to plugin '{}' stdin: {}", + self.manifest.tool.name, + e + )); + } + // stdin dropped here → child receives EOF + } + + // Take stdout and stderr handles before we move `child`. + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + + // Read one line from stdout with a hard timeout. + let read_result = match stdout_handle { + None => { + // No stdout — kill and error. + let _ = child.kill().await; + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "plugin '{}': could not attach stdout pipe", + self.manifest.tool.name + )), + }); + } + Some(stdout) => { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + timeout( + Duration::from_secs(SUBPROCESS_TIMEOUT_SECS), + reader.read_line(&mut line), + ) + .await + .map(|inner| inner.map(|_| line)) + } + }; + + match read_result { + // ── Timeout ──────────────────────────────────────────────────── + // The read deadline elapsed — force-kill the plugin and collect + // any stderr it emitted before dying. + Err(_elapsed) => { + let _ = child.kill().await; + let _ = child.wait().await; + let stderr_msg = collect_stderr(stderr_handle).await; + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "plugin '{}' timed out after {}s{}", + self.manifest.tool.name, + SUBPROCESS_TIMEOUT_SECS, + if stderr_msg.is_empty() { + String::new() + } else { + format!("; stderr: {}", stderr_msg) + } + )), + }) + } + + // ── I/O error reading stdout ─────────────────────────────────── + Ok(Err(io_err)) => { + let _ = child.kill().await; + let _ = child.wait().await; + let stderr_msg = collect_stderr(stderr_handle).await; + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "plugin '{}': I/O error reading stdout: {}{}", + self.manifest.tool.name, + io_err, + if stderr_msg.is_empty() { + String::new() + } else { + format!("; stderr: {}", stderr_msg) + } + )), + }) + } + + // ── Got a line ──────────────────────────────────────────────── + // Let the process finish naturally — plugins that write their + // result and then do cleanup should not be interrupted. + Ok(Ok(line)) => { + let child_status = + timeout(Duration::from_secs(PROCESS_EXIT_TIMEOUT_SECS), child.wait()) + .await + .ok() + .and_then(|r| r.ok()); + let stderr_msg = collect_stderr(stderr_handle).await; + let line = line.trim(); + + if line.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "plugin '{}': empty stdout{}", + self.manifest.tool.name, + if stderr_msg.is_empty() { + String::new() + } else { + format!("; stderr: {}", stderr_msg) + } + )), + }); + } + + match serde_json::from_str::(line) { + Ok(result) => { + // Non-zero exit overrides a parsed result: the plugin + // signalled failure even if it wrote a success line. + if let Some(status) = child_status { + if !status.success() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "plugin '{}' exited with {}{}", + self.manifest.tool.name, + status, + if stderr_msg.is_empty() { + String::new() + } else { + format!("; stderr: {}", stderr_msg) + } + )), + }); + } + } + Ok(result) + } + Err(parse_err) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "plugin '{}': failed to parse output as ToolResult: {} (got: {:?})", + self.manifest.tool.name, + parse_err, + // Truncate oversized output in the error message. + // Use char-based truncation to avoid panic on multi-byte UTF-8. + if line.chars().count() > 200 { + let truncated: String = line.chars().take(200).collect(); + format!("{}...", truncated) + } else { + line.to_string() + } + )), + }), + } + } + } + } +} + +/// Collect up to 512 bytes from an optional stderr handle. +/// Used to enrich error messages when a plugin writes nothing to stdout. +async fn collect_stderr(handle: Option) -> String { + use tokio::io::AsyncReadExt; + let Some(mut stderr) = handle else { + return String::new(); + }; + let mut buf = vec![0u8; 512]; + match stderr.read(&mut buf).await { + Ok(n) if n > 0 => String::from_utf8_lossy(&buf[..n]).trim().to_string(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hardware::manifest::{ExecConfig, ParameterDef, ToolManifest, ToolMeta}; + + fn make_manifest(name: &str, params: Vec) -> ToolManifest { + ToolManifest { + tool: ToolMeta { + name: name.to_string(), + version: "1.0.0".to_string(), + description: format!("Test tool: {}", name), + }, + exec: ExecConfig { + binary: "tool".to_string(), + }, + transport: None, + parameters: params, + } + } + + fn make_param(name: &str, ty: &str, required: bool) -> ParameterDef { + ParameterDef { + name: name.to_string(), + r#type: ty.to_string(), + description: format!("param {}", name), + required, + default: None, + } + } + + #[test] + fn name_and_description_come_from_manifest() { + let m = make_manifest("gpio_test", vec![]); + let tool = SubprocessTool::new(m, PathBuf::from("/bin/true")); + assert_eq!(tool.name(), "gpio_test"); + assert_eq!(tool.description(), "Test tool: gpio_test"); + } + + #[test] + fn schema_reflects_parameter_definitions() { + let params = vec![ + make_param("device", "string", true), + make_param("pin", "integer", true), + make_param("value", "integer", false), + ]; + let m = make_manifest("gpio_write", params); + let tool = SubprocessTool::new(m, PathBuf::from("/bin/true")); + let schema = tool.parameters_schema(); + + assert_eq!(schema["type"], "object"); + assert_eq!(schema["properties"]["device"]["type"], "string"); + assert_eq!(schema["properties"]["pin"]["type"], "integer"); + + let required = schema["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"device")); + assert!(req_names.contains(&"pin")); + assert!(!req_names.contains(&"value")); + } + + #[test] + fn schema_parameterless_tool_has_empty_required() { + let m = make_manifest("noop", vec![]); + let tool = SubprocessTool::new(m, PathBuf::from("/bin/true")); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.is_empty()); + } + + /// Verify that a binary which exits 0 with valid ToolResult JSON on stdout + /// is deserialised correctly. + #[tokio::test] + async fn execute_successful_subprocess() { + // Use `echo` to emit a valid ToolResult on stdout. + // `echo` prints its argument + newline and exits 0. + let result_json = r#"{"success":true,"output":"ok","error":null}"#; + + // Build a manifest pointing at `echo`. + let m = make_manifest("echo_tool", vec![]); + + // Construct an `echo` invocation as the binary with the JSON pre-set. + // We use `sh -c 'echo '` because the SubprocessTool feeds the + // manifest binary with args on stdin — echo just ignores stdin. + let script = format!("echo '{}'", result_json); + let binary = PathBuf::from("sh"); + // Override binary to `sh` and pass `-c` + script via a wrapper. + // Simpler: write a temp script. + let dir = tempfile::tempdir().unwrap(); + let script_path = dir.path().join("tool.sh"); + std::fs::write(&script_path, format!("#!/bin/sh\necho '{}'\n", result_json)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let tool = SubprocessTool::new(m, script_path.clone()); + let result = tool + .execute(serde_json::json!({})) + .await + .expect("execute should not return Err"); + + assert!(result.success, "expected success=true, got: {:?}", result); + assert_eq!(result.output, "ok"); + assert!(result.error.is_none()); + + let _ = script; + let _ = binary; + } + + /// A binary that hangs forever should be killed and return a timeout error. + #[tokio::test] + #[ignore = "slow: waits SUBPROCESS_TIMEOUT_SECS (~10 s) to elapse — run manually"] + async fn execute_timeout_kills_process_and_returns_error() { + // Script sleeps forever — SubprocessTool should kill it and return a + // "timed out" error once SUBPROCESS_TIMEOUT_SECS elapses. + let dir = tempfile::tempdir().unwrap(); + let script_path = dir.path().join("tool.sh"); + std::fs::write(&script_path, "#!/bin/sh\nsleep 60\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let m = make_manifest("sleep_tool", vec![]); + let tool = SubprocessTool::new(m, script_path); + let result = tool + .execute(serde_json::json!({})) + .await + .expect("should not propagate Err"); + + assert!(!result.success); + let err = result.error.unwrap(); + assert!( + err.contains("timed out"), + "expected 'timed out' in error, got: {}", + err + ); + } +} diff --git a/src/hardware/tool_registry.rs b/src/hardware/tool_registry.rs new file mode 100644 index 0000000000..9fa7ad237f --- /dev/null +++ b/src/hardware/tool_registry.rs @@ -0,0 +1,396 @@ +//! ToolRegistry — central store of all available tools. +//! +//! The LLM receives its tool list exclusively from the registry. +//! If a tool is not registered, the LLM cannot call it. +//! +//! Startup sequence (called via [`ToolRegistry::load`]): +//! 1. Register built-in hardware tools (`gpio_read`, `gpio_write`). +//! 2. Scan `~/.zeroclaw/tools/` for user plugin manifests. +//! 3. Build a [`SubprocessTool`] for each valid manifest and register it. +//! 4. Print the startup log summarising loaded tools and connected devices. +//! +//! Dispatch flow (called per LLM tool-call): +//! ```text +//! registry.dispatch("gpio_write", {"device":"pico0","pin":25,"value":1}) +//! │ +//! ├── look up "gpio_write" in tools HashMap +//! └── tool.execute(args) → ToolResult +//! ``` +//! +//! Device lookup is handled internally by each tool (GPIO tools read the +//! [`DeviceRegistry`] themselves via their `Arc>`). + +use super::device::DeviceRegistry; +use super::gpio::gpio_tools; +use super::loader::scan_plugin_dir; +use crate::tools::traits::{Tool, ToolResult}; +use std::collections::HashMap; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::RwLock; + +// ── ToolError ───────────────────────────────────────────────────────────────── + +/// Error type returned by [`ToolRegistry::dispatch`]. +#[derive(Debug, Error)] +pub enum ToolError { + /// No tool with the requested name is registered. + #[error("unknown tool: '{0}'")] + UnknownTool(String), + + /// The tool's `execute` method returned an error. + #[error("tool execution failed: {0}")] + ExecutionFailed(String), +} + +// ── ToolRegistry ────────────────────────────────────────────────────────────── + +/// Central registry of all available tools (built-ins + user plugins). +/// +/// Cheaply cloneable via the inner `Arc` — wrapping in an outer `Arc` is not +/// needed in most call sites. +pub struct ToolRegistry { + /// Map of tool name → boxed `Tool` impl. + tools: HashMap>, + /// Shared device registry — retained for future introspection / hot-reload. + device_registry: Arc>, +} + +impl ToolRegistry { + /// Load the registry at startup. + /// + /// 1. Instantiates the built-in GPIO tools. + /// 2. Scans `~/.zeroclaw/tools/` for user plugins and registers each one. + /// 3. Prints the startup log. + /// + /// Plugin loading errors are logged as warnings and never abort startup. + pub async fn load(devices: Arc>) -> anyhow::Result { + let mut tools: HashMap> = HashMap::new(); + + // ── 1. Built-in tools ───────────────────────────────────────────── + for tool in gpio_tools(devices.clone()) { + let name = tool.name().to_string(); + if tools.contains_key(&name) { + anyhow::bail!("duplicate built-in tool name: '{}'", name); + } + println!("[registry] loaded built-in: {}", name); + tools.insert(name, tool); + } + + // pico_flash — hardware feature only (needs UF2 assets embedded at compile time) + #[cfg(feature = "hardware")] + { + let tool: Box = + Box::new(super::pico_flash::PicoFlashTool::new(devices.clone())); + let name = tool.name().to_string(); + if tools.contains_key(&name) { + anyhow::bail!("duplicate built-in tool name: '{}'", name); + } + println!("[registry] loaded built-in: {}", name); + tools.insert(name, tool); + } + + // Phase 7: dynamic code tools (device_read_code, device_write_code, device_exec) + #[cfg(feature = "hardware")] + { + for tool in super::pico_code::device_code_tools(devices.clone()) { + let name = tool.name().to_string(); + if tools.contains_key(&name) { + anyhow::bail!("duplicate built-in tool name: '{}'", name); + } + println!("[registry] loaded built-in: {}", name); + tools.insert(name, tool); + } + } + + // Aardvark I2C / SPI / GPIO tools + datasheet tool (hardware feature only, + // and only when at least one Aardvark adapter is present at startup). + #[cfg(feature = "hardware")] + { + let has_aardvark = { + let reg = devices.read().await; + reg.has_aardvark() + }; + if has_aardvark { + for tool in super::aardvark_tools::aardvark_tools(devices.clone()) { + let name = tool.name().to_string(); + if tools.contains_key(&name) { + anyhow::bail!("duplicate built-in tool name: '{}'", name); + } + println!("[registry] loaded built-in: {}", name); + tools.insert(name, tool); + } + // Datasheet tool: always useful once an Aardvark is connected. + { + let tool: Box = Box::new(super::datasheet::DatasheetTool::new()); + let name = tool.name().to_string(); + if tools.contains_key(&name) { + anyhow::bail!("duplicate built-in tool name: '{}'", name); + } + println!("[registry] loaded built-in: {}", name); + tools.insert(name, tool); + } + } + } + + // ── 2. User plugins ─────────────────────────────────────────────── + let plugins = scan_plugin_dir(); + for plugin in plugins { + if tools.contains_key(&plugin.name) { + anyhow::bail!( + "duplicate tool name: plugin '{}' conflicts with an existing tool", + plugin.name + ); + } + println!( + "[registry] loaded plugin: {} (v{})", + plugin.name, plugin.version + ); + tools.insert(plugin.name, plugin.tool); + } + + // ── 3. Startup summary ──────────────────────────────────────────── + println!("[registry] {} tools available", tools.len()); + + { + let reg = devices.read().await; + let mut aliases = reg.aliases(); + aliases.sort_unstable(); // deterministic log order + for alias in aliases { + if let Some(device) = reg.get_device(alias) { + let port = device.port().unwrap_or("(native)"); + println!("[registry] {} ready → {}", alias, port); + } + } + } + + Ok(Self { + tools, + device_registry: devices, + }) + } + + /// Returns a JSON Schema array for **all** registered tools. + /// + /// Each element follows the shape the LLM expects for function calling: + /// ```json + /// { + /// "name": "gpio_write", + /// "description": "...", + /// "parameters": { "type": "object", "properties": { ... }, "required": [...] } + /// } + /// ``` + /// + /// Inject the result of this method into the LLM system prompt so the + /// model knows what tools exist and how to call them. + pub fn schemas(&self) -> Vec { + let mut schemas: Vec = self + .tools + .values() + .map(|tool| { + serde_json::json!({ + "name": tool.name(), + "description": tool.description(), + "parameters": tool.parameters_schema(), + }) + }) + .collect(); + + // Sort by name for deterministic output (important for prompt stability). + schemas.sort_by(|a, b| { + a["name"] + .as_str() + .unwrap_or("") + .cmp(b["name"].as_str().unwrap_or("")) + }); + + schemas + } + + /// Dispatch a tool call from the LLM. + /// + /// Looks up the tool by `name` and delegates to `tool.execute(args)`. + /// Returns [`ToolError::UnknownTool`] when no matching tool is found. + pub async fn dispatch( + &self, + name: &str, + args: serde_json::Value, + ) -> Result { + let tool = self + .tools + .get(name) + .ok_or_else(|| ToolError::UnknownTool(name.to_string()))?; + + tool.execute(args) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string())) + } + + /// List all registered tool names (sorted, for logging / debug). + pub fn list(&self) -> Vec<&str> { + let mut names: Vec<&str> = self.tools.keys().map(|s| s.as_str()).collect(); + names.sort_unstable(); + names + } + + /// Number of registered tools. + pub fn len(&self) -> usize { + self.tools.len() + } + + /// Whether the registry contains no tools. + pub fn is_empty(&self) -> bool { + self.tools.is_empty() + } + + /// Borrow the device registry (e.g. for introspection or hot-reload). + pub fn device_registry(&self) -> Arc> { + self.device_registry.clone() + } + + /// Consume the registry and return all tools as a `Vec`. + /// + /// Used by [`crate::hardware::boot`] to hand tools off to the agent loop, + /// which manages its own flat `Vec>` registry. + /// Order is alphabetical by tool name for deterministic output. + pub fn into_tools(self) -> Vec> { + let mut pairs: Vec<(String, Box)> = self.tools.into_iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.into_iter().map(|(_, tool)| tool).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build an empty DeviceRegistry behind the expected Arc>. + fn empty_device_registry() -> Arc> { + Arc::new(RwLock::new(DeviceRegistry::new())) + } + + #[tokio::test] + async fn load_registers_builtin_gpio_tools() { + let devices = empty_device_registry(); + let registry = ToolRegistry::load(devices).await.expect("load failed"); + + let names = registry.list(); + assert!( + names.contains(&"gpio_write"), + "gpio_write missing; got: {:?}", + names + ); + assert!( + names.contains(&"gpio_read"), + "gpio_read missing; got: {:?}", + names + ); + assert!(registry.len() >= 2); + } + + /// With the `hardware` feature, exactly 6 built-in tools must be present: + /// gpio_read, gpio_write, pico_flash, device_read_code, device_write_code, device_exec. + #[cfg(feature = "hardware")] + #[tokio::test] + async fn hardware_feature_registers_all_six_tools() { + let devices = empty_device_registry(); + let registry = ToolRegistry::load(devices).await.expect("load failed"); + + let names = registry.list(); + let expected = [ + "device_exec", + "device_read_code", + "device_write_code", + "gpio_read", + "gpio_write", + "pico_flash", + ]; + for tool_name in &expected { + assert!( + names.contains(tool_name), + "expected tool '{}' missing; got: {:?}", + tool_name, + names + ); + } + assert_eq!( + registry.len(), + 6, + "expected exactly 6 built-in tools, got {} (names: {:?})", + registry.len(), + names + ); + } + + #[tokio::test] + async fn schemas_returns_valid_json_schema_array() { + let devices = empty_device_registry(); + let registry = ToolRegistry::load(devices).await.expect("load failed"); + + let schemas = registry.schemas(); + assert!(!schemas.is_empty()); + + for schema in &schemas { + assert!(schema["name"].is_string(), "name missing in schema"); + assert!(schema["description"].is_string(), "description missing"); + assert!( + schema["parameters"]["type"] == "object", + "parameters.type should be object" + ); + } + } + + #[tokio::test] + async fn schemas_are_sorted_by_name() { + let devices = empty_device_registry(); + let registry = ToolRegistry::load(devices).await.expect("load failed"); + + let schemas = registry.schemas(); + let names: Vec<&str> = schemas + .iter() + .map(|s| s["name"].as_str().unwrap_or("")) + .collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted, "schemas not sorted by name"); + } + + #[tokio::test] + async fn dispatch_unknown_tool_returns_error() { + let devices = empty_device_registry(); + let registry = ToolRegistry::load(devices).await.expect("load failed"); + + let result = registry + .dispatch("nonexistent_tool", serde_json::json!({})) + .await; + + match result { + Err(ToolError::UnknownTool(name)) => assert_eq!(name, "nonexistent_tool"), + other => panic!("expected UnknownTool, got: {:?}", other), + } + } + + #[tokio::test] + async fn list_returns_sorted_tool_names() { + let devices = empty_device_registry(); + let registry = ToolRegistry::load(devices).await.expect("load failed"); + + let names = registry.list(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!( + names, sorted, + "list() should return sorted names; got: {:?}", + names + ); + } + + #[test] + fn tool_error_display() { + let e = ToolError::UnknownTool("bad_tool".to_string()); + assert_eq!(e.to_string(), "unknown tool: 'bad_tool'"); + + let e = ToolError::ExecutionFailed("oops".to_string()); + assert_eq!(e.to_string(), "tool execution failed: oops"); + } +} diff --git a/src/hardware/transport.rs b/src/hardware/transport.rs new file mode 100644 index 0000000000..fd574a13b9 --- /dev/null +++ b/src/hardware/transport.rs @@ -0,0 +1,115 @@ +//! Transport trait — decouples hardware tools from wire protocol. +//! +//! Implementations: +//! - `serial::HardwareSerialTransport` — lazy-open newline-delimited JSON over USB CDC (Phase 2) +//! - `SWDTransport` — memory read/write via probe-rs (Phase 7) +//! - `UF2Transport` — firmware flashing via UF2 mass storage (Phase 6) +//! - `NativeTransport` — direct Linux GPIO/I2C/SPI via rppal/sysfs (later) + +use super::protocol::{ZcCommand, ZcResponse}; +use async_trait::async_trait; +use thiserror::Error; + +/// Transport layer error. +#[derive(Debug, Error)] +pub enum TransportError { + /// Operation timed out. + #[error("transport timeout after {0}s")] + Timeout(u64), + + /// Transport is disconnected or device was removed. + #[error("transport disconnected")] + Disconnected, + + /// Protocol-level error (malformed JSON, id mismatch, etc.). + #[error("protocol error: {0}")] + Protocol(String), + + /// Underlying I/O error. + #[error("transport I/O error: {0}")] + Io(#[from] std::io::Error), + + /// Catch-all for transport-specific errors. + #[error("{0}")] + Other(String), +} + +/// Transport kind discriminator. +/// +/// Used for capability matching — some tools require a specific transport +/// (e.g. `pico_flash` requires UF2, `memory_read` prefers SWD). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TransportKind { + /// Newline-delimited JSON over USB CDC serial. + Serial, + /// SWD debug probe (probe-rs). + Swd, + /// UF2 mass storage firmware flashing. + Uf2, + /// Direct Linux GPIO/I2C/SPI (rppal, sysfs). + Native, + /// Total Phase Aardvark USB adapter (I2C/SPI/GPIO via C library). + Aardvark, +} + +impl std::fmt::Display for TransportKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Serial => write!(f, "serial"), + Self::Swd => write!(f, "swd"), + Self::Uf2 => write!(f, "uf2"), + Self::Native => write!(f, "native"), + Self::Aardvark => write!(f, "aardvark"), + } + } +} + +/// Transport trait — sends commands to a hardware device and receives responses. +/// +/// All implementations MUST use explicit `tokio::time::timeout` on I/O operations. +/// Callers should never assume success; always handle `TransportError`. +#[async_trait] +pub trait Transport: Send + Sync { + /// Send a command to the device and receive the response. + async fn send(&self, cmd: &ZcCommand) -> Result; + + /// What kind of transport this is. + fn kind(&self) -> TransportKind; + + /// Whether the transport is currently connected to a device. + fn is_connected(&self) -> bool; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transport_kind_display() { + assert_eq!(TransportKind::Serial.to_string(), "serial"); + assert_eq!(TransportKind::Swd.to_string(), "swd"); + assert_eq!(TransportKind::Uf2.to_string(), "uf2"); + assert_eq!(TransportKind::Native.to_string(), "native"); + } + + #[test] + fn transport_error_display() { + let err = TransportError::Timeout(5); + assert_eq!(err.to_string(), "transport timeout after 5s"); + + let err = TransportError::Disconnected; + assert_eq!(err.to_string(), "transport disconnected"); + + let err = TransportError::Protocol("bad json".into()); + assert_eq!(err.to_string(), "protocol error: bad json"); + + let err = TransportError::Other("custom".into()); + assert_eq!(err.to_string(), "custom"); + } + + #[test] + fn transport_kind_equality() { + assert_eq!(TransportKind::Serial, TransportKind::Serial); + assert_ne!(TransportKind::Serial, TransportKind::Swd); + } +} diff --git a/src/hardware/uf2.rs b/src/hardware/uf2.rs new file mode 100644 index 0000000000..b0ba8fabe7 --- /dev/null +++ b/src/hardware/uf2.rs @@ -0,0 +1,351 @@ +//! UF2 flashing support — detect BOOTSEL-mode Pico and deploy firmware. +//! +//! # Workflow +//! 1. [`find_rpi_rp2_mount`] — check well-known mount points for the RPI-RP2 volume +//! that appears when a Pico is held in BOOTSEL mode. +//! 2. [`ensure_firmware_dir`] — extract the bundled firmware files to +//! `~/.zeroclaw/firmware/pico/` if they aren't there yet. +//! 3. [`flash_uf2`] — copy the UF2 to the mount point; the Pico reboots automatically. +//! +//! # Embedded assets +//! Both firmware files are compiled into the binary with `include_bytes!` so +//! users never need to download them separately. + +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; + +// ── Embedded firmware ───────────────────────────────────────────────────────── + +/// MicroPython UF2 binary — copied to RPI-RP2 to install the base runtime. +const PICO_UF2: &[u8] = include_bytes!("../firmware/pico/zeroclaw-pico.uf2"); + +/// ZeroClaw serial protocol handler — written to the Pico after MicroPython boots. +pub const PICO_MAIN_PY: &[u8] = include_bytes!("../firmware/pico/main.py"); + +/// UF2 magic word 1 (little-endian bytes at offset 0 of every UF2 block). +const UF2_MAGIC1: [u8; 4] = [0x55, 0x46, 0x32, 0x0A]; + +// ── Volume detection ────────────────────────────────────────────────────────── + +/// Find the RPI-RP2 mount point if a Pico is connected in BOOTSEL mode. +/// +/// Checks: +/// - macOS: `/Volumes/RPI-RP2` +/// - Linux: `/media/*/RPI-RP2` and `/run/media/*/RPI-RP2` +pub fn find_rpi_rp2_mount() -> Option { + // macOS + let mac = PathBuf::from("/Volumes/RPI-RP2"); + if mac.exists() { + return Some(mac); + } + + // Linux — /media//RPI-RP2 or /run/media//RPI-RP2 + for base in &["/media", "/run/media"] { + if let Ok(entries) = std::fs::read_dir(base) { + for entry in entries.flatten() { + let candidate = entry.path().join("RPI-RP2"); + if candidate.exists() { + return Some(candidate); + } + } + } + } + + None +} + +// ── Firmware directory management ───────────────────────────────────────────── + +/// Ensure `~/.zeroclaw/firmware/pico/` exists and contains the bundled assets. +/// +/// Files are only written if they are absent — existing files are never overwritten +/// so users can substitute their own firmware. +/// +/// Returns the firmware directory path. +pub fn ensure_firmware_dir() -> Result { + use directories::BaseDirs; + + let base = BaseDirs::new().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?; + + let firmware_dir = base + .home_dir() + .join(".zeroclaw") + .join("firmware") + .join("pico"); + std::fs::create_dir_all(&firmware_dir)?; + + // UF2 — validate magic before writing so a broken stub is caught early. + let uf2_path = firmware_dir.join("zeroclaw-pico.uf2"); + if !uf2_path.exists() { + if PICO_UF2.len() < 8 || PICO_UF2[..4] != UF2_MAGIC1 { + bail!( + "Bundled UF2 is a placeholder — download the real MicroPython UF2 from \ + https://micropython.org/download/RPI_PICO/ and place it at \ + src/firmware/pico/zeroclaw-pico.uf2, then rebuild ZeroClaw." + ); + } + std::fs::write(&uf2_path, PICO_UF2)?; + tracing::info!(path = %uf2_path.display(), "extracted bundled UF2"); + } + + // main.py — always check UF2 magic even if path already exists (user may + // have placed a stub). main.py has no such check — it's just text. + let main_py_path = firmware_dir.join("main.py"); + if !main_py_path.exists() { + std::fs::write(&main_py_path, PICO_MAIN_PY)?; + tracing::info!(path = %main_py_path.display(), "extracted bundled main.py"); + } + + Ok(firmware_dir) +} + +// ── Flashing ────────────────────────────────────────────────────────────────── + +/// Copy the UF2 file to the RPI-RP2 mount point. +/// +/// macOS often returns "Operation not permitted" for `std::fs::copy` on FAT +/// volumes presented by BOOTSEL-mode Picos. We try four approaches in order +/// and return a clear manual-fallback message if all fail: +/// +/// 1. `std::fs::copy` — fast, no subprocess; works on most Linux setups. +/// 2. `cp ` — bypasses some macOS VFS permission layers. +/// 3. `sudo cp …` — escalates for locked volumes. +/// 4. Error — instructs the user to run the `sudo cp` manually. +pub async fn flash_uf2(mount_point: &Path, firmware_dir: &Path) -> Result<()> { + let uf2_src = firmware_dir.join("zeroclaw-pico.uf2"); + let uf2_dst = mount_point.join("firmware.uf2"); + let src_str = uf2_src.to_string_lossy().into_owned(); + let dst_str = uf2_dst.to_string_lossy().into_owned(); + + tracing::info!( + src = %src_str, + dst = %dst_str, + "flashing UF2" + ); + + // Validate UF2 magic before any copy attempt — prevents flashing a stub. + let data = std::fs::read(&uf2_src)?; + if data.len() < 8 || data[..4] != UF2_MAGIC1 { + bail!( + "UF2 at {} does not look like a valid UF2 file (magic mismatch). \ + Download from https://micropython.org/download/RPI_PICO/ and delete \ + the existing file so ZeroClaw can re-extract it.", + uf2_src.display() + ); + } + + // ── Attempt 1: std::fs::copy (works on Linux, sometimes blocked on macOS) ─ + { + let src = uf2_src.clone(); + let dst = uf2_dst.clone(); + let result = tokio::task::spawn_blocking(move || std::fs::copy(&src, &dst)) + .await + .map_err(|e| anyhow::anyhow!("copy task panicked: {e}")); + + match result { + Ok(Ok(_)) => { + tracing::info!("UF2 copy complete (std::fs::copy) — Pico will reboot"); + return Ok(()); + } + Ok(Err(e)) => tracing::warn!("std::fs::copy failed ({}), trying cp", e), + Err(e) => tracing::warn!("std::fs::copy task failed ({}), trying cp", e), + } + } + + // ── Attempt 2: cp via subprocess ────────────────────────────────────────── + { + /// Timeout for subprocess copy attempts (seconds). + const CP_TIMEOUT_SECS: u64 = 10; + + let out = tokio::time::timeout( + std::time::Duration::from_secs(CP_TIMEOUT_SECS), + tokio::process::Command::new("cp") + .arg(&src_str) + .arg(&dst_str) + .output(), + ) + .await; + + match out { + Err(_elapsed) => { + tracing::warn!("cp timed out after {}s, trying sudo cp", CP_TIMEOUT_SECS); + } + Ok(Ok(o)) if o.status.success() => { + tracing::info!("UF2 copy complete (cp) — Pico will reboot"); + return Ok(()); + } + Ok(Ok(o)) => { + let stderr = String::from_utf8_lossy(&o.stderr); + tracing::warn!("cp failed ({}), trying sudo cp", stderr.trim()); + } + Ok(Err(e)) => tracing::warn!("cp spawn failed ({}), trying sudo cp", e), + } + } + + // ── Attempt 3: sudo cp (non-interactive) ───────────────────────────────── + { + const SUDO_CP_TIMEOUT_SECS: u64 = 10; + + let out = tokio::time::timeout( + std::time::Duration::from_secs(SUDO_CP_TIMEOUT_SECS), + tokio::process::Command::new("sudo") + .args(["-n", "cp", &src_str, &dst_str]) + .output(), + ) + .await; + + match out { + Err(_elapsed) => { + tracing::warn!("sudo cp timed out after {}s", SUDO_CP_TIMEOUT_SECS); + } + Ok(Ok(o)) if o.status.success() => { + tracing::info!("UF2 copy complete (sudo cp) — Pico will reboot"); + return Ok(()); + } + Ok(Ok(o)) => { + let stderr = String::from_utf8_lossy(&o.stderr); + tracing::warn!("sudo cp failed: {}", stderr.trim()); + } + Ok(Err(e)) => tracing::warn!("sudo cp spawn failed: {}", e), + } + } + + // ── All attempts failed — give the user a clear manual command ──────────── + bail!( + "All copy methods failed. Run this command manually, then restart ZeroClaw:\n\ + \n sudo cp {src_str} {dst_str}\n" + ) +} + +/// Wait for `/dev/cu.usbmodem*` (macOS) or `/dev/ttyACM*` (Linux) to appear. +/// +/// Polls every `interval` for up to `timeout`. Returns the first matching path +/// found, or `None` if the deadline expires. +pub async fn wait_for_serial_port( + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Option { + #[cfg(target_os = "macos")] + let patterns = &["/dev/cu.usbmodem*"]; + #[cfg(target_os = "linux")] + let patterns = &["/dev/ttyACM*"]; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let patterns: &[&str] = &[]; + + let deadline = tokio::time::Instant::now() + timeout; + + loop { + for pattern in *patterns { + if let Ok(mut hits) = glob::glob(pattern) { + if let Some(Ok(path)) = hits.next() { + return Some(path); + } + } + } + + if tokio::time::Instant::now() >= deadline { + return None; + } + + tokio::time::sleep(interval).await; + } +} + +// ── Deploy main.py via mpremote ─────────────────────────────────────────────── + +/// Copy `main.py` to the Pico's MicroPython filesystem and soft-reset it. +/// +/// After the UF2 is flashed the Pico reboots into MicroPython but has no +/// `main.py` on its internal filesystem. This function uses `mpremote` to +/// upload the bundled `main.py` and issue a reset so it starts executing +/// immediately. +/// +/// Returns `Ok(())` on success or an error with a helpful fallback command. +pub async fn deploy_main_py(port: &Path, firmware_dir: &Path) -> Result<()> { + let main_py_src = firmware_dir.join("main.py"); + let src_str = main_py_src.to_string_lossy().into_owned(); + let port_str = port.to_string_lossy().into_owned(); + + if !main_py_src.exists() { + bail!( + "main.py not found at {} — run ensure_firmware_dir() first", + main_py_src.display() + ); + } + + tracing::info!( + src = %src_str, + port = %port_str, + "deploying main.py via mpremote" + ); + + let out = tokio::process::Command::new("mpremote") + .args([ + "connect", &port_str, "cp", &src_str, ":main.py", "+", "reset", + ]) + .output() + .await; + + match out { + Ok(o) if o.status.success() => { + tracing::info!("main.py deployed and Pico reset via mpremote"); + Ok(()) + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + bail!( + "mpremote failed (exit {}): {}.\n\ + Run manually:\n mpremote connect {port_str} cp {src_str} :main.py + reset", + o.status, + stderr.trim() + ) + } + Err(e) => { + bail!( + "mpremote not found or could not start ({e}).\n\ + Install it with: pip install mpremote\n\ + Then run: mpremote connect {port_str} cp {src_str} :main.py + reset" + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pico_uf2_has_valid_magic() { + assert!( + PICO_UF2.len() >= 8, + "bundled UF2 too small ({} bytes) — replace with real MicroPython UF2", + PICO_UF2.len() + ); + assert_eq!( + &PICO_UF2[..4], + &UF2_MAGIC1, + "bundled UF2 has wrong magic — replace with real MicroPython UF2 from \ + https://micropython.org/download/RPI_PICO/" + ); + } + + #[test] + fn pico_main_py_is_non_empty() { + assert!(!PICO_MAIN_PY.is_empty(), "bundled main.py is empty"); + } + + #[test] + fn pico_main_py_contains_zeroclaw_marker() { + let src = std::str::from_utf8(PICO_MAIN_PY).expect("main.py is not valid UTF-8"); + assert!( + src.contains("zeroclaw"), + "main.py should contain 'zeroclaw' firmware marker" + ); + } + + #[test] + fn find_rpi_rp2_mount_returns_none_when_not_connected() { + // This test runs on CI without a Pico attached — just verify it doesn't panic. + let _ = find_rpi_rp2_mount(); // may be Some or None depending on environment + } +} diff --git a/src/peripherals/mod.rs b/src/peripherals/mod.rs index 46f9055d33..f9b8db823b 100644 --- a/src/peripherals/mod.rs +++ b/src/peripherals/mod.rs @@ -234,6 +234,31 @@ pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result Vec> { + if !config.enabled || config.boards.is_empty() { + return Vec::new(); + } + let board_names: Vec = config.boards.iter().map(|b| b.board.clone()).collect(); + vec![ + Box::new(crate::tools::HardwareMemoryMapTool::new( + board_names.clone(), + )), + Box::new(crate::tools::HardwareBoardInfoTool::new( + board_names.clone(), + )), + Box::new(crate::tools::HardwareMemoryReadTool::new(board_names)), + ] +} + +#[cfg(not(feature = "hardware"))] +pub fn create_board_info_tools(_config: &PeripheralsConfig) -> Vec> { + Vec::new() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index b91998f84d..e1e2128c13 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -52,6 +52,8 @@ struct NativeChatRequest<'a> { temperature: f64, #[serde(skip_serializing_if = "Option::is_none")] tools: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, } #[derive(Debug, Serialize)] @@ -585,13 +587,26 @@ impl Provider for AnthropicProvider { Self::apply_cache_to_last_message(&mut messages); } + // Check for tool_choice override from the agent loop (e.g. "any" + // to force tool use for hardware requests). + let tool_choice_override = crate::agent::loop_::TOOL_CHOICE_OVERRIDE + .try_with(Clone::clone) + .ok() + .flatten(); + let native_tools = Self::convert_tools(request.tools); + let tool_choice = if native_tools.is_some() { + tool_choice_override.map(|tc| serde_json::json!({ "type": tc })) + } else { + None + }; let native_request = NativeChatRequest { model: model.to_string(), max_tokens: 4096, system: system_prompt, messages, temperature, - tools: Self::convert_tools(request.tools), + tools: native_tools, + tool_choice, }; let req = self @@ -1263,6 +1278,7 @@ mod tests { }], temperature: 0.7, tools: None, + tool_choice: None, }; let json = serde_json::to_string(&req).unwrap();