Ehu shubham shaw contribution --> Hardware support (#306)

* feat: add ZeroClaw firmware for ESP32 and Nucleo

* Introduced new firmware for ZeroClaw on ESP32 and Nucleo-F401RE, enabling JSON-over-serial communication for GPIO control.
* Added `zeroclaw-esp32` with support for commands like `gpio_read` and `gpio_write`, along with capabilities reporting.
* Implemented `zeroclaw-nucleo` firmware with similar functionality for STM32, ensuring compatibility with existing ZeroClaw protocols.
* Updated `.gitignore` to include new firmware targets and added necessary dependencies in `Cargo.toml` for both platforms.
* Created README files for both firmware projects detailing setup, build, and usage instructions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat: enhance hardware peripheral support and documentation

- Added `Peripheral` trait implementation in `src/peripherals/` to manage hardware boards (STM32, RPi GPIO).
- Updated `AGENTS.md` to include new extension points for peripherals and their configuration.
- Introduced comprehensive documentation for adding boards and tools, including a quick start guide and supported boards.
- Enhanced `Cargo.toml` to include optional dependencies for PDF extraction and peripheral support.
- Created new datasheets for Arduino Uno, ESP32, and Nucleo-F401RE, detailing pin aliases and GPIO usage.
- Implemented new tools for hardware memory reading and board information retrieval in the agent loop.

This update significantly improves the integration and usability of hardware peripherals within the ZeroClaw framework.

* feat: add ZeroClaw firmware for ESP32 and Nucleo

* Introduced new firmware for ZeroClaw on ESP32 and Nucleo-F401RE, enabling JSON-over-serial communication for GPIO control.
* Added `zeroclaw-esp32` with support for commands like `gpio_read` and `gpio_write`, along with capabilities reporting.
* Implemented `zeroclaw-nucleo` firmware with similar functionality for STM32, ensuring compatibility with existing ZeroClaw protocols.
* Updated `.gitignore` to include new firmware targets and added necessary dependencies in `Cargo.toml` for both platforms.
* Created README files for both firmware projects detailing setup, build, and usage instructions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat: enhance hardware peripheral support and documentation

- Added `Peripheral` trait implementation in `src/peripherals/` to manage hardware boards (STM32, RPi GPIO).
- Updated `AGENTS.md` to include new extension points for peripherals and their configuration.
- Introduced comprehensive documentation for adding boards and tools, including a quick start guide and supported boards.
- Enhanced `Cargo.toml` to include optional dependencies for PDF extraction and peripheral support.
- Created new datasheets for Arduino Uno, ESP32, and Nucleo-F401RE, detailing pin aliases and GPIO usage.
- Implemented new tools for hardware memory reading and board information retrieval in the agent loop.

This update significantly improves the integration and usability of hardware peripherals within the ZeroClaw framework.

* feat: Introduce hardware auto-discovery and expanded configuration options for agents, hardware, and security.

* chore: update dependencies and improve probe-rs integration

- Updated `Cargo.lock` to remove specific version constraints for several dependencies, including `zerocopy`, `syn`, and `strsim`, allowing for more flexibility in version resolution.
- Upgraded `bincode` and `bitfield` to their latest versions, enhancing serialization and memory management capabilities.
- Updated `Cargo.toml` to reflect the new version of `probe-rs` from `0.24` to `0.30`, improving hardware probing functionality.
- Refactored code in `src/hardware` and `src/tools` to utilize the new `SessionConfig` for session management in `probe-rs`, ensuring better compatibility and performance.
- Cleaned up documentation in `docs/datasheets/nucleo-f401re.md` by removing unnecessary lines.

* fix: apply cargo fmt

* docs: add hardware architecture diagram.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ehu shubham shaw 2026-02-16 11:40:10 -05:00 committed by GitHub
parent b36f23784a
commit de3ec87d16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 9607 additions and 1885 deletions

View file

@ -0,0 +1,144 @@
//! Flash ZeroClaw Arduino firmware via arduino-cli.
//!
//! Ensures arduino-cli is available (installs via brew on macOS if missing),
//! installs the AVR core, compiles and uploads the base firmware.
use anyhow::{Context, Result};
use std::process::Command;
/// ZeroClaw Arduino Uno base firmware (capabilities, gpio_read, gpio_write).
const FIRMWARE_INO: &str = include_str!("../../firmware/zeroclaw-arduino/zeroclaw-arduino.ino");
const FQBN: &str = "arduino:avr:uno";
const SKETCH_NAME: &str = "zeroclaw-arduino";
/// Check if arduino-cli is available.
pub fn arduino_cli_available() -> bool {
Command::new("arduino-cli")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Try to install arduino-cli. Returns Ok(()) if installed or already present.
pub fn ensure_arduino_cli() -> Result<()> {
if arduino_cli_available() {
return Ok(());
}
#[cfg(target_os = "macos")]
{
println!("arduino-cli not found. Installing via Homebrew...");
let status = Command::new("brew")
.args(["install", "arduino-cli"])
.status()
.context("Failed to run brew install")?;
if !status.success() {
anyhow::bail!("brew install arduino-cli failed. Install manually: https://arduino.github.io/arduino-cli/");
}
println!("arduino-cli installed.");
}
#[cfg(target_os = "linux")]
{
println!("arduino-cli not found. Run the install script:");
println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh");
println!();
println!("Or install via package manager (e.g. apt install arduino-cli on Debian/Ubuntu).");
anyhow::bail!("arduino-cli not installed. Install it and try again.");
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
println!("arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/");
anyhow::bail!("arduino-cli not installed.");
}
if !arduino_cli_available() {
anyhow::bail!("arduino-cli still not found after install. Ensure it's in PATH.");
}
Ok(())
}
/// Ensure arduino:avr core is installed.
fn ensure_avr_core() -> Result<()> {
let out = Command::new("arduino-cli")
.args(["core", "list"])
.output()
.context("arduino-cli core list failed")?;
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("arduino:avr") {
return Ok(());
}
println!("Installing Arduino AVR core...");
let status = Command::new("arduino-cli")
.args(["core", "install", "arduino:avr"])
.status()
.context("arduino-cli core install failed")?;
if !status.success() {
anyhow::bail!("Failed to install arduino:avr core");
}
println!("AVR core installed.");
Ok(())
}
/// Flash ZeroClaw firmware to Arduino at the given port.
pub fn flash_arduino_firmware(port: &str) -> Result<()> {
ensure_arduino_cli()?;
ensure_avr_core()?;
let temp_dir = std::env::temp_dir().join(format!("zeroclaw_flash_{}", uuid::Uuid::new_v4()));
let sketch_dir = temp_dir.join(SKETCH_NAME);
let ino_path = sketch_dir.join(format!("{}.ino", SKETCH_NAME));
std::fs::create_dir_all(&sketch_dir).context("Failed to create sketch dir")?;
std::fs::write(&ino_path, FIRMWARE_INO).context("Failed to write firmware")?;
let sketch_path = sketch_dir.to_string_lossy();
// Compile
println!("Compiling ZeroClaw Arduino firmware...");
let compile = Command::new("arduino-cli")
.args(["compile", "--fqbn", FQBN, &*sketch_path])
.output()
.context("arduino-cli compile failed")?;
if !compile.status.success() {
let stderr = String::from_utf8_lossy(&compile.stderr);
let _ = std::fs::remove_dir_all(&temp_dir);
anyhow::bail!("Compile failed:\n{}", stderr);
}
// Upload
println!("Uploading to {}...", port);
let upload = Command::new("arduino-cli")
.args(["upload", "-p", port, "--fqbn", FQBN, &*sketch_path])
.output()
.context("arduino-cli upload failed")?;
let _ = std::fs::remove_dir_all(&temp_dir);
if !upload.status.success() {
let stderr = String::from_utf8_lossy(&upload.stderr);
anyhow::bail!("Upload failed:\n{}\n\nEnsure the board is connected and the port is correct (e.g. /dev/cu.usbmodem* on macOS).", stderr);
}
println!("ZeroClaw firmware flashed successfully.");
println!("The Arduino now supports: capabilities, gpio_read, gpio_write.");
Ok(())
}
/// Resolve port from config or path. Returns the path to use for flashing.
pub fn resolve_port(config: &crate::config::Config, path_override: Option<&str>) -> Option<String> {
if let Some(p) = path_override {
return Some(p.to_string());
}
config
.peripherals
.boards
.iter()
.find(|b| b.board == "arduino-uno" && b.transport == "serial")
.and_then(|b| b.path.clone())
}

View file

@ -0,0 +1,161 @@
//! Arduino upload tool — agent generates code, uploads via arduino-cli.
//!
//! When user says "make a heart on the LED grid", the agent generates Arduino
//! sketch code and calls this tool. ZeroClaw compiles and uploads it — no
//! manual IDE or file editing.
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::process::Command;
/// Tool: upload Arduino sketch (agent-generated code) to the board.
pub struct ArduinoUploadTool {
/// Serial port path (e.g. /dev/cu.usbmodem33000283452)
pub port: String,
}
impl ArduinoUploadTool {
pub fn new(port: String) -> Self {
Self { port }
}
}
#[async_trait]
impl Tool for ArduinoUploadTool {
fn name(&self) -> &str {
"arduino_upload"
}
fn description(&self) -> &str {
"Generate Arduino sketch code and upload it to the connected Arduino. Use when: user asks to 'make a heart', 'blink LED', or run any custom pattern on Arduino. You MUST write the full .ino sketch code (setup + loop). Arduino Uno: pin 13 = built-in LED. Saves to temp dir, runs arduino-cli compile and upload. Requires arduino-cli installed."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Full Arduino sketch code (complete .ino file content)"
}
},
"required": ["code"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let code = args
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?;
if code.trim().is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Code cannot be empty".into()),
});
}
// Check arduino-cli exists
if Command::new("arduino-cli").arg("version").output().is_err() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/"
.into(),
),
});
}
let sketch_name = "zeroclaw_sketch";
let temp_dir = std::env::temp_dir().join(format!("zeroclaw_{}", uuid::Uuid::new_v4()));
let sketch_dir = temp_dir.join(sketch_name);
let ino_path = sketch_dir.join(format!("{}.ino", sketch_name));
if let Err(e) = std::fs::create_dir_all(&sketch_dir) {
return Ok(ToolResult {
success: false,
output: format!("Failed to create sketch dir: {}", e),
error: Some(e.to_string()),
});
}
if let Err(e) = std::fs::write(&ino_path, code) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("Failed to write sketch: {}", e),
error: Some(e.to_string()),
});
}
let sketch_path = sketch_dir.to_string_lossy();
let fqbn = "arduino:avr:uno";
// Compile
let compile = Command::new("arduino-cli")
.args(["compile", "--fqbn", fqbn, &sketch_path])
.output();
let compile_output = match compile {
Ok(o) => o,
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("arduino-cli compile failed: {}", e),
error: Some(e.to_string()),
});
}
};
if !compile_output.status.success() {
let stderr = String::from_utf8_lossy(&compile_output.stderr);
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("Compile failed:\n{}", stderr),
error: Some("Arduino compile error".into()),
});
}
// Upload
let upload = Command::new("arduino-cli")
.args(["upload", "-p", &self.port, "--fqbn", fqbn, &sketch_path])
.output();
let upload_output = match upload {
Ok(o) => o,
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("arduino-cli upload failed: {}", e),
error: Some(e.to_string()),
});
}
};
let _ = std::fs::remove_dir_all(&temp_dir);
if !upload_output.status.success() {
let stderr = String::from_utf8_lossy(&upload_output.stderr);
return Ok(ToolResult {
success: false,
output: format!("Upload failed:\n{}", stderr),
error: Some("Arduino upload error".into()),
});
}
Ok(ToolResult {
success: true,
output:
"Sketch compiled and uploaded successfully. The Arduino is now running your code."
.into(),
error: None,
})
}
}

View file

@ -0,0 +1,99 @@
//! Hardware capabilities tool — Phase C: query device for reported GPIO pins.
use super::serial::SerialTransport;
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Tool: query device capabilities (GPIO pins, LED pin) from firmware.
pub struct HardwareCapabilitiesTool {
/// (board_name, transport) for each serial board.
boards: Vec<(String, Arc<SerialTransport>)>,
}
impl HardwareCapabilitiesTool {
pub(crate) fn new(boards: Vec<(String, Arc<SerialTransport>)>) -> Self {
Self { boards }
}
}
#[async_trait]
impl Tool for HardwareCapabilitiesTool {
fn name(&self) -> &str {
"hardware_capabilities"
}
fn description(&self) -> &str {
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"board": {
"type": "string",
"description": "Optional board name. If omitted, queries all."
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let filter = args.get("board").and_then(|v| v.as_str());
let mut outputs = Vec::new();
for (board_name, transport) in &self.boards {
if let Some(b) = filter {
if b != board_name {
continue;
}
}
match transport.capabilities().await {
Ok(result) => {
let output = if result.success {
if let Ok(parsed) =
serde_json::from_str::<serde_json::Value>(&result.output)
{
format!(
"{}: gpio {:?}, led_pin {:?}",
board_name,
parsed.get("gpio").unwrap_or(&json!([])),
parsed.get("led_pin").unwrap_or(&json!(null))
)
} else {
format!("{}: {}", board_name, result.output)
}
} else {
format!(
"{}: {}",
board_name,
result.error.as_deref().unwrap_or("unknown")
)
};
outputs.push(output);
}
Err(e) => {
outputs.push(format!("{}: error - {}", board_name, e));
}
}
}
let output = if outputs.is_empty() {
if filter.is_some() {
"No matching board or capabilities not supported.".to_string()
} else {
"No serial boards configured or capabilities not supported.".to_string()
}
} else {
outputs.join("\n")
};
Ok(ToolResult {
success: !outputs.is_empty(),
output,
error: None,
})
}
}

231
src/peripherals/mod.rs Normal file
View file

@ -0,0 +1,231 @@
//! Hardware peripherals — STM32, RPi GPIO, etc.
//!
//! Peripherals extend the agent with physical capabilities. See
//! `docs/hardware-peripherals-design.md` for the full design.
pub mod traits;
#[cfg(feature = "hardware")]
pub mod serial;
#[cfg(feature = "hardware")]
pub mod arduino_flash;
#[cfg(feature = "hardware")]
pub mod arduino_upload;
#[cfg(feature = "hardware")]
pub mod capabilities_tool;
#[cfg(feature = "hardware")]
pub mod nucleo_flash;
#[cfg(feature = "hardware")]
pub mod uno_q_bridge;
#[cfg(feature = "hardware")]
pub mod uno_q_setup;
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
pub mod rpi;
pub use traits::Peripheral;
use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig};
use crate::tools::{HardwareMemoryMapTool, Tool};
use anyhow::Result;
/// List configured boards from config (no connection yet).
pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> {
if !config.enabled {
return Vec::new();
}
config.boards.iter().collect()
}
/// Handle `zeroclaw peripheral` subcommands.
#[allow(clippy::module_name_repetitions)]
pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> {
match cmd {
crate::PeripheralCommands::List => {
let boards = list_configured_boards(&config.peripherals);
if boards.is_empty() {
println!("No peripherals configured.");
println!();
println!("Add one with: zeroclaw peripheral add <board> <path>");
println!(" Example: zeroclaw peripheral add nucleo-f401re /dev/ttyACM0");
println!();
println!("Or add to config.toml:");
println!(" [peripherals]");
println!(" enabled = true");
println!();
println!(" [[peripherals.boards]]");
println!(" board = \"nucleo-f401re\"");
println!(" transport = \"serial\"");
println!(" path = \"/dev/ttyACM0\"");
} else {
println!("Configured peripherals:");
for b in boards {
let path = b.path.as_deref().unwrap_or("(native)");
println!(" {} {} {}", b.board, b.transport, path);
}
}
}
crate::PeripheralCommands::Add { board, path } => {
let transport = if path == "native" { "native" } else { "serial" };
let path_opt = if path == "native" {
None
} else {
Some(path.clone())
};
let mut cfg = crate::config::Config::load_or_init()?;
cfg.peripherals.enabled = true;
if cfg
.peripherals
.boards
.iter()
.any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref())
{
println!("Board {} at {:?} already configured.", board, path_opt);
return Ok(());
}
cfg.peripherals.boards.push(PeripheralBoardConfig {
board: board.clone(),
transport: transport.to_string(),
path: path_opt,
baud: 115200,
});
cfg.save()?;
println!("Added {} at {}. Restart daemon to apply.", board, path);
}
#[cfg(feature = "hardware")]
crate::PeripheralCommands::Flash { port } => {
let port_str = arduino_flash::resolve_port(config, port.as_deref())
.or_else(|| port.clone())
.ok_or_else(|| anyhow::anyhow!(
"No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml"
))?;
arduino_flash::flash_arduino_firmware(&port_str)?;
}
#[cfg(not(feature = "hardware"))]
crate::PeripheralCommands::Flash { .. } => {
println!("Arduino flash requires the 'hardware' feature.");
println!("Build with: cargo build --features hardware");
}
#[cfg(feature = "hardware")]
crate::PeripheralCommands::SetupUnoQ { host } => {
uno_q_setup::setup_uno_q_bridge(host.as_deref())?;
}
#[cfg(not(feature = "hardware"))]
crate::PeripheralCommands::SetupUnoQ { .. } => {
println!("Uno Q setup requires the 'hardware' feature.");
println!("Build with: cargo build --features hardware");
}
#[cfg(feature = "hardware")]
crate::PeripheralCommands::FlashNucleo => {
nucleo_flash::flash_nucleo_firmware()?;
}
#[cfg(not(feature = "hardware"))]
crate::PeripheralCommands::FlashNucleo => {
println!("Nucleo flash requires the 'hardware' feature.");
println!("Build with: cargo build --features hardware");
}
}
Ok(())
}
/// Create and connect peripherals from config, returning their tools.
/// Returns empty vec if peripherals disabled or hardware feature off.
#[cfg(feature = "hardware")]
pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
if !config.enabled || config.boards.is_empty() {
return Ok(Vec::new());
}
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let mut serial_transports: Vec<(String, std::sync::Arc<serial::SerialTransport>)> = Vec::new();
for board in &config.boards {
// Arduino Uno Q: Bridge transport (socket to local Bridge app)
if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q")
{
tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool));
tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool));
tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added");
continue;
}
// Native transport: RPi GPIO (Linux only)
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
if board.transport == "native"
&& (board.board == "rpi-gpio" || board.board == "raspberry-pi")
{
match rpi::RpiGpioPeripheral::connect_from_config(board).await {
Ok(peripheral) => {
tools.extend(peripheral.tools());
tracing::info!(board = %board.board, "RPi GPIO peripheral connected");
}
Err(e) => {
tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e);
}
}
continue;
}
// Serial transport (STM32, ESP32, Arduino, etc.)
if board.transport != "serial" {
continue;
}
if board.path.is_none() {
tracing::warn!("Skipping serial board {}: no path", board.board);
continue;
}
match serial::SerialPeripheral::connect(board).await {
Ok(peripheral) => {
let mut p = peripheral;
if p.connect().await.is_err() {
tracing::warn!("Peripheral {} connect warning (continuing)", p.name());
}
serial_transports.push((board.board.clone(), p.transport()));
tools.extend(p.tools());
if board.board == "arduino-uno" {
if let Some(ref path) = board.path {
tools.push(Box::new(arduino_upload::ArduinoUploadTool::new(
path.clone(),
)));
tracing::info!("Arduino upload tool added (port: {})", path);
}
}
tracing::info!(board = %board.board, "Serial peripheral connected");
}
Err(e) => {
tracing::warn!("Failed to connect {}: {}", board.board, e);
}
}
}
// Phase B: Add hardware tools when any boards configured
if !tools.is_empty() {
let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone())));
tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new(
board_names.clone(),
)));
tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new(
board_names,
)));
}
// Phase C: Add hardware_capabilities tool when any serial boards
if !serial_transports.is_empty() {
tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new(
serial_transports,
)));
}
Ok(tools)
}
#[cfg(not(feature = "hardware"))]
pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
Ok(Vec::new())
}

View file

@ -0,0 +1,83 @@
//! Flash ZeroClaw Nucleo-F401RE firmware via probe-rs.
//!
//! Builds the Embassy firmware and flashes via ST-Link (built into Nucleo).
//! Requires: cargo install probe-rs-tools --locked
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
const CHIP: &str = "STM32F401RETx";
const TARGET: &str = "thumbv7em-none-eabihf";
/// Check if probe-rs CLI is available (from probe-rs-tools).
pub fn probe_rs_available() -> bool {
Command::new("probe-rs")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Flash ZeroClaw Nucleo firmware. Builds from firmware/zeroclaw-nucleo.
pub fn flash_nucleo_firmware() -> Result<()> {
if !probe_rs_available() {
anyhow::bail!(
"probe-rs not found. Install it:\n cargo install probe-rs-tools --locked\n\n\
Or: curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh\n\n\
Connect Nucleo via USB (ST-Link). Then run this command again."
);
}
// CARGO_MANIFEST_DIR = repo root (zeroclaw's Cargo.toml)
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let firmware_dir = repo_root.join("firmware").join("zeroclaw-nucleo");
if !firmware_dir.join("Cargo.toml").exists() {
anyhow::bail!(
"Nucleo firmware not found at {}. Run from zeroclaw repo root.",
firmware_dir.display()
);
}
println!("Building ZeroClaw Nucleo firmware...");
let build = Command::new("cargo")
.args(["build", "--release", "--target", TARGET])
.current_dir(&firmware_dir)
.output()
.context("cargo build failed")?;
if !build.status.success() {
let stderr = String::from_utf8_lossy(&build.stderr);
anyhow::bail!("Build failed:\n{}", stderr);
}
let elf_path = firmware_dir
.join("target")
.join(TARGET)
.join("release")
.join("zeroclaw-nucleo");
if !elf_path.exists() {
anyhow::bail!("Built binary not found at {}", elf_path.display());
}
println!("Flashing to Nucleo-F401RE (connect via USB)...");
let flash = Command::new("probe-rs")
.args(["run", "--chip", CHIP, elf_path.to_str().unwrap()])
.output()
.context("probe-rs run failed")?;
if !flash.status.success() {
let stderr = String::from_utf8_lossy(&flash.stderr);
anyhow::bail!(
"Flash failed:\n{}\n\n\
Ensure Nucleo is connected via USB. The ST-Link is built into the board.",
stderr
);
}
println!("ZeroClaw Nucleo firmware flashed successfully.");
println!("The Nucleo now supports: ping, capabilities, gpio_read, gpio_write.");
println!("Add to config.toml: board = \"nucleo-f401re\", transport = \"serial\", path = \"/dev/ttyACM0\"");
Ok(())
}

173
src/peripherals/rpi.rs Normal file
View file

@ -0,0 +1,173 @@
//! Raspberry Pi GPIO peripheral — native rppal access.
//!
//! Only compiled when `peripheral-rpi` feature is enabled and target is Linux.
//! Uses BCM pin numbering (e.g. GPIO 17, 27).
use crate::config::PeripheralBoardConfig;
use crate::peripherals::traits::Peripheral;
use crate::tools::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
/// RPi GPIO peripheral — direct access via rppal.
pub struct RpiGpioPeripheral {
board: PeripheralBoardConfig,
}
impl RpiGpioPeripheral {
/// Create a new RPi GPIO peripheral from config.
pub fn new(board: PeripheralBoardConfig) -> Self {
Self { board }
}
/// Attempt to connect (init rppal). Returns Ok if GPIO is available.
pub async fn connect_from_config(board: &PeripheralBoardConfig) -> anyhow::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) -> anyhow::Result<()> {
// Verify GPIO is accessible by doing a no-op init
let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??;
drop(result);
Ok(())
}
async fn disconnect(&mut self) -> anyhow::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)]
}
}
/// Tool: read GPIO pin value (BCM numbering).
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) -> anyhow::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,
})
}
}
/// Tool: write GPIO pin value (BCM numbering).
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) -> anyhow::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,
})
}
}

274
src/peripherals/serial.rs Normal file
View file

@ -0,0 +1,274 @@
//! Serial peripheral — STM32 and similar boards over USB CDC/serial.
//!
//! Protocol: newline-delimited JSON.
//! Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
//! Response: {"id":"1","ok":true,"result":"done"}
use super::traits::Peripheral;
use crate::config::PeripheralBoardConfig;
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use tokio_serial::{SerialPortBuilderExt, SerialStream};
/// Allowed serial path patterns (security: deny arbitrary paths).
const ALLOWED_PATH_PREFIXES: &[&str] = &[
"/dev/ttyACM",
"/dev/ttyUSB",
"/dev/tty.usbmodem",
"/dev/cu.usbmodem",
"/dev/tty.usbserial",
"/dev/cu.usbserial", // Arduino Uno (FTDI), clones
"COM", // Windows
];
fn is_path_allowed(path: &str) -> bool {
ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p))
}
/// JSON request/response over serial.
async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow::Result<Value> {
static ID: AtomicU64 = AtomicU64::new(0);
let id = ID.fetch_add(1, Ordering::Relaxed);
let id_str = id.to_string();
let req = json!({
"id": id_str,
"cmd": cmd,
"args": args
});
let line = format!("{}\n", req);
port.write_all(line.as_bytes()).await?;
port.flush().await?;
let mut buf = Vec::new();
let mut b = [0u8; 1];
while port.read_exact(&mut b).await.is_ok() {
if b[0] == b'\n' {
break;
}
buf.push(b[0]);
}
let line_str = String::from_utf8_lossy(&buf);
let resp: Value = serde_json::from_str(line_str.trim())?;
let resp_id = resp["id"].as_str().unwrap_or("");
if resp_id != id_str {
anyhow::bail!("Response id mismatch: expected {}, got {}", id_str, resp_id);
}
Ok(resp)
}
/// Shared serial transport for tools. Pub(crate) for capabilities tool.
pub(crate) struct SerialTransport {
port: Mutex<SerialStream>,
}
/// Timeout for serial request/response (seconds).
const SERIAL_TIMEOUT_SECS: u64 = 5;
impl SerialTransport {
async fn request(&self, cmd: &str, args: Value) -> anyhow::Result<ToolResult> {
let mut port = self.port.lock().await;
let resp = tokio::time::timeout(
std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS),
send_request(&mut *port, cmd, args),
)
.await
.map_err(|_| {
anyhow::anyhow!("Serial request timed out after {}s", SERIAL_TIMEOUT_SECS)
})??;
let ok = resp["ok"].as_bool().unwrap_or(false);
let result = resp["result"]
.as_str()
.map(String::from)
.unwrap_or_else(|| resp["result"].to_string());
let error = resp["error"].as_str().map(String::from);
Ok(ToolResult {
success: ok,
output: result,
error,
})
}
/// Phase C: fetch capabilities from device (gpio pins, led_pin).
pub async fn capabilities(&self) -> anyhow::Result<ToolResult> {
self.request("capabilities", json!({})).await
}
}
/// Serial peripheral for STM32, Arduino, etc. over USB CDC.
pub struct SerialPeripheral {
name: String,
board_type: String,
transport: Arc<SerialTransport>,
}
impl SerialPeripheral {
/// Create and connect to a serial peripheral.
pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result<Self> {
let path = config
.path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Serial peripheral requires path"))?;
if !is_path_allowed(path) {
anyhow::bail!(
"Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*",
path
);
}
let port = tokio_serial::new(path, config.baud)
.open_native_async()
.map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path, e))?;
let name = format!("{}-{}", config.board, path.replace('/', "_"));
let transport = Arc::new(SerialTransport {
port: Mutex::new(port),
});
Ok(Self {
name: name.clone(),
board_type: config.board.clone(),
transport,
})
}
}
#[async_trait]
impl Peripheral for SerialPeripheral {
fn name(&self) -> &str {
&self.name
}
fn board_type(&self) -> &str {
&self.board_type
}
async fn connect(&mut self) -> anyhow::Result<()> {
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
Ok(())
}
async fn health_check(&self) -> bool {
self.transport
.request("ping", json!({}))
.await
.map(|r| r.success)
.unwrap_or(false)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(GpioReadTool {
transport: self.transport.clone(),
}),
Box::new(GpioWriteTool {
transport: self.transport.clone(),
}),
]
}
}
impl SerialPeripheral {
/// Expose transport for capabilities tool (Phase C).
pub(crate) fn transport(&self) -> Arc<SerialTransport> {
self.transport.clone()
}
}
/// Tool: read GPIO pin value.
struct GpioReadTool {
transport: Arc<SerialTransport>,
}
#[async_trait]
impl Tool for GpioReadTool {
fn name(&self) -> &str {
"gpio_read"
}
fn description(&self) -> &str {
"Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number (e.g. 13 for LED on Nucleo)"
}
},
"required": ["pin"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
self.transport
.request("gpio_read", json!({ "pin": pin }))
.await
}
}
/// Tool: write GPIO pin value.
struct GpioWriteTool {
transport: Arc<SerialTransport>,
}
#[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 peripheral (e.g. turn on/off LED)"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number"
},
"value": {
"type": "integer",
"description": "0 for low, 1 for high"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: Value) -> anyhow::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"))?;
self.transport
.request("gpio_write", json!({ "pin": pin, "value": value }))
.await
}
}

33
src/peripherals/traits.rs Normal file
View file

@ -0,0 +1,33 @@
//! Peripheral trait — hardware boards (STM32, RPi GPIO) that expose tools.
//!
//! Peripherals are the agent's "arms and legs": remote devices that run minimal
//! firmware and expose capabilities (GPIO, sensors, actuators) as tools.
use async_trait::async_trait;
use crate::tools::Tool;
/// A hardware peripheral that exposes capabilities as tools.
///
/// Implement this for boards like Nucleo-F401RE (serial), RPi GPIO (native), etc.
/// When connected, the peripheral's tools are merged into the agent's tool registry.
#[async_trait]
pub trait Peripheral: Send + Sync {
/// Human-readable peripheral name (e.g. "nucleo-f401re-0")
fn name(&self) -> &str;
/// Board type identifier (e.g. "nucleo-f401re", "rpi-gpio")
fn board_type(&self) -> &str;
/// Connect to the peripheral (open serial, init GPIO, etc.)
async fn connect(&mut self) -> anyhow::Result<()>;
/// Disconnect and release resources
async fn disconnect(&mut self) -> anyhow::Result<()>;
/// Check if the peripheral is reachable and responsive
async fn health_check(&self) -> bool;
/// Tools this peripheral provides (e.g. gpio_read, gpio_write, sensor_read)
fn tools(&self) -> Vec<Box<dyn Tool>>;
}

View file

@ -0,0 +1,151 @@
//! Arduino Uno Q Bridge — GPIO via socket to Bridge app.
//!
//! When ZeroClaw runs on Uno Q, the Bridge app (Python + MCU) exposes
//! digitalWrite/digitalRead over a local socket. These tools connect to it.
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const BRIDGE_HOST: &str = "127.0.0.1";
const BRIDGE_PORT: u16 = 9999;
async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result<String> {
let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT);
let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr))
.await
.map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??;
let msg = format!("{} {}\n", cmd, args.join(" "));
stream.write_all(msg.as_bytes()).await?;
let mut buf = vec![0u8; 64];
let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf))
.await
.map_err(|_| anyhow::anyhow!("Bridge response timed out"))??;
let resp = String::from_utf8_lossy(&buf[..n]).trim().to_string();
Ok(resp)
}
/// Tool: read GPIO pin via Uno Q Bridge.
pub struct UnoQGpioReadTool;
#[async_trait]
impl Tool for UnoQGpioReadTool {
fn name(&self) -> &str {
"gpio_read"
}
fn description(&self) -> &str {
"Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number (e.g. 13 for LED)"
}
},
"required": ["pin"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
match bridge_request("gpio_read", &[pin.to_string()]).await {
Ok(resp) => {
if resp.starts_with("error:") {
Ok(ToolResult {
success: false,
output: resp.clone(),
error: Some(resp),
})
} else {
Ok(ToolResult {
success: true,
output: resp,
error: None,
})
}
}
Err(e) => Ok(ToolResult {
success: false,
output: format!("Bridge error: {}", e),
error: Some(e.to_string()),
}),
}
}
}
/// Tool: write GPIO pin via Uno Q Bridge.
pub struct UnoQGpioWriteTool;
#[async_trait]
impl Tool for UnoQGpioWriteTool {
fn name(&self) -> &str {
"gpio_write"
}
fn description(&self) -> &str {
"Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number"
},
"value": {
"type": "integer",
"description": "0 for low, 1 for high"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: Value) -> anyhow::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"))?;
match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await {
Ok(resp) => {
if resp.starts_with("error:") {
Ok(ToolResult {
success: false,
output: resp.clone(),
error: Some(resp),
})
} else {
Ok(ToolResult {
success: true,
output: "done".into(),
error: None,
})
}
}
Err(e) => Ok(ToolResult {
success: false,
output: format!("Bridge error: {}", e),
error: Some(e.to_string()),
}),
}
}
}

View file

@ -0,0 +1,143 @@
//! Deploy ZeroClaw Bridge app to Arduino Uno Q.
use anyhow::{Context, Result};
use std::process::Command;
const BRIDGE_APP_NAME: &str = "zeroclaw-uno-q-bridge";
/// Deploy the Bridge app. If host is Some, scp from repo and ssh to start.
/// If host is None, assume we're ON the Uno Q — use embedded files and start.
pub fn setup_uno_q_bridge(host: Option<&str>) -> Result<()> {
let bridge_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("firmware")
.join("zeroclaw-uno-q-bridge");
if let Some(h) = host {
if bridge_dir.exists() {
deploy_remote(h, &bridge_dir)?;
} else {
anyhow::bail!(
"Bridge app not found at {}. Run from zeroclaw repo root.",
bridge_dir.display()
);
}
} else {
deploy_local(if bridge_dir.exists() {
Some(&bridge_dir)
} else {
None
})?;
}
Ok(())
}
fn deploy_remote(host: &str, bridge_dir: &std::path::Path) -> Result<()> {
let ssh_target = if host.contains('@') {
host.to_string()
} else {
format!("arduino@{}", host)
};
println!("Copying Bridge app to {}...", host);
let status = Command::new("ssh")
.args([&ssh_target, "mkdir", "-p", "~/ArduinoApps"])
.status()
.context("ssh mkdir failed")?;
if !status.success() {
anyhow::bail!("Failed to create ArduinoApps dir on Uno Q");
}
let status = Command::new("scp")
.args([
"-r",
bridge_dir.to_str().unwrap(),
&format!("{}:~/ArduinoApps/", ssh_target),
])
.status()
.context("scp failed")?;
if !status.success() {
anyhow::bail!("Failed to copy Bridge app");
}
println!("Starting Bridge app on Uno Q...");
let status = Command::new("ssh")
.args([
&ssh_target,
"arduino-app-cli",
"app",
"start",
&format!("~/ArduinoApps/zeroclaw-uno-q-bridge"),
])
.status()
.context("arduino-app-cli start failed")?;
if !status.success() {
anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q.");
}
println!("ZeroClaw Bridge app started. Add to config.toml:");
println!(" [[peripherals.boards]]");
println!(" board = \"arduino-uno-q\"");
println!(" transport = \"bridge\"");
Ok(())
}
fn deploy_local(bridge_dir: Option<&std::path::Path>) -> Result<()> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/arduino".into());
let apps_dir = std::path::Path::new(&home).join("ArduinoApps");
let dest_dir = apps_dir.join(BRIDGE_APP_NAME);
std::fs::create_dir_all(&dest_dir).context("create dest dir")?;
if let Some(src) = bridge_dir {
println!("Copying Bridge app from repo...");
copy_dir(src, &dest_dir)?;
} else {
println!("Writing embedded Bridge app...");
write_embedded_bridge(&dest_dir)?;
}
println!("Starting Bridge app...");
let status = Command::new("arduino-app-cli")
.args(["app", "start", dest_dir.to_str().unwrap()])
.status()
.context("arduino-app-cli start failed")?;
if !status.success() {
anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q.");
}
println!("ZeroClaw Bridge app started.");
Ok(())
}
fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> {
let app_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/app.yaml");
let sketch_ino = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino");
let sketch_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml");
let main_py = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/main.py");
let requirements = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/requirements.txt");
std::fs::write(dest.join("app.yaml"), app_yaml)?;
std::fs::create_dir_all(dest.join("sketch"))?;
std::fs::write(dest.join("sketch").join("sketch.ino"), sketch_ino)?;
std::fs::write(dest.join("sketch").join("sketch.yaml"), sketch_yaml)?;
std::fs::create_dir_all(dest.join("python"))?;
std::fs::write(dest.join("python").join("main.py"), main_py)?;
std::fs::write(dest.join("python").join("requirements.txt"), requirements)?;
Ok(())
}
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> {
for entry in std::fs::read_dir(src)? {
let e = entry?;
let name = e.file_name();
let src_path = src.join(&name);
let dst_path = dst.join(&name);
if e.file_type()?.is_dir() {
std::fs::create_dir_all(&dst_path)?;
copy_dir(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}