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:
parent
b36f23784a
commit
de3ec87d16
59 changed files with 9607 additions and 1885 deletions
144
src/peripherals/arduino_flash.rs
Normal file
144
src/peripherals/arduino_flash.rs
Normal 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())
|
||||
}
|
||||
161
src/peripherals/arduino_upload.rs
Normal file
161
src/peripherals/arduino_upload.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
99
src/peripherals/capabilities_tool.rs
Normal file
99
src/peripherals/capabilities_tool.rs
Normal 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
231
src/peripherals/mod.rs
Normal 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())
|
||||
}
|
||||
83
src/peripherals/nucleo_flash.rs
Normal file
83
src/peripherals/nucleo_flash.rs
Normal 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
173
src/peripherals/rpi.rs
Normal 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
274
src/peripherals/serial.rs
Normal 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
33
src/peripherals/traits.rs
Normal 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>>;
|
||||
}
|
||||
151
src/peripherals/uno_q_bridge.rs
Normal file
151
src/peripherals/uno_q_bridge.rs
Normal 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()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/peripherals/uno_q_setup.rs
Normal file
143
src/peripherals/uno_q_setup.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue