0fded7f10d
* feat(hardware): add RPi GPIO, Aardvark I2C/SPI/GPIO, and hardware plugin system
Extends the hardware subsystem with three clusters of functionality,
all feature-gated (hardware / peripheral-rpi) with no impact on default builds.
Raspberry Pi native support:
- src/hardware/rpi.rs: board self-discovery (model, serial, revision),
sysfs GPIO pin read/write, and ACT LED control
- scripts/99-act-led.rules: udev rule for non-root ACT LED access
- scripts/deploy-rpi.sh, scripts/rpi-config.toml, scripts/zeroclaw.service:
one-shot deployment helper and systemd service template
Total Phase Aardvark USB adapter (I2C / SPI / GPIO):
- crates/aardvark-sys/: new workspace crate with FFI bindings loaded at
runtime via libloading; graceful stub fallback when .so is absent or
arch mismatches (Rosetta 2 detection)
- src/hardware/aardvark.rs: AardvarkTransport implementing Transport trait
- src/hardware/aardvark_tools.rs: agent tools i2c_scan, i2c_read,
i2c_write, spi_transfer, gpio_aardvark
- src/hardware/datasheet.rs: datasheet search/download for detected devices
- docs/aardvark-integration.md, examples/hardware/aardvark/: guide + examples
Hardware plugin / ToolRegistry system:
- src/hardware/tool_registry.rs: ToolRegistry for hardware module tool sets
- src/hardware/loader.rs, src/hardware/manifest.rs: manifest-driven loader
- src/hardware/subprocess.rs: subprocess execution helper for board I/O
- src/gateway/hardware_context.rs: POST /api/hardware/reload endpoint
- src/hardware/mod.rs: exports all new modules; merge_hardware_tools and
load_hardware_context_prompt helpers
Integration hooks (minimal surface):
- src/hardware/device.rs: DeviceKind::Aardvark, DeviceRuntime::Aardvark,
has_aardvark / resolve_aardvark_device on DeviceRegistry
- src/hardware/transport.rs: TransportKind::Aardvark
- src/peripherals/mod.rs: gate create_board_info_tools behind hardware feature
- src/agent/loop_.rs: TOOL_CHOICE_OVERRIDE task-local for Anthropic provider
- src/providers/anthropic.rs: read TOOL_CHOICE_OVERRIDE; add tool_choice field
- Cargo.toml: add aardvark-sys to workspace and as dependency
- firmware/zeroclaw-nucleo/: update Cargo.toml and Cargo.lock
Non-goals:
- No changes to agent orchestration, channels, providers, or security policy
- No new config keys outside existing [hardware] / [peripherals] sections
- No CI workflow changes
Risk: Low. All new paths are feature-gated; aardvark.so loads at runtime
only when present. No schema migrations or persistent state introduced.
Rollback: revert this single commit.
* fix(hardware): resolve clippy and rustfmt CI failures
- struct_excessive_bools: allow on DeviceCapabilities (7 bool fields needed)
- unnecessary_debug_formatting: use .display() instead of {:?} for paths
- stable_sort_primitive: replace .sort() with .sort_unstable() on &str slices
* fix(hardware): add missing serial/uf2/pico modules declared in mod.rs
cargo fmt was exiting with code 1 because mod.rs declared pub mod serial,
uf2, pico_flash, pico_code but those files were missing from the branch.
Also apply auto-formatting to loader.rs.
* fix(hardware): apply rustfmt 1.92.0 formatting (matches CI toolchain)
* docs(scripts): add RPi deployment and interaction guide
* push
* feat(firmware): add initial Pico firmware and serial device handling
- Introduced main.py for ZeroClaw Pico firmware with a placeholder for MicroPython implementation.
- Added binary UF2 file for Pico deployment.
- Implemented serial device enumeration and validation in the hardware module, enhancing security by restricting allowed serial paths.
- Updated related modules to integrate new serial device functionality.
---------
Co-authored-by: ehushubhamshaw <eshaw1@wpi.edu>
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
//! 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.
|
|
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<String>, 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<String>) -> 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<ZcResponse, TransportError> {
|
|
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<ZcResponse, TransportError> {
|
|
// 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);
|
|
}
|
|
}
|