zeroclaw/src/gateway/hardware_context.rs
ehu shubham shaw 71e89801b5
feat(hardware): add RPi GPIO, Aardvark I2C/SPI/GPIO, and hardware plugin system (#4125)
* 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>
2026-03-21 04:17:01 -04:00

428 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 164 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(())
}