* feat(hardware): add RPi GPIO, Aardvark I2C/SPI/GPIO, and hardware plugin system
Extends the hardware subsystem with three clusters of functionality,
all feature-gated (hardware / peripheral-rpi) with no impact on default builds.
Raspberry Pi native support:
- src/hardware/rpi.rs: board self-discovery (model, serial, revision),
sysfs GPIO pin read/write, and ACT LED control
- scripts/99-act-led.rules: udev rule for non-root ACT LED access
- scripts/deploy-rpi.sh, scripts/rpi-config.toml, scripts/zeroclaw.service:
one-shot deployment helper and systemd service template
Total Phase Aardvark USB adapter (I2C / SPI / GPIO):
- crates/aardvark-sys/: new workspace crate with FFI bindings loaded at
runtime via libloading; graceful stub fallback when .so is absent or
arch mismatches (Rosetta 2 detection)
- src/hardware/aardvark.rs: AardvarkTransport implementing Transport trait
- src/hardware/aardvark_tools.rs: agent tools i2c_scan, i2c_read,
i2c_write, spi_transfer, gpio_aardvark
- src/hardware/datasheet.rs: datasheet search/download for detected devices
- docs/aardvark-integration.md, examples/hardware/aardvark/: guide + examples
Hardware plugin / ToolRegistry system:
- src/hardware/tool_registry.rs: ToolRegistry for hardware module tool sets
- src/hardware/loader.rs, src/hardware/manifest.rs: manifest-driven loader
- src/hardware/subprocess.rs: subprocess execution helper for board I/O
- src/gateway/hardware_context.rs: POST /api/hardware/reload endpoint
- src/hardware/mod.rs: exports all new modules; merge_hardware_tools and
load_hardware_context_prompt helpers
Integration hooks (minimal surface):
- src/hardware/device.rs: DeviceKind::Aardvark, DeviceRuntime::Aardvark,
has_aardvark / resolve_aardvark_device on DeviceRegistry
- src/hardware/transport.rs: TransportKind::Aardvark
- src/peripherals/mod.rs: gate create_board_info_tools behind hardware feature
- src/agent/loop_.rs: TOOL_CHOICE_OVERRIDE task-local for Anthropic provider
- src/providers/anthropic.rs: read TOOL_CHOICE_OVERRIDE; add tool_choice field
- Cargo.toml: add aardvark-sys to workspace and as dependency
- firmware/zeroclaw-nucleo/: update Cargo.toml and Cargo.lock
Non-goals:
- No changes to agent orchestration, channels, providers, or security policy
- No new config keys outside existing [hardware] / [peripherals] sections
- No CI workflow changes
Risk: Low. All new paths are feature-gated; aardvark.so loads at runtime
only when present. No schema migrations or persistent state introduced.
Rollback: revert this single commit.
* fix(hardware): resolve clippy and rustfmt CI failures
- struct_excessive_bools: allow on DeviceCapabilities (7 bool fields needed)
- unnecessary_debug_formatting: use .display() instead of {:?} for paths
- stable_sort_primitive: replace .sort() with .sort_unstable() on &str slices
* fix(hardware): add missing serial/uf2/pico modules declared in mod.rs
cargo fmt was exiting with code 1 because mod.rs declared pub mod serial,
uf2, pico_flash, pico_code but those files were missing from the branch.
Also apply auto-formatting to loader.rs.
* fix(hardware): apply rustfmt 1.92.0 formatting (matches CI toolchain)
* docs(scripts): add RPi deployment and interaction guide
* push
* feat(firmware): add initial Pico firmware and serial device handling
- Introduced main.py for ZeroClaw Pico firmware with a placeholder for MicroPython implementation.
- Added binary UF2 file for Pico deployment.
- Implemented serial device enumeration and validation in the hardware module, enhancing security by restricting allowed serial paths.
- Updated related modules to integrate new serial device functionality.
---------
Co-authored-by: ehushubhamshaw <eshaw1@wpi.edu>
328 lines
10 KiB
Rust
328 lines
10 KiB
Rust
//! Plugin manifest loader — scans `~/.zeroclaw/tools/` at startup.
|
|
//!
|
|
//! Layout expected on disk:
|
|
//! ```text
|
|
//! ~/.zeroclaw/tools/
|
|
//! ├── i2c_scan/
|
|
//! │ ├── tool.toml
|
|
//! │ └── i2c_scan.py
|
|
//! └── pwm_set/
|
|
//! ├── tool.toml
|
|
//! └── pwm_set
|
|
//! ```
|
|
//!
|
|
//! Rules:
|
|
//! - The directory is **created** if it does not exist.
|
|
//! - Each subdirectory is scanned for a `tool.toml`.
|
|
//! - Manifests that fail to parse or validate are **skipped with a warning**;
|
|
//! they must not crash startup.
|
|
//! - Non-directory entries at the top level are silently ignored.
|
|
|
|
use super::manifest::ToolManifest;
|
|
use super::subprocess::SubprocessTool;
|
|
use crate::tools::traits::Tool;
|
|
use anyhow::Result;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// A successfully loaded plugin, ready for registration.
|
|
pub struct LoadedPlugin {
|
|
/// Tool name from the manifest (unique key in [`ToolRegistry`]).
|
|
pub name: String,
|
|
/// Semantic version string from the manifest.
|
|
pub version: String,
|
|
/// The constructed tool, boxed for dynamic dispatch.
|
|
pub tool: Box<dyn Tool>,
|
|
}
|
|
|
|
/// Scan `~/.zeroclaw/tools/` and return all valid plugins.
|
|
///
|
|
/// - Creates the directory if absent.
|
|
/// - Skips broken manifests with a `tracing::warn!` — does not propagate errors.
|
|
/// - Returns an empty `Vec` when no plugins are installed.
|
|
pub fn scan_plugin_dir() -> Vec<LoadedPlugin> {
|
|
let tools_dir = match plugin_tools_dir() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
tracing::warn!("[registry] cannot resolve plugin tools dir: {}", e);
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
// Create the directory tree if it is missing.
|
|
if !tools_dir.exists() {
|
|
if let Err(e) = fs::create_dir_all(&tools_dir) {
|
|
tracing::warn!(
|
|
"[registry] could not create {:?}: {}",
|
|
tools_dir.display(),
|
|
e
|
|
);
|
|
return Vec::new();
|
|
}
|
|
tracing::info!(
|
|
"[registry] created plugin directory: {}",
|
|
tools_dir.display()
|
|
);
|
|
}
|
|
|
|
println!(
|
|
"[registry] scanning {}...",
|
|
match dirs_home().as_deref().filter(|s| !s.is_empty()) {
|
|
Some(home) => tools_dir
|
|
.to_str()
|
|
.unwrap_or("~/.zeroclaw/tools")
|
|
.replace(home, "~"),
|
|
None => tools_dir
|
|
.to_str()
|
|
.unwrap_or("~/.zeroclaw/tools")
|
|
.to_string(),
|
|
}
|
|
);
|
|
|
|
let mut plugins = Vec::new();
|
|
|
|
let entries = match fs::read_dir(&tools_dir) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
tracing::warn!("[registry] cannot read tools dir: {}", e);
|
|
return Vec::new();
|
|
}
|
|
};
|
|
|
|
for entry in entries {
|
|
let entry = match entry {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
tracing::warn!("[registry] skipping unreadable dir entry: {}", e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let plugin_dir = entry.path();
|
|
|
|
// Only descend into subdirectories.
|
|
if !plugin_dir.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let manifest_path = plugin_dir.join("tool.toml");
|
|
|
|
if !manifest_path.exists() {
|
|
tracing::debug!(
|
|
"[registry] no tool.toml in {:?} — skipping",
|
|
plugin_dir.file_name().unwrap_or_default()
|
|
);
|
|
continue;
|
|
}
|
|
|
|
match load_one_plugin(&plugin_dir, &manifest_path) {
|
|
Ok(plugin) => plugins.push(plugin),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
"[registry] skipping plugin in {:?}: {}",
|
|
plugin_dir.file_name().unwrap_or_default(),
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
plugins
|
|
}
|
|
|
|
/// Parse and validate a single plugin directory.
|
|
///
|
|
/// Returns `Err` on any validation failure so the caller can log and continue.
|
|
fn load_one_plugin(plugin_dir: &Path, manifest_path: &Path) -> Result<LoadedPlugin> {
|
|
let raw = fs::read_to_string(manifest_path)
|
|
.map_err(|e| anyhow::anyhow!("cannot read tool.toml: {}", e))?;
|
|
|
|
let manifest: ToolManifest = toml::from_str(&raw)
|
|
.map_err(|e| anyhow::anyhow!("TOML parse error in tool.toml: {}", e))?;
|
|
|
|
// Validate required fields — fail fast with a descriptive error.
|
|
if manifest.tool.name.trim().is_empty() {
|
|
anyhow::bail!("manifest missing [tool] name");
|
|
}
|
|
if manifest.tool.description.trim().is_empty() {
|
|
anyhow::bail!("manifest missing [tool] description");
|
|
}
|
|
if manifest.exec.binary.trim().is_empty() {
|
|
anyhow::bail!("manifest missing [exec] binary");
|
|
}
|
|
|
|
// Validate binary path: must exist, be a regular file, and reside within plugin_dir.
|
|
let canonical_plugin_dir = plugin_dir.canonicalize().map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"cannot canonicalize plugin dir {}: {}",
|
|
plugin_dir.display(),
|
|
e
|
|
)
|
|
})?;
|
|
let raw_binary_path = plugin_dir.join(&manifest.exec.binary);
|
|
if !raw_binary_path.exists() {
|
|
anyhow::bail!(
|
|
"manifest exec binary not found: {}",
|
|
raw_binary_path.display()
|
|
);
|
|
}
|
|
let binary_path = raw_binary_path.canonicalize().map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"cannot canonicalize binary path {}: {}",
|
|
raw_binary_path.display(),
|
|
e
|
|
)
|
|
})?;
|
|
if !binary_path.starts_with(&canonical_plugin_dir) {
|
|
anyhow::bail!(
|
|
"manifest exec binary escapes plugin directory: {} is not under {}",
|
|
binary_path.display(),
|
|
canonical_plugin_dir.display()
|
|
);
|
|
}
|
|
if !binary_path.is_file() {
|
|
anyhow::bail!(
|
|
"manifest exec binary is not a regular file: {}",
|
|
binary_path.display()
|
|
);
|
|
}
|
|
|
|
let name = manifest.tool.name.clone();
|
|
let version = manifest.tool.version.clone();
|
|
let tool: Box<dyn Tool> = Box::new(SubprocessTool::new(manifest, binary_path));
|
|
|
|
Ok(LoadedPlugin {
|
|
name,
|
|
version,
|
|
tool,
|
|
})
|
|
}
|
|
|
|
/// Return the path `~/.zeroclaw/tools/` using the `directories` crate.
|
|
pub fn plugin_tools_dir() -> Result<PathBuf> {
|
|
use directories::BaseDirs;
|
|
let base = BaseDirs::new()
|
|
.ok_or_else(|| anyhow::anyhow!("cannot determine the user home directory"))?;
|
|
Ok(base.home_dir().join(".zeroclaw").join("tools"))
|
|
}
|
|
|
|
/// Best-effort home dir string for display purposes only.
|
|
fn dirs_home() -> Option<String> {
|
|
use directories::BaseDirs;
|
|
BaseDirs::new().map(|b| b.home_dir().to_string_lossy().into_owned())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn write_valid_manifest(dir: &Path) {
|
|
let toml = r#"
|
|
[tool]
|
|
name = "test_plugin"
|
|
version = "1.0.0"
|
|
description = "A deterministic test plugin"
|
|
|
|
[exec]
|
|
binary = "tool.sh"
|
|
|
|
[[parameters]]
|
|
name = "device"
|
|
type = "string"
|
|
description = "Device alias"
|
|
required = true
|
|
"#;
|
|
fs::write(dir.join("tool.toml"), toml).unwrap();
|
|
// Write a dummy binary (content doesn't matter for manifest loading).
|
|
fs::write(
|
|
dir.join("tool.sh"),
|
|
"#!/bin/sh\necho '{\"success\":true,\"output\":\"ok\",\"error\":null}'\n",
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn load_one_plugin_succeeds_for_valid_manifest() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
write_valid_manifest(dir.path());
|
|
|
|
let manifest_path = dir.path().join("tool.toml");
|
|
let plugin = load_one_plugin(dir.path(), &manifest_path).unwrap();
|
|
|
|
assert_eq!(plugin.name, "test_plugin");
|
|
assert_eq!(plugin.version, "1.0.0");
|
|
assert_eq!(plugin.tool.name(), "test_plugin");
|
|
}
|
|
|
|
#[test]
|
|
fn load_one_plugin_fails_on_missing_name() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let toml = r#"
|
|
[tool]
|
|
name = ""
|
|
version = "1.0.0"
|
|
description = "Missing name test"
|
|
|
|
[exec]
|
|
binary = "tool.sh"
|
|
"#;
|
|
fs::write(dir.path().join("tool.toml"), toml).unwrap();
|
|
|
|
let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml"));
|
|
match result {
|
|
Err(e) => assert!(e.to_string().contains("name"), "unexpected error: {}", e),
|
|
Ok(_) => panic!("expected an error for missing name"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_one_plugin_fails_on_parse_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
fs::write(dir.path().join("tool.toml"), "not valid toml {{{{").unwrap();
|
|
|
|
let result = load_one_plugin(dir.path(), &dir.path().join("tool.toml"));
|
|
match result {
|
|
Err(e) => assert!(
|
|
e.to_string().contains("TOML parse error"),
|
|
"unexpected error: {}",
|
|
e
|
|
),
|
|
Ok(_) => panic!("expected a parse error"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scan_plugin_dir_skips_broken_manifests_without_panicking() {
|
|
// We can't redirect scan_plugin_dir to an arbitrary directory (it
|
|
// always uses ~/.zeroclaw/tools), but we can verify load_one_plugin
|
|
// behaviour under broken input without affecting the real directory.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Plugin 1: valid
|
|
let p1 = dir.path().join("good");
|
|
fs::create_dir_all(&p1).unwrap();
|
|
write_valid_manifest(&p1);
|
|
|
|
// Plugin 2: broken TOML
|
|
let p2 = dir.path().join("bad");
|
|
fs::create_dir_all(&p2).unwrap();
|
|
fs::write(p2.join("tool.toml"), "{{broken").unwrap();
|
|
|
|
// Load manually to simulate what scan_plugin_dir does.
|
|
let good = load_one_plugin(&p1, &p1.join("tool.toml"));
|
|
let bad = load_one_plugin(&p2, &p2.join("tool.toml"));
|
|
|
|
assert!(good.is_ok(), "good plugin should load");
|
|
assert!(bad.is_err(), "bad plugin should error, not panic");
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_tools_dir_returns_path_ending_in_zeroclaw_tools() {
|
|
let path = plugin_tools_dir().expect("should resolve");
|
|
let display = path.to_string_lossy();
|
|
let expected = std::path::Path::new(".zeroclaw").join("tools");
|
|
assert!(path.ends_with(&expected), "unexpected path: {}", display);
|
|
}
|
|
}
|