* 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>
428 lines
14 KiB
Rust
428 lines
14 KiB
Rust
//! Hardware context management endpoints.
|
||
//!
|
||
//! These endpoints let remote callers (phone, laptop) register GPIO pins and
|
||
//! append context to the running agent's hardware knowledge base without SSH.
|
||
//!
|
||
//! ## Endpoints
|
||
//!
|
||
//! - `POST /api/hardware/pin` — register a single GPIO pin assignment
|
||
//! - `POST /api/hardware/context` — append raw markdown to a device file
|
||
//! - `GET /api/hardware/context` — read all current hardware context files
|
||
//! - `POST /api/hardware/reload` — verify on-disk context; report what will be
|
||
//! used on the next chat request
|
||
//!
|
||
//! ## Live update semantics
|
||
//!
|
||
//! ZeroClaw's agent loop calls [`crate::hardware::boot`] on **every** request,
|
||
//! which re-reads `~/.zeroclaw/hardware/` from disk. Writing to those files
|
||
//! therefore takes effect on the very next `/api/chat` call — no daemon restart
|
||
//! needed. The `/api/hardware/reload` endpoint verifies what is on disk and
|
||
//! reports what will be injected into the system prompt next time.
|
||
//!
|
||
//! ## Security
|
||
//!
|
||
//! - **Auth**: same `require_auth` helper used by all `/api/*` routes.
|
||
//! - **Path traversal**: device aliases are validated to be alphanumeric +
|
||
//! hyphens/underscores only; they are never used as raw path components.
|
||
//! - **Append-only**: all writes use `OpenOptions::append(true)` — existing
|
||
//! content cannot be truncated or overwritten through these endpoints.
|
||
//! - **Size limit**: individual append payloads are capped at 32 KB.
|
||
|
||
use super::AppState;
|
||
use axum::{
|
||
extract::{State},
|
||
http::{HeaderMap, StatusCode},
|
||
response::{IntoResponse, Json},
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::path::PathBuf;
|
||
use tokio::fs;
|
||
use tokio::io::AsyncWriteExt as _;
|
||
|
||
/// Maximum bytes allowed in a single append payload.
|
||
const MAX_APPEND_BYTES: usize = 32_768; // 32 KB
|
||
|
||
// ── Auth helper (re-uses the pattern from api.rs) ─────────────────────────────
|
||
|
||
fn require_auth(
|
||
state: &AppState,
|
||
headers: &HeaderMap,
|
||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||
if !state.pairing.require_pairing() {
|
||
return Ok(());
|
||
}
|
||
let token = headers
|
||
.get(axum::http::header::AUTHORIZATION)
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||
.unwrap_or("");
|
||
if state.pairing.is_authenticated(token) {
|
||
Ok(())
|
||
} else {
|
||
Err((
|
||
StatusCode::UNAUTHORIZED,
|
||
Json(serde_json::json!({
|
||
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
|
||
})),
|
||
))
|
||
}
|
||
}
|
||
|
||
// ── Path helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/// Return `~/.zeroclaw/hardware/` or an error string.
|
||
fn hardware_dir() -> Result<PathBuf, String> {
|
||
directories::BaseDirs::new()
|
||
.map(|b| b.home_dir().join(".zeroclaw").join("hardware"))
|
||
.ok_or_else(|| "Cannot determine home directory".to_string())
|
||
}
|
||
|
||
/// Validate a device alias: must be non-empty, ≤64 chars, and consist only of
|
||
/// alphanumerics, hyphens, and underscores. Returns an error message on failure.
|
||
fn validate_device_alias(alias: &str) -> Result<(), &'static str> {
|
||
if alias.is_empty() || alias.len() > 64 {
|
||
return Err("Device alias must be 1–64 characters");
|
||
}
|
||
if !alias.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||
return Err("Device alias must contain only alphanumerics, hyphens, and underscores");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Return the path to a device context file, after validating the alias.
|
||
fn device_file_path(hw_dir: &std::path::Path, alias: &str) -> Result<PathBuf, &'static str> {
|
||
validate_device_alias(alias)?;
|
||
Ok(hw_dir.join("devices").join(format!("{alias}.md")))
|
||
}
|
||
|
||
// ── POST /api/hardware/pin ────────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct PinRegistrationBody {
|
||
/// Device alias (default: "rpi0").
|
||
#[serde(default = "default_device")]
|
||
pub device: String,
|
||
/// BCM GPIO number.
|
||
pub pin: u32,
|
||
/// Component type/name, e.g. "LED", "Button", "Servo".
|
||
pub component: String,
|
||
/// Optional human notes about this pin, e.g. "red LED, active HIGH".
|
||
#[serde(default)]
|
||
pub notes: String,
|
||
}
|
||
|
||
fn default_device() -> String {
|
||
"rpi0".to_string()
|
||
}
|
||
|
||
/// `POST /api/hardware/pin` — register a single GPIO pin assignment.
|
||
///
|
||
/// Appends one line to `~/.zeroclaw/hardware/devices/<device>.md`:
|
||
/// ```text
|
||
/// - GPIO <pin>: <component> — <notes>
|
||
/// ```
|
||
pub async fn handle_hardware_pin(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
body: Result<Json<PinRegistrationBody>, axum::extract::rejection::JsonRejection>,
|
||
) -> impl IntoResponse {
|
||
if let Err(e) = require_auth(&state, &headers) {
|
||
return e.into_response();
|
||
}
|
||
|
||
let Json(req) = match body {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
if req.component.is_empty() {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({ "error": "\"component\" must not be empty" })),
|
||
)
|
||
.into_response();
|
||
}
|
||
// Sanitize component + notes: strip newlines to prevent line-injection.
|
||
let component = req.component.replace(['\n', '\r'], " ");
|
||
let notes = req.notes.replace(['\n', '\r'], " ");
|
||
|
||
let hw_dir = match hardware_dir() {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": e })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
let device_path = match device_file_path(&hw_dir, &req.device) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({ "error": e })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
// Create devices dir + file if missing, then append.
|
||
if let Some(parent) = device_path.parent() {
|
||
if let Err(e) = fs::create_dir_all(parent).await {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
|
||
let line = if notes.is_empty() {
|
||
format!("- GPIO {}: {}\n", req.pin, component)
|
||
} else {
|
||
format!("- GPIO {}: {} — {}\n", req.pin, component, notes)
|
||
};
|
||
|
||
match append_to_file(&device_path, &line).await {
|
||
Ok(()) => {
|
||
let message = format!(
|
||
"GPIO {} registered as {} on {}",
|
||
req.pin, component, req.device
|
||
);
|
||
tracing::info!(device = %req.device, pin = req.pin, component = %component, "{}", message);
|
||
(
|
||
StatusCode::OK,
|
||
Json(serde_json::json!({ "ok": true, "message": message })),
|
||
)
|
||
.into_response()
|
||
}
|
||
Err(e) => (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
|
||
)
|
||
.into_response(),
|
||
}
|
||
}
|
||
|
||
// ── POST /api/hardware/context ────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct ContextAppendBody {
|
||
/// Device alias (default: "rpi0").
|
||
#[serde(default = "default_device")]
|
||
pub device: String,
|
||
/// Raw markdown string to append to the device file.
|
||
pub content: String,
|
||
}
|
||
|
||
/// `POST /api/hardware/context` — append raw markdown to a device file.
|
||
pub async fn handle_hardware_context_post(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
body: Result<Json<ContextAppendBody>, axum::extract::rejection::JsonRejection>,
|
||
) -> impl IntoResponse {
|
||
if let Err(e) = require_auth(&state, &headers) {
|
||
return e.into_response();
|
||
}
|
||
|
||
let Json(req) = match body {
|
||
Ok(b) => b,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
if req.content.is_empty() {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({ "error": "\"content\" must not be empty" })),
|
||
)
|
||
.into_response();
|
||
}
|
||
if req.content.len() > MAX_APPEND_BYTES {
|
||
return (
|
||
StatusCode::PAYLOAD_TOO_LARGE,
|
||
Json(serde_json::json!({
|
||
"error": format!("Content too large — max {} bytes", MAX_APPEND_BYTES)
|
||
})),
|
||
)
|
||
.into_response();
|
||
}
|
||
|
||
let hw_dir = match hardware_dir() {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": e })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
let device_path = match device_file_path(&hw_dir, &req.device) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::BAD_REQUEST,
|
||
Json(serde_json::json!({ "error": e })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
if let Some(parent) = device_path.parent() {
|
||
if let Err(e) = fs::create_dir_all(parent).await {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
|
||
)
|
||
.into_response();
|
||
}
|
||
}
|
||
|
||
// Ensure content ends with a newline so successive appends don't merge lines.
|
||
let mut content = req.content.clone();
|
||
if !content.ends_with('\n') {
|
||
content.push('\n');
|
||
}
|
||
|
||
match append_to_file(&device_path, &content).await {
|
||
Ok(()) => {
|
||
tracing::info!(device = %req.device, bytes = content.len(), "Hardware context appended");
|
||
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||
}
|
||
Err(e) => (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
|
||
)
|
||
.into_response(),
|
||
}
|
||
}
|
||
|
||
// ── GET /api/hardware/context ─────────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct HardwareContextResponse {
|
||
hardware_md: String,
|
||
devices: std::collections::HashMap<String, String>,
|
||
}
|
||
|
||
/// `GET /api/hardware/context` — return all current hardware context file contents.
|
||
pub async fn handle_hardware_context_get(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
) -> impl IntoResponse {
|
||
if let Err(e) = require_auth(&state, &headers) {
|
||
return e.into_response();
|
||
}
|
||
|
||
let hw_dir = match hardware_dir() {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return (
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
Json(serde_json::json!({ "error": e })),
|
||
)
|
||
.into_response()
|
||
}
|
||
};
|
||
|
||
// Read HARDWARE.md
|
||
let hardware_md = fs::read_to_string(hw_dir.join("HARDWARE.md"))
|
||
.await
|
||
.unwrap_or_default();
|
||
|
||
// Read all device files
|
||
let devices_dir = hw_dir.join("devices");
|
||
let mut devices = std::collections::HashMap::new();
|
||
if let Ok(mut entries) = fs::read_dir(&devices_dir).await {
|
||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||
let path = entry.path();
|
||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||
let alias = path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
if !alias.is_empty() {
|
||
let content = fs::read_to_string(&path).await.unwrap_or_default();
|
||
devices.insert(alias, content);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let resp = HardwareContextResponse {
|
||
hardware_md,
|
||
devices,
|
||
};
|
||
(StatusCode::OK, Json(resp)).into_response()
|
||
}
|
||
|
||
// ── POST /api/hardware/reload ─────────────────────────────────────────────────
|
||
|
||
/// `POST /api/hardware/reload` — verify on-disk hardware context and report what
|
||
/// will be loaded on the next chat request.
|
||
///
|
||
/// Since [`crate::hardware::boot`] re-reads from disk on every agent invocation,
|
||
/// writing to the hardware files via the other endpoints already takes effect on
|
||
/// the next `/api/chat` call. This endpoint reads the same files and reports
|
||
/// the current state so callers can confirm the update landed.
|
||
pub async fn handle_hardware_reload(
|
||
State(state): State<AppState>,
|
||
headers: HeaderMap,
|
||
) -> impl IntoResponse {
|
||
if let Err(e) = require_auth(&state, &headers) {
|
||
return e.into_response();
|
||
}
|
||
|
||
// Count currently-registered tools in the gateway state
|
||
let tool_count = state.tools_registry.len();
|
||
|
||
// Reload hardware context from disk (same function used by the agent loop)
|
||
let context = crate::hardware::load_hardware_context_prompt(&[]);
|
||
let context_length = context.len();
|
||
|
||
tracing::info!(
|
||
context_length,
|
||
tool_count,
|
||
"Hardware context reloaded (on-disk read)"
|
||
);
|
||
|
||
(
|
||
StatusCode::OK,
|
||
Json(serde_json::json!({
|
||
"ok": true,
|
||
"tools": tool_count,
|
||
"context_length": context_length,
|
||
})),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
// ── File I/O helper ───────────────────────────────────────────────────────────
|
||
|
||
async fn append_to_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
|
||
let mut file = tokio::fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(path)
|
||
.await?;
|
||
file.write_all(content.as_bytes()).await?;
|
||
file.flush().await?;
|
||
Ok(())
|
||
}
|