The Peripheral trait defines the interface for hardware peripherals in ZeroClaw. Implement this trait to integrate physical devices like microcontrollers (STM32, Arduino), GPIO boards (Raspberry Pi), sensors, and actuators as agent-controllable capabilities.
Overview
Peripherals are the agent’s “arms and legs” - remote devices that run minimal firmware and expose hardware capabilities as tools. The agent connects to peripherals, retrieves their tool definitions, and can invoke hardware functions just like software tools.
Trait Definition
use async_trait::async_trait;
use crate::tools::Tool;
#[async_trait]
pub trait Peripheral: Send + Sync {
fn name(&self) -> &str;
fn board_type(&self) -> &str;
async fn connect(&mut self) -> anyhow::Result<()>;
async fn disconnect(&mut self) -> anyhow::Result<()>;
async fn health_check(&self) -> bool;
fn tools(&self) -> Vec<Box<dyn Tool>>;
}
Required Methods
name
Return the human-readable instance name of this peripheral.
Unique instance identifier including index or serial (e.g., “nucleo-f401re-0”, “rpi-gpio-hat-1”)
Note: Must uniquely identify the specific device instance when multiple boards of the same type are connected.
board_type
Return the board type identifier.
Stable lowercase identifier (e.g., “nucleo-f401re”, “rpi-gpio”)
Note: Must match the key used in config schema’s peripheral section.
connect
Establish a connection to the peripheral hardware.
Responsibilities:
- Open underlying transport (serial port, GPIO bus, I²C, etc.)
- Perform initialization handshake with firmware
- Verify device is ready for commands
Example actions:
- Open
/dev/ttyACM0 at 115200 baud for STM32
- Export GPIO pins via sysfs for Raspberry Pi
- Perform version check handshake with firmware
disconnect
Disconnect from the peripheral and release all resources.
Responsibilities:
- Close serial ports
- Unexport GPIO pins
- Perform safe shutdown sequence
- Release any held locks or file descriptors
Note: After this call, health_check() should return false until connect() is called again.
health_check
Check whether the peripheral is reachable and responsive.
true if device responds within timeout
Implementation: Perform lightweight probe without altering device state (e.g., ping command, read status register).
Return the tools this peripheral exposes to the agent.
Array of tool implementations that delegate to hardware
Each tool:
- Implements the
Tool trait
- Delegates execution to underlying hardware (GPIO, sensors, actuators)
- Handles communication protocol with firmware
- Returns structured results to the agent
Example tools:
gpio_read - Read digital pin state
gpio_write - Set digital pin state
adc_read - Read analog sensor value
pwm_set - Control PWM output
sensor_read - Read temperature/humidity/etc.
Implementation Example
Here’s a simplified STM32 Nucleo peripheral:
use async_trait::async_trait;
use zeroclaw::peripherals::traits::Peripheral;
use zeroclaw::tools::{Tool, ToolResult};
use serialport::{SerialPort, SerialPortBuilder};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub struct NucleoF401RE {
name: String,
port_path: String,
port: Arc<Mutex<Option<Box<dyn SerialPort>>>>,
}
impl NucleoF401RE {
pub fn new(name: String, port_path: String) -> Self {
Self {
name,
port_path,
port: Arc::new(Mutex::new(None)),
}
}
fn send_command(&self, cmd: &str) -> anyhow::Result<String> {
let mut port_lock = self.port.lock().unwrap();
let port = port_lock
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
// Send command
port.write_all(cmd.as_bytes())?;
port.write_all(b"\n")?;
// Read response (simplified)
let mut buf = vec![0u8; 256];
let n = port.read(&mut buf)?;
let response = String::from_utf8_lossy(&buf[..n]).to_string();
Ok(response)
}
}
#[async_trait]
impl Peripheral for NucleoF401RE {
fn name(&self) -> &str {
&self.name
}
fn board_type(&self) -> &str {
"nucleo-f401re"
}
async fn connect(&mut self) -> anyhow::Result<()> {
let port = serialport::new(&self.port_path, 115200)
.timeout(Duration::from_millis(1000))
.open()?;
let mut port_lock = self.port.lock().unwrap();
*port_lock = Some(port);
// Perform handshake
drop(port_lock);
let response = self.send_command("PING")?;
if !response.contains("PONG") {
anyhow::bail!("Handshake failed: {}", response);
}
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
let mut port_lock = self.port.lock().unwrap();
*port_lock = None;
Ok(())
}
async fn health_check(&self) -> bool {
self.send_command("PING")
.map(|r| r.contains("PONG"))
.unwrap_or(false)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(GpioReadTool::new(self.port.clone())),
Box::new(GpioWriteTool::new(self.port.clone())),
]
}
}
// Example GPIO tool
struct GpioWriteTool {
port: Arc<Mutex<Option<Box<dyn SerialPort>>>>,
}
impl GpioWriteTool {
fn new(port: Arc<Mutex<Option<Box<dyn SerialPort>>>>) -> Self {
Self { port }
}
}
#[async_trait]
impl Tool for GpioWriteTool {
fn name(&self) -> &str {
"gpio_write"
}
fn description(&self) -> &str {
"Write digital output to GPIO pin"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"pin": {
"type": "number",
"description": "GPIO pin number"
},
"value": {
"type": "boolean",
"description": "Output state (true=HIGH, false=LOW)"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let pin = args["pin"].as_u64().ok_or_else(|| anyhow::anyhow!("Missing pin"))?;
let value = args["value"].as_bool().ok_or_else(|| anyhow::anyhow!("Missing value"))?;
let cmd = format!("GPIO_WRITE {} {}", pin, if value { 1 } else { 0 });
let mut port_lock = self.port.lock().unwrap();
let port = port_lock
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Not connected"))?;
port.write_all(cmd.as_bytes())?;
port.write_all(b"\n")?;
let mut buf = vec![0u8; 256];
let n = port.read(&mut buf)?;
let response = String::from_utf8_lossy(&buf[..n]);
if response.contains("OK") {
Ok(ToolResult {
success: true,
output: format!("Pin {} set to {}", pin, if value { "HIGH" } else { "LOW" }),
error: None,
})
} else {
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Command failed: {}", response)),
})
}
}
}
Communication Protocol
Typical firmware communication pattern:
Agent → Firmware: PING\n
Firmware → Agent: PONG\n
Agent → Firmware: GPIO_WRITE 13 1\n
Firmware → Agent: OK\n
Agent → Firmware: GPIO_READ 12\n
Firmware → Agent: VALUE 1\n
Agent → Firmware: ADC_READ 0\n
Firmware → Agent: VALUE 3.14\n
See docs/hardware-peripherals-design.md for complete protocol specification.
Factory Registration
Register your peripheral in the factory:
// src/peripherals/mod.rs
pub fn create_peripheral(board_type: &str, config: &PeripheralConfig) -> Box<dyn Peripheral> {
match board_type {
"nucleo-f401re" => Box::new(NucleoF401RE::new(
config.name.clone(),
config.port.clone(),
)),
"rpi-gpio" => Box::new(RaspberryPiGpio::new(config)),
_ => panic!("Unknown peripheral type: {}", board_type),
}
}
Configuration Example
[peripherals]
enable = true
[[peripherals.devices]]
name = "nucleo-f401re-0"
board_type = "nucleo-f401re"
port = "/dev/ttyACM0"
baud_rate = 115200
[[peripherals.devices]]
name = "rpi-gpio-hat-1"
board_type = "rpi-gpio"
base_path = "/sys/class/gpio"
Lifecycle Flow
- Startup: Agent reads peripheral config
- Factory: Creates peripheral instances via factory
- Connect: Agent calls
connect() on each peripheral
- Tool Registration: Agent calls
tools() and merges into tool registry
- Runtime: LLM can invoke peripheral tools like any other tool
- Health Monitoring: Periodic
health_check() calls
- Shutdown: Agent calls
disconnect() on cleanup
Best Practices
Hardware Safety: Validate all parameters before sending to firmware. Invalid GPIO operations can damage hardware.
Timeouts: Use reasonable timeouts for serial/I²C operations (typically 100-1000ms). Hardware can be slow.
Error Recovery: Implement reconnection logic for transient failures (cable disconnects, USB resets).
Thread Safety: Use Arc<Mutex<>> for shared resources like serial ports since tools may be called concurrently.
Firmware Protocol: Keep protocol simple and text-based for debugging. Binary protocols are harder to troubleshoot.
Supported Board Types
Current implementations:
- nucleo-f401re - STM32 Nucleo F401RE over USB serial
- rpi-gpio - Raspberry Pi GPIO via sysfs
Firmware Development
See docs/hardware-peripherals-design.md for:
- Communication protocol specification
- Firmware implementation guide
- Example firmware for STM32 (C/C++)
- Testing and debugging procedures
Testing
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_connect_disconnect() {
let mut peripheral = NucleoF401RE::new(
"test-board".to_string(),
"/dev/ttyACM0".to_string(),
);
peripheral.connect().await.unwrap();
assert!(peripheral.health_check().await);
peripheral.disconnect().await.unwrap();
assert!(!peripheral.health_check().await);
}
#[tokio::test]
async fn test_tools_registration() {
let peripheral = NucleoF401RE::new(
"test-board".to_string(),
"/dev/ttyACM0".to_string(),
);
let tools = peripheral.tools();
assert!(!tools.is_empty());
assert!(tools.iter().any(|t| t.name() == "gpio_write"));
}
}