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:
ehu shubham shaw 2026-02-16 11:40:10 -05:00 committed by GitHub
parent b36f23784a
commit de3ec87d16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 9607 additions and 1885 deletions

View file

@ -74,31 +74,139 @@ pub struct Config {
#[serde(default)]
pub cost: CostConfig,
/// Hardware Abstraction Layer (HAL) configuration.
/// Controls how ZeroClaw interfaces with physical hardware
/// (GPIO, serial, debug probes).
#[serde(default)]
pub hardware: crate::hardware::HardwareConfig,
pub peripherals: PeripheralsConfig,
/// Named delegate agents for agent-to-agent handoff.
///
/// ```toml
/// [agents.researcher]
/// provider = "gemini"
/// model = "gemini-2.0-flash"
/// system_prompt = "You are a research assistant..."
///
/// [agents.coder]
/// provider = "openrouter"
/// model = "anthropic/claude-sonnet-4-20250514"
/// system_prompt = "You are a coding assistant..."
/// ```
/// Agent context limits — use compact for smaller models (e.g. 13B with 4k8k context).
#[serde(default)]
pub agent: AgentConfig,
/// Delegate agent configurations for multi-agent workflows.
#[serde(default)]
pub agents: HashMap<String, DelegateAgentConfig>,
/// Security configuration (sandboxing, resource limits, audit logging)
/// Hardware configuration (wizard-driven physical world setup).
#[serde(default)]
pub security: SecurityConfig,
pub hardware: HardwareConfig,
}
// ── Agent (context limits for smaller models) ────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
/// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
#[serde(default)]
pub compact_context: bool,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
compact_context: false,
}
}
}
// ── Delegate Agents ──────────────────────────────────────────────
/// Configuration for a delegate sub-agent used by the `delegate` tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegateAgentConfig {
/// Provider name (e.g. "ollama", "openrouter", "anthropic")
pub provider: String,
/// Model name
pub model: String,
/// Optional system prompt for the sub-agent
#[serde(default)]
pub system_prompt: Option<String>,
/// Optional API key override
#[serde(default)]
pub api_key: Option<String>,
/// Temperature override
#[serde(default)]
pub temperature: Option<f64>,
/// Max recursion depth for nested delegation
#[serde(default = "default_max_depth")]
pub max_depth: u32,
}
fn default_max_depth() -> u32 {
3
}
// ── Hardware Config (wizard-driven) ─────────────────────────────
/// Hardware transport mode.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HardwareTransport {
None,
Native,
Serial,
Probe,
}
impl Default for HardwareTransport {
fn default() -> Self {
Self::None
}
}
impl std::fmt::Display for HardwareTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "none"),
Self::Native => write!(f, "native"),
Self::Serial => write!(f, "serial"),
Self::Probe => write!(f, "probe"),
}
}
}
/// Wizard-driven hardware configuration for physical world interaction.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HardwareConfig {
/// Whether hardware access is enabled
#[serde(default)]
pub enabled: bool,
/// Transport mode
#[serde(default)]
pub transport: HardwareTransport,
/// Serial port path (e.g. "/dev/ttyACM0")
#[serde(default)]
pub serial_port: Option<String>,
/// Serial baud rate
#[serde(default = "default_baud_rate")]
pub baud_rate: u32,
/// Probe target chip (e.g. "STM32F401RE")
#[serde(default)]
pub probe_target: Option<String>,
/// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups)
#[serde(default)]
pub workspace_datasheets: bool,
}
fn default_baud_rate() -> u32 {
115200
}
impl HardwareConfig {
/// Return the active transport mode.
pub fn transport_mode(&self) -> HardwareTransport {
self.transport.clone()
}
}
impl Default for HardwareConfig {
fn default() -> Self {
Self {
enabled: false,
transport: HardwareTransport::None,
serial_port: None,
baud_rate: default_baud_rate(),
probe_target: None,
workspace_datasheets: false,
}
}
}
// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
@ -271,34 +379,64 @@ fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
prices
}
// ── Agent delegation ─────────────────────────────────────────────
// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────
/// Configuration for a named delegate agent that can be invoked via the
/// `delegate` tool. Each agent uses its own provider/model combination
/// and system prompt, enabling multi-agent workflows with specialization.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegateAgentConfig {
/// Provider name (e.g. "gemini", "openrouter", "ollama")
pub provider: String,
/// Model identifier for the provider
pub model: String,
/// System prompt defining the agent's role and capabilities
pub struct PeripheralsConfig {
/// Enable peripheral support (boards become agent tools)
#[serde(default)]
pub system_prompt: Option<String>,
/// Optional API key override (uses default if not set).
/// Stored encrypted when `secrets.encrypt = true`.
pub enabled: bool,
/// Board configurations (nucleo-f401re, rpi-gpio, etc.)
#[serde(default)]
pub api_key: Option<String>,
/// Temperature override (uses 0.7 if not set)
pub boards: Vec<PeripheralBoardConfig>,
/// Path to datasheet docs (relative to workspace) for RAG retrieval.
/// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
#[serde(default)]
pub temperature: Option<f64>,
/// Maximum delegation depth to prevent infinite recursion (default: 3)
#[serde(default = "default_max_delegation_depth")]
pub max_depth: u32,
pub datasheet_dir: Option<String>,
}
fn default_max_delegation_depth() -> u32 {
3
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeripheralBoardConfig {
/// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
pub board: String,
/// Transport: "serial", "native", "websocket"
#[serde(default = "default_peripheral_transport")]
pub transport: String,
/// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
#[serde(default)]
pub path: Option<String>,
/// Baud rate for serial (default: 115200)
#[serde(default = "default_peripheral_baud")]
pub baud: u32,
}
fn default_peripheral_transport() -> String {
"serial".into()
}
fn default_peripheral_baud() -> u32 {
115200
}
impl Default for PeripheralsConfig {
fn default() -> Self {
Self {
enabled: false,
boards: Vec::new(),
datasheet_dir: None,
}
}
}
impl Default for PeripheralBoardConfig {
fn default() -> Self {
Self {
board: String::new(),
transport: default_peripheral_transport(),
path: None,
baud: default_peripheral_baud(),
}
}
}
// ── Gateway security ─────────────────────────────────────────────
@ -1381,9 +1519,10 @@ impl Default for Config {
http_request: HttpRequestConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
hardware: crate::hardware::HardwareConfig::default(),
peripherals: PeripheralsConfig::default(),
agent: AgentConfig::default(),
agents: HashMap::new(),
security: SecurityConfig::default(),
hardware: HardwareConfig::default(),
}
}
}
@ -1410,37 +1549,36 @@ impl Config {
// Set computed paths that are skipped during serialization
config.config_path = config_path.clone();
config.workspace_dir = zeroclaw_dir.join("workspace");
// Decrypt agent API keys if encryption is enabled
let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
for agent in config.agents.values_mut() {
if let Some(ref encrypted_key) = agent.api_key {
agent.api_key = Some(
store
.decrypt(encrypted_key)
.context("Failed to decrypt agent API key")?,
);
}
}
config.apply_env_overrides();
Ok(config)
} else {
let mut config = Config::default();
config.config_path = config_path.clone();
config.workspace_dir = zeroclaw_dir.join("workspace");
config.save()?;
config.apply_env_overrides();
Ok(config)
}
}
/// Apply environment variable overrides to config
pub fn apply_env_overrides(&mut self) {
// API Key: ZEROCLAW_API_KEY or API_KEY
// API Key: ZEROCLAW_API_KEY or API_KEY (generic)
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
if !key.is_empty() {
self.api_key = Some(key);
}
}
// API Key: GLM_API_KEY overrides when provider is glm (provider-specific)
if self.default_provider.as_deref() == Some("glm")
|| self.default_provider.as_deref() == Some("zhipu")
{
if let Ok(key) = std::env::var("GLM_API_KEY") {
if !key.is_empty() {
self.api_key = Some(key);
}
}
}
// Provider: ZEROCLAW_PROVIDER or PROVIDER
if let Ok(provider) =
@ -1737,9 +1875,10 @@ mod tests {
http_request: HttpRequestConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
hardware: crate::hardware::HardwareConfig::default(),
peripherals: PeripheralsConfig::default(),
agent: AgentConfig::default(),
agents: HashMap::new(),
security: SecurityConfig::default(),
hardware: HardwareConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@ -1814,9 +1953,10 @@ default_temperature = 0.7
http_request: HttpRequestConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
hardware: crate::hardware::HardwareConfig::default(),
peripherals: PeripheralsConfig::default(),
agent: AgentConfig::default(),
agents: HashMap::new(),
security: SecurityConfig::default(),
hardware: HardwareConfig::default(),
};
config.save().unwrap();
@ -2637,236 +2777,41 @@ default_temperature = 0.7
assert!(g.paired_tokens.is_empty());
}
// ── Lark config ───────────────────────────────────────────────
// ── Peripherals config ───────────────────────────────────────
#[test]
fn lark_config_serde() {
let lc = LarkConfig {
app_id: "cli_123456".into(),
app_secret: "secret_abc".into(),
encrypt_key: Some("encrypt_key".into()),
verification_token: Some("verify_token".into()),
allowed_users: vec!["user_123".into(), "user_456".into()],
use_feishu: true,
fn peripherals_config_default_disabled() {
let p = PeripheralsConfig::default();
assert!(!p.enabled);
assert!(p.boards.is_empty());
}
#[test]
fn peripheral_board_config_defaults() {
let b = PeripheralBoardConfig::default();
assert!(b.board.is_empty());
assert_eq!(b.transport, "serial");
assert!(b.path.is_none());
assert_eq!(b.baud, 115200);
}
#[test]
fn peripherals_config_toml_roundtrip() {
let p = PeripheralsConfig {
enabled: true,
boards: vec![PeripheralBoardConfig {
board: "nucleo-f401re".into(),
transport: "serial".into(),
path: Some("/dev/ttyACM0".into()),
baud: 115200,
}],
datasheet_dir: None,
};
let json = serde_json::to_string(&lc).unwrap();
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.app_id, "cli_123456");
assert_eq!(parsed.app_secret, "secret_abc");
assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
assert_eq!(parsed.allowed_users.len(), 2);
assert!(parsed.use_feishu);
}
#[test]
fn lark_config_toml_roundtrip() {
let lc = LarkConfig {
app_id: "cli_123456".into(),
app_secret: "secret_abc".into(),
encrypt_key: Some("encrypt_key".into()),
verification_token: Some("verify_token".into()),
allowed_users: vec!["*".into()],
use_feishu: false,
};
let toml_str = toml::to_string(&lc).unwrap();
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.app_id, "cli_123456");
assert_eq!(parsed.app_secret, "secret_abc");
assert!(!parsed.use_feishu);
}
#[test]
fn lark_config_deserializes_without_optional_fields() {
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert!(parsed.encrypt_key.is_none());
assert!(parsed.verification_token.is_none());
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.use_feishu);
}
#[test]
fn lark_config_defaults_to_lark_endpoint() {
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert!(
!parsed.use_feishu,
"use_feishu should default to false (Lark)"
);
}
#[test]
fn lark_config_with_wildcard_allowed_users() {
let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["*"]);
}
// ══════════════════════════════════════════════════════════
// AGENT DELEGATION CONFIG TESTS
// ══════════════════════════════════════════════════════════
#[test]
fn agents_config_default_empty() {
let c = Config::default();
assert!(c.agents.is_empty());
}
#[test]
fn agents_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(parsed.agents.is_empty());
}
#[test]
fn agents_config_toml_roundtrip() {
let toml_str = r#"
default_temperature = 0.7
[agents.researcher]
provider = "gemini"
model = "gemini-2.0-flash"
system_prompt = "You are a research assistant."
max_depth = 2
[agents.coder]
provider = "openrouter"
model = "anthropic/claude-sonnet-4-20250514"
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.agents.len(), 2);
let researcher = &parsed.agents["researcher"];
assert_eq!(researcher.provider, "gemini");
assert_eq!(researcher.model, "gemini-2.0-flash");
assert_eq!(
researcher.system_prompt.as_deref(),
Some("You are a research assistant.")
);
assert_eq!(researcher.max_depth, 2);
assert!(researcher.api_key.is_none());
assert!(researcher.temperature.is_none());
let coder = &parsed.agents["coder"];
assert_eq!(coder.provider, "openrouter");
assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514");
assert!(coder.system_prompt.is_none());
assert_eq!(coder.max_depth, 3); // default
}
#[test]
fn agents_config_with_api_key_and_temperature() {
let toml_str = r#"
[agents.fast]
provider = "groq"
model = "llama-3.3-70b-versatile"
api_key = "gsk-test-key"
temperature = 0.3
"#;
let parsed: HashMap<String, DelegateAgentConfig> = toml::from_str::<toml::Value>(toml_str)
.unwrap()["agents"]
.clone()
.try_into()
.unwrap();
let fast = &parsed["fast"];
assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key"));
assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON);
}
#[test]
fn agent_api_key_encrypted_on_save_and_decrypted_on_load() {
let tmp = TempDir::new().unwrap();
let zeroclaw_dir = tmp.path();
let config_path = zeroclaw_dir.join("config.toml");
// Create a config with a plaintext agent API key
let mut agents = HashMap::new();
agents.insert(
"test_agent".to_string(),
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "test-model".to_string(),
system_prompt: None,
api_key: Some("sk-super-secret".to_string()),
temperature: None,
max_depth: 3,
},
);
let config = Config {
config_path: config_path.clone(),
workspace_dir: zeroclaw_dir.join("workspace"),
secrets: SecretsConfig { encrypt: true },
agents,
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config.save().unwrap();
// Read the raw TOML and verify the key is encrypted (not plaintext)
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(
!raw.contains("sk-super-secret"),
"Plaintext API key should not appear in saved config"
);
assert!(
raw.contains("enc2:"),
"Encrypted key should use enc2: prefix"
);
// Parse and decrypt — simulate load_or_init by reading + decrypting
let store = crate::security::SecretStore::new(zeroclaw_dir, true);
let mut loaded: Config = toml::from_str(&raw).unwrap();
for agent in loaded.agents.values_mut() {
if let Some(ref encrypted_key) = agent.api_key {
agent.api_key = Some(store.decrypt(encrypted_key).unwrap());
}
}
assert_eq!(
loaded.agents["test_agent"].api_key.as_deref(),
Some("sk-super-secret"),
"Decrypted key should match original"
);
}
#[test]
fn agent_api_key_not_encrypted_when_disabled() {
let tmp = TempDir::new().unwrap();
let zeroclaw_dir = tmp.path();
let config_path = zeroclaw_dir.join("config.toml");
let mut agents = HashMap::new();
agents.insert(
"test_agent".to_string(),
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "test-model".to_string(),
system_prompt: None,
api_key: Some("sk-plaintext-ok".to_string()),
temperature: None,
max_depth: 3,
},
);
let config = Config {
config_path: config_path.clone(),
workspace_dir: zeroclaw_dir.join("workspace"),
secrets: SecretsConfig { encrypt: false },
agents,
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config.save().unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(
raw.contains("sk-plaintext-ok"),
"With encryption disabled, key should remain plaintext"
);
assert!(!raw.contains("enc2:"), "No encryption prefix when disabled");
let toml_str = toml::to_string(&p).unwrap();
let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.boards.len(), 1);
assert_eq!(parsed.boards[0].board, "nucleo-f401re");
assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
}
}