zeroclaw/src/hardware/protocol.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

149 lines
4.7 KiB
Rust

//! ZeroClaw serial JSON protocol — the firmware contract.
//!
//! These types define the newline-delimited JSON wire format shared between
//! the ZeroClaw host and device firmware (Pico, Arduino, ESP32, Nucleo).
//!
//! Wire format:
//! Host → Device: `{"cmd":"gpio_write","params":{"pin":25,"value":1}}\n`
//! Device → Host: `{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n`
//!
//! Both sides MUST agree on these struct definitions. Any change here is a
//! breaking firmware contract change.
use serde::{Deserialize, Serialize};
/// Host-to-device command.
///
/// Serialized as one JSON line terminated by `\n`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ZcCommand {
/// Command name (e.g. `"gpio_read"`, `"ping"`, `"reboot_bootsel"`).
pub cmd: String,
/// Command parameters — schema depends on the command.
#[serde(default)]
pub params: serde_json::Value,
}
impl ZcCommand {
/// Create a new command with the given name and parameters.
pub fn new(cmd: impl Into<String>, params: serde_json::Value) -> Self {
Self {
cmd: cmd.into(),
params,
}
}
/// Create a parameterless command (e.g. `ping`, `capabilities`).
pub fn simple(cmd: impl Into<String>) -> Self {
Self {
cmd: cmd.into(),
params: serde_json::Value::Object(serde_json::Map::new()),
}
}
}
/// Device-to-host response.
///
/// Serialized as one JSON line terminated by `\n`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ZcResponse {
/// Whether the command succeeded.
pub ok: bool,
/// Response payload — schema depends on the command executed.
#[serde(default)]
pub data: serde_json::Value,
/// Human-readable error message when `ok` is false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl ZcResponse {
/// Create a success response with data.
pub fn success(data: serde_json::Value) -> Self {
Self {
ok: true,
data,
error: None,
}
}
/// Create an error response.
pub fn error(message: impl Into<String>) -> Self {
Self {
ok: false,
data: serde_json::Value::Null,
error: Some(message.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn zc_command_serialization_roundtrip() {
let cmd = ZcCommand::new("gpio_write", json!({"pin": 25, "value": 1}));
let json = serde_json::to_string(&cmd).unwrap();
let parsed: ZcCommand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.cmd, "gpio_write");
assert_eq!(parsed.params["pin"], 25);
assert_eq!(parsed.params["value"], 1);
}
#[test]
fn zc_command_simple_has_empty_params() {
let cmd = ZcCommand::simple("ping");
assert_eq!(cmd.cmd, "ping");
assert!(cmd.params.is_object());
}
#[test]
fn zc_response_success_roundtrip() {
let resp = ZcResponse::success(json!({"value": 1}));
let json = serde_json::to_string(&resp).unwrap();
let parsed: ZcResponse = serde_json::from_str(&json).unwrap();
assert!(parsed.ok);
assert_eq!(parsed.data["value"], 1);
assert!(parsed.error.is_none());
}
#[test]
fn zc_response_error_roundtrip() {
let resp = ZcResponse::error("pin not available");
let json = serde_json::to_string(&resp).unwrap();
let parsed: ZcResponse = serde_json::from_str(&json).unwrap();
assert!(!parsed.ok);
assert_eq!(parsed.error.as_deref(), Some("pin not available"));
}
#[test]
fn zc_command_wire_format_matches_spec() {
// Verify the exact JSON shape the firmware expects.
let cmd = ZcCommand::new("gpio_write", json!({"pin": 25, "value": 1}));
let v: serde_json::Value = serde_json::to_value(&cmd).unwrap();
assert!(v.get("cmd").is_some());
assert!(v.get("params").is_some());
}
#[test]
fn zc_response_from_firmware_json() {
// Simulate a raw firmware response line.
let raw = r#"{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}"#;
let resp: ZcResponse = serde_json::from_str(raw).unwrap();
assert!(resp.ok);
assert_eq!(resp.data["state"], "HIGH");
}
#[test]
fn zc_response_missing_optional_fields() {
// Firmware may omit `data` and `error` on success.
let raw = r#"{"ok":true}"#;
let resp: ZcResponse = serde_json::from_str(raw).unwrap();
assert!(resp.ok);
assert!(resp.data.is_null());
assert!(resp.error.is_none());
}
}