Skip to main content
ZeroClaw can control physical hardware through peripherals — microcontrollers, Raspberry Pi GPIO, sensors, and actuators. Peripherals expose their capabilities as tools that the agent can invoke.

Overview

Peripherals implement the Peripheral trait, which provides:
  • Connection management: Connect to hardware over serial, native GPIO, or network
  • Tool exposure: Each peripheral exposes tools the agent can use
  • Health monitoring: Check device connectivity and status
#[async_trait]
pub trait Peripheral: Send + Sync {
    fn name(&self) -> &str;
    fn board_type(&self) -> &str;
    async fn connect(&mut self) -> Result<()>;
    async fn disconnect(&mut self) -> Result<()>;
    async fn health_check(&self) -> bool;
    fn tools(&self) -> Vec<Box<dyn Tool>>;
}

Supported Hardware

ZeroClaw supports multiple hardware platforms:

Raspberry Pi GPIO

Native GPIO control via rppal (BCM pin numbering)

STM32 Nucleo

Serial-connected microcontrollers (Nucleo-F401RE, etc.)

Arduino

Arduino Uno and compatible boards

Arduino Uno Q

WiFi-connected Arduino via Bridge app

Step-by-Step Setup

1
Enable Hardware Feature
2
Build with hardware support:
3
cargo build --features hardware
4
For Raspberry Pi GPIO:
5
cargo build --features "hardware,peripheral-rpi"
6
Configure Peripherals
7
Add peripherals to config.toml:
8
[peripherals]
enabled = true

# Raspberry Pi GPIO (native)
[[peripherals.boards]]
board = "rpi-gpio"
transport = "native"

# STM32 Nucleo over serial
[[peripherals.boards]]
board = "nucleo-f401re"
transport = "serial"
path = "/dev/ttyACM0"
baud = 115200

# Arduino Uno over serial
[[peripherals.boards]]
board = "arduino-uno"
transport = "serial"
path = "/dev/ttyUSB0"
baud = 115200

# Arduino Uno Q over WiFi bridge
[[peripherals.boards]]
board = "uno-q"
transport = "bridge"
9
Flash Firmware (for microcontrollers)
10
For Arduino:
11
zeroclaw peripheral flash --port /dev/ttyUSB0
12
For STM32 Nucleo:
13
zeroclaw peripheral flash-nucleo
14
Verify Connection
15
List configured peripherals:
16
zeroclaw peripheral list
17
Output:
18
Configured peripherals:
  rpi-gpio      native    (native)
  nucleo-f401re serial    /dev/ttyACM0
  arduino-uno   serial    /dev/ttyUSB0
19
Use Hardware Tools
20
Start the agent and use hardware capabilities:
21
zeroclaw chat "Turn on GPIO pin 17"
zeroclaw chat "Read the value from GPIO pin 27"

Raspberry Pi GPIO Example

Here’s how the Raspberry Pi GPIO peripheral is implemented (src/peripherals/rpi.rs):
use crate::peripherals::traits::Peripheral;
use crate::tools::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};

pub struct RpiGpioPeripheral {
    board: PeripheralBoardConfig,
}

impl RpiGpioPeripheral {
    pub fn new(board: PeripheralBoardConfig) -> Self {
        Self { board }
    }

    pub async fn connect_from_config(board: &PeripheralBoardConfig) -> Result<Self> {
        let mut peripheral = Self::new(board.clone());
        peripheral.connect().await?;
        Ok(peripheral)
    }
}

#[async_trait]
impl Peripheral for RpiGpioPeripheral {
    fn name(&self) -> &str {
        &self.board.board
    }

    fn board_type(&self) -> &str {
        "rpi-gpio"
    }

    async fn connect(&mut self) -> Result<()> {
        // Verify GPIO is accessible
        let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??;
        drop(result);
        Ok(())
    }

    async fn disconnect(&mut self) -> Result<()> {
        Ok(())
    }

    async fn health_check(&self) -> bool {
        tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new().is_ok())
            .await
            .unwrap_or(false)
    }

    fn tools(&self) -> Vec<Box<dyn Tool>> {
        vec![
            Box::new(RpiGpioReadTool),
            Box::new(RpiGpioWriteTool),
        ]
    }
}

GPIO Read Tool

struct RpiGpioReadTool;

#[async_trait]
impl Tool for RpiGpioReadTool {
    fn name(&self) -> &str {
        "gpio_read"
    }

    fn description(&self) -> &str {
        "Read the value (0 or 1) of a GPIO pin on Raspberry Pi. Uses BCM pin numbers (e.g. 17, 27)."
    }

    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "pin": {
                    "type": "integer",
                    "description": "BCM GPIO pin number (e.g. 17, 27)"
                }
            },
            "required": ["pin"]
        })
    }

    async fn execute(&self, args: Value) -> Result<ToolResult> {
        let pin = args
            .get("pin")
            .and_then(|v| v.as_u64())
            .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
        let pin_u8 = pin as u8;

        let value = tokio::task::spawn_blocking(move || {
            let gpio = rppal::gpio::Gpio::new()?;
            let pin = gpio.get(pin_u8)?.into_input();
            Ok::<_, anyhow::Error>(match pin.read() {
                rppal::gpio::Level::Low => 0,
                rppal::gpio::Level::High => 1,
            })
        })
        .await??;

        Ok(ToolResult {
            success: true,
            output: format!("pin {} = {}", pin, value),
            error: None,
        })
    }
}

GPIO Write Tool

struct RpiGpioWriteTool;

#[async_trait]
impl Tool for RpiGpioWriteTool {
    fn name(&self) -> &str {
        "gpio_write"
    }

    fn description(&self) -> &str {
        "Set a GPIO pin high (1) or low (0) on Raspberry Pi. Uses BCM pin numbers."
    }

    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "pin": {
                    "type": "integer",
                    "description": "BCM GPIO pin number"
                },
                "value": {
                    "type": "integer",
                    "description": "0 for low, 1 for high"
                }
            },
            "required": ["pin", "value"]
        })
    }

    async fn execute(&self, args: Value) -> Result<ToolResult> {
        let pin = args
            .get("pin")
            .and_then(|v| v.as_u64())
            .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
        let value = args
            .get("value")
            .and_then(|v| v.as_u64())
            .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
        let pin_u8 = pin as u8;
        let level = match value {
            0 => rppal::gpio::Level::Low,
            _ => rppal::gpio::Level::High,
        };

        tokio::task::spawn_blocking(move || {
            let gpio = rppal::gpio::Gpio::new()?;
            let mut pin = gpio.get(pin_u8)?.into_output();
            pin.write(level);
            Ok::<_, anyhow::Error>(())
        })
        .await??;

        Ok(ToolResult {
            success: true,
            output: format!("pin {} = {}", pin, value),
            error: None,
        })
    }
}

Serial Peripheral Communication

For STM32 and Arduino boards, ZeroClaw uses a serial protocol to communicate with firmware:

Protocol Format

Commands are JSON-based over serial:
{"cmd": "gpio_read", "pin": 13}
{"cmd": "gpio_write", "pin": 13, "value": 1}
{"cmd": "capabilities"}
Responses:
{"ok": true, "value": 1}
{"ok": false, "error": "Invalid pin"}
{"capabilities": ["gpio_read", "gpio_write", "analog_read"]}

Serial Transport

From src/peripherals/serial.rs:
pub struct SerialTransport {
    port: Arc<Mutex<Box<dyn serialport::SerialPort>>>,
}

impl SerialTransport {
    pub fn new(path: &str, baud: u32) -> Result<Self> {
        let port = serialport::new(path, baud)
            .timeout(Duration::from_secs(2))
            .open()?;
        
        Ok(Self {
            port: Arc::new(Mutex::new(port)),
        })
    }

    pub async fn send_command(&self, cmd: &str) -> Result<String> {
        let mut port = self.port.lock().await;
        
        // Send command
        port.write_all(cmd.as_bytes())?;
        port.write_all(b"\n")?;
        port.flush()?;
        
        // Read response
        let mut buffer = Vec::new();
        let mut byte = [0u8; 1];
        
        loop {
            port.read_exact(&mut byte)?;
            if byte[0] == b'\n' {
                break;
            }
            buffer.push(byte[0]);
        }
        
        Ok(String::from_utf8(buffer)?)
    }
}

Arduino Uno Q (WiFi Bridge)

For wireless control, use the Arduino Uno Q Bridge:

Setup Bridge

zeroclaw peripheral setup-uno-q --host "192.168.1.100"
This configures the Bridge app to forward commands to the Arduino.

Bridge Configuration

[[peripherals.boards]]
board = "uno-q"
transport = "bridge"
The bridge exposes the same GPIO tools but communicates over HTTP instead of serial.

Creating Custom Peripherals

1
Implement the Peripheral Trait
2
use crate::peripherals::traits::Peripheral;
use crate::tools::{Tool, ToolResult};
use async_trait::async_trait;

pub struct MyCustomPeripheral {
    device_path: String,
    connected: bool,
}

impl MyCustomPeripheral {
    pub fn new(device_path: String) -> Self {
        Self {
            device_path,
            connected: false,
        }
    }
}

#[async_trait]
impl Peripheral for MyCustomPeripheral {
    fn name(&self) -> &str {
        "my-custom-peripheral"
    }

    fn board_type(&self) -> &str {
        "custom"
    }

    async fn connect(&mut self) -> Result<()> {
        // Initialize your hardware connection
        self.connected = true;
        Ok(())
    }

    async fn disconnect(&mut self) -> Result<()> {
        self.connected = false;
        Ok(())
    }

    async fn health_check(&self) -> bool {
        self.connected
    }

    fn tools(&self) -> Vec<Box<dyn Tool>> {
        vec![
            Box::new(MyCustomTool),
        ]
    }
}
3
Create Hardware Tools
4
struct MyCustomTool;

#[async_trait]
impl Tool for MyCustomTool {
    fn name(&self) -> &str {
        "custom_sensor_read"
    }

    fn description(&self) -> &str {
        "Read data from custom sensor"
    }

    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "sensor_id": {
                    "type": "integer",
                    "description": "Sensor ID (0-7)"
                }
            },
            "required": ["sensor_id"]
        })
    }

    async fn execute(&self, args: Value) -> Result<ToolResult> {
        let sensor_id = args["sensor_id"].as_u64()
            .ok_or_else(|| anyhow::anyhow!("Missing sensor_id"))?;
        
        // Read from your hardware
        let value = read_sensor(sensor_id as u8).await?;
        
        Ok(ToolResult {
            success: true,
            output: format!("Sensor {} = {}", sensor_id, value),
            error: None,
        })
    }
}
5
Register in Factory
6
Add to src/peripherals/mod.rs:
7
pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
    let mut tools = Vec::new();
    
    for board in &config.boards {
        if board.board == "my-custom" {
            let peripheral = MyCustomPeripheral::new(board.path.clone());
            tools.extend(peripheral.tools());
        }
    }
    
    Ok(tools)
}

Best Practices

  • Serial: STM32, Arduino, ESP32 (reliable, wired)
  • Native: Raspberry Pi GPIO (fastest, direct access)
  • Bridge/Network: Wireless devices (flexible, may have latency)
async fn connect(&mut self) -> Result<()> {
    for attempt in 1..=3 {
        match self.try_connect().await {
            Ok(()) => return Ok(()),
            Err(e) if attempt < 3 => {
                tracing::warn!("Connection attempt {attempt} failed: {e}");
                tokio::time::sleep(Duration::from_secs(1)).await;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}
if pin > 40 {
    return Ok(ToolResult {
        success: false,
        output: String::new(),
        error: Some(format!("Invalid pin: {pin} (must be 0-40)")),
    });
}
// GPIO libraries are often blocking
let value = tokio::task::spawn_blocking(move || {
    let gpio = rppal::gpio::Gpio::new()?;
    let pin = gpio.get(pin_num)?;
    Ok(pin.read())
}).await??;
async fn connect(&mut self) -> Result<()> {
    let version = self.query_firmware_version().await?;
    
    if version < MINIMUM_FIRMWARE_VERSION {
        anyhow::bail!(
            "Firmware too old: {version}. Please flash version {MINIMUM_FIRMWARE_VERSION} or later."
        );
    }
    
    Ok(())
}

Troubleshooting

Add your user to the dialout group:
sudo usermod -a -G dialout $USER
# Log out and back in
Run with appropriate permissions or add user to gpio group:
sudo usermod -a -G gpio $USER
Check baud rate, connection, and firmware:
# Test serial connection
screen /dev/ttyACM0 115200

# Send test command
{"cmd":"capabilities"}
Press reset button on the board after flashing:
zeroclaw peripheral flash --port /dev/ttyUSB0
# Press reset button on Arduino

Next Steps

Creating Tools

Learn how to create custom tools

Gateway Setup

Expose your agent via HTTP

Build docs developers (and LLMs) love