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

@ -143,6 +143,46 @@ async fn build_context(mem: &dyn Memory, user_msg: &str) -> String {
context
}
/// Build hardware datasheet context from RAG when peripherals are enabled.
/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
fn build_hardware_context(
rag: &crate::rag::HardwareRag,
user_msg: &str,
boards: &[String],
chunk_limit: usize,
) -> String {
if rag.is_empty() || boards.is_empty() {
return String::new();
}
let mut context = String::new();
// Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
let pin_ctx = rag.pin_alias_context(user_msg, boards);
if !pin_ctx.is_empty() {
context.push_str(&pin_ctx);
}
let chunks = rag.retrieve(user_msg, boards, chunk_limit);
if chunks.is_empty() && pin_ctx.is_empty() {
return String::new();
}
if !chunks.is_empty() {
context.push_str("[Hardware documentation]\n");
}
for chunk in chunks {
let board_tag = chunk.board.as_deref().unwrap_or("generic");
let _ = writeln!(
context,
"--- {} ({}) ---\n{}\n",
chunk.source, board_tag, chunk.content
);
}
context.push('\n');
context
}
/// Find a tool by name in the registry.
fn find_tool<'a>(tools: &'a [Box<dyn Tool>], name: &str) -> Option<&'a dyn Tool> {
tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
@ -370,10 +410,9 @@ struct ParsedToolCall {
arguments: serde_json::Value,
}
/// Execute a single turn for channel runtime paths.
///
/// Channel runtime now provides an explicit provider label so observer events
/// stay consistent with the main agent loop execution path.
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
/// When `silent` is true, suppresses stdout (for channel use).
pub(crate) async fn agent_turn(
provider: &dyn Provider,
history: &mut Vec<ChatMessage>,
@ -382,6 +421,7 @@ pub(crate) async fn agent_turn(
provider_name: &str,
model: &str,
temperature: f64,
silent: bool,
) -> Result<String> {
run_tool_call_loop(
provider,
@ -391,6 +431,7 @@ pub(crate) async fn agent_turn(
provider_name,
model,
temperature,
silent,
)
.await
}
@ -405,6 +446,7 @@ pub(crate) async fn run_tool_call_loop(
provider_name: &str,
model: &str,
temperature: f64,
silent: bool,
) -> Result<String> {
for _iteration in 0..MAX_TOOL_ITERATIONS {
observer.record_event(&ObserverEvent::LlmRequest {
@ -458,17 +500,16 @@ pub(crate) async fn run_tool_call_loop(
if tool_calls.is_empty() {
// No tool calls — this is the final response
let final_text = if parsed_text.is_empty() {
history.push(ChatMessage::assistant(response_text.clone()));
return Ok(if parsed_text.is_empty() {
response_text
} else {
parsed_text
};
history.push(ChatMessage::assistant(&final_text));
return Ok(final_text);
});
}
// Print any text the LLM produced alongside tool calls
if !parsed_text.is_empty() {
// Print any text the LLM produced alongside tool calls (unless silent)
if !silent && !parsed_text.is_empty() {
print!("{parsed_text}");
let _ = std::io::stdout().flush();
}
@ -515,7 +556,7 @@ pub(crate) async fn run_tool_call_loop(
}
// Add assistant message with tool calls + tool results to history
history.push(ChatMessage::assistant(&assistant_history_content));
history.push(ChatMessage::assistant(assistant_history_content.clone()));
history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
}
@ -529,6 +570,10 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> Strin
instructions.push_str("\n## Tool Use Protocol\n\n");
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
instructions.push_str(
"CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
);
instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n\n");
instructions.push_str("You may use multiple tool calls in a single response. ");
instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
instructions
@ -555,18 +600,11 @@ pub async fn run(
provider_override: Option<String>,
model_override: Option<String>,
temperature: f64,
verbose: bool,
peripheral_overrides: Vec<String>,
) -> Result<()> {
// ── Wire up agnostic subsystems ──────────────────────────────
let base_observer = observability::create_observer(&config.observability);
let observer: Arc<dyn Observer> = if verbose {
Arc::from(Box::new(observability::MultiObserver::new(vec![
base_observer,
Box::new(observability::VerboseObserver::new()),
])) as Box<dyn Observer>)
} else {
Arc::from(base_observer)
};
let observer: Arc<dyn Observer> = Arc::from(base_observer);
let runtime: Arc<dyn runtime::RuntimeAdapter> =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
@ -582,7 +620,15 @@ pub async fn run(
)?);
tracing::info!(backend = mem.name(), "Memory initialized");
// ── Tools (including memory tools) ────────────────────────────
// ── Peripherals (merge peripheral tools into registry) ─
if !peripheral_overrides.is_empty() {
tracing::info!(
peripherals = ?peripheral_overrides,
"Peripheral overrides from CLI (config boards take precedence)"
);
}
// ── Tools (including memory tools and peripherals) ────────────
let (composio_key, composio_entity_id) = if config.composio.enabled {
(
config.composio.api_key.as_deref(),
@ -591,7 +637,7 @@ pub async fn run(
} else {
(None, None)
};
let tools_registry = tools::all_tools_with_runtime(
let mut tools_registry = tools::all_tools_with_runtime(
&security,
runtime,
mem.clone(),
@ -605,6 +651,13 @@ pub async fn run(
&config,
);
let peripheral_tools: Vec<Box<dyn Tool>> =
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
if !peripheral_tools.is_empty() {
tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
tools_registry.extend(peripheral_tools);
}
// ── Resolve provider ─────────────────────────────────────────
let provider_name = provider_override
.as_deref()
@ -629,6 +682,26 @@ pub async fn run(
model: model_name.to_string(),
});
// ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
let hardware_rag: Option<crate::rag::HardwareRag> = config
.peripherals
.datasheet_dir
.as_ref()
.filter(|d| !d.trim().is_empty())
.map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
.and_then(Result::ok)
.filter(|r: &crate::rag::HardwareRag| !r.is_empty());
if let Some(ref rag) = hardware_rag {
tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
}
let board_names: Vec<String> = config
.peripherals
.boards
.iter()
.map(|b| b.board.clone())
.collect();
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
let skills = crate::skills::load_skills(&config.workspace_dir);
let mut tool_descs: Vec<(&str, &str)> = vec![
@ -684,17 +757,51 @@ pub async fn run(
if !config.agents.is_empty() {
tool_descs.push((
"delegate",
"Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
(e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
prompt and returns its response.",
"Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
));
}
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
tool_descs.push((
"gpio_read",
"Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
));
tool_descs.push((
"gpio_write",
"Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
));
tool_descs.push((
"arduino_upload",
"Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.",
));
tool_descs.push((
"hardware_memory_map",
"Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
));
tool_descs.push((
"hardware_board_info",
"Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.",
));
tool_descs.push((
"hardware_memory_read",
"Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).",
));
tool_descs.push((
"hardware_capabilities",
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
));
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
None
};
let mut system_prompt = crate::channels::build_system_prompt(
&config.workspace_dir,
model_name,
&tool_descs,
&skills,
Some(&config.identity),
bootstrap_max_chars,
);
// Append structured tool-use instructions with schemas
@ -712,8 +819,14 @@ pub async fn run(
.await;
}
// Inject memory context into user message
let context = build_context(mem.as_ref(), &msg).await;
// Inject memory + hardware RAG context into user message
let mem_context = build_context(mem.as_ref(), &msg).await;
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
let hw_context = hardware_rag
.as_ref()
.map(|r| build_hardware_context(r, &msg, &board_names, rag_limit))
.unwrap_or_default();
let context = format!("{mem_context}{hw_context}");
let enriched = if context.is_empty() {
msg.clone()
} else {
@ -733,6 +846,7 @@ pub async fn run(
provider_name,
model_name,
temperature,
false,
)
.await?;
println!("{response}");
@ -770,8 +884,14 @@ pub async fn run(
.await;
}
// Inject memory context into user message
let context = build_context(mem.as_ref(), &msg.content).await;
// Inject memory + hardware RAG context into user message
let mem_context = build_context(mem.as_ref(), &msg.content).await;
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
let hw_context = hardware_rag
.as_ref()
.map(|r| build_hardware_context(r, &msg.content, &board_names, rag_limit))
.unwrap_or_default();
let context = format!("{mem_context}{hw_context}");
let enriched = if context.is_empty() {
msg.content.clone()
} else {
@ -788,6 +908,7 @@ pub async fn run(
provider_name,
model_name,
temperature,
false,
)
.await
{
@ -833,6 +954,166 @@ pub async fn run(
Ok(())
}
/// Process a single message through the full agent (with tools, peripherals, memory).
/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
pub async fn process_message(config: Config, message: &str) -> Result<String> {
let observer: Arc<dyn Observer> =
Arc::from(observability::create_observer(&config.observability));
let runtime: Arc<dyn runtime::RuntimeAdapter> =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
&config.memory,
&config.workspace_dir,
config.api_key.as_deref(),
)?);
let (composio_key, composio_entity_id) = if config.composio.enabled {
(
config.composio.api_key.as_deref(),
Some(config.composio.entity_id.as_str()),
)
} else {
(None, None)
};
let mut tools_registry = tools::all_tools_with_runtime(
&security,
runtime,
mem.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
&config,
);
let peripheral_tools: Vec<Box<dyn Tool>> =
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
tools_registry.extend(peripheral_tools);
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
let model_name = config
.default_model
.clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
let provider: Box<dyn Provider> = providers::create_routed_provider(
provider_name,
config.api_key.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
)?;
let hardware_rag: Option<crate::rag::HardwareRag> = config
.peripherals
.datasheet_dir
.as_ref()
.filter(|d| !d.trim().is_empty())
.map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
.and_then(Result::ok)
.filter(|r: &crate::rag::HardwareRag| !r.is_empty());
let board_names: Vec<String> = config
.peripherals
.boards
.iter()
.map(|b| b.board.clone())
.collect();
let skills = crate::skills::load_skills(&config.workspace_dir);
let mut tool_descs: Vec<(&str, &str)> = vec![
("shell", "Execute terminal commands."),
("file_read", "Read file contents."),
("file_write", "Write file contents."),
("memory_store", "Save to memory."),
("memory_recall", "Search memory."),
("memory_forget", "Delete a memory entry."),
("screenshot", "Capture a screenshot."),
("image_info", "Read image metadata."),
];
if config.browser.enabled {
tool_descs.push(("browser_open", "Open approved URLs in browser."));
}
if config.composio.enabled {
tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
}
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
tool_descs.push((
"gpio_write",
"Set GPIO pin high or low on connected hardware.",
));
tool_descs.push((
"arduino_upload",
"Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.",
));
tool_descs.push((
"hardware_memory_map",
"Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
));
tool_descs.push((
"hardware_board_info",
"Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
));
tool_descs.push((
"hardware_memory_read",
"Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.",
));
tool_descs.push((
"hardware_capabilities",
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
));
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
None
};
let mut system_prompt = crate::channels::build_system_prompt(
&config.workspace_dir,
&model_name,
&tool_descs,
&skills,
Some(&config.identity),
bootstrap_max_chars,
);
system_prompt.push_str(&build_tool_instructions(&tools_registry));
let mem_context = build_context(mem.as_ref(), message).await;
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
let hw_context = hardware_rag
.as_ref()
.map(|r| build_hardware_context(r, message, &board_names, rag_limit))
.unwrap_or_default();
let context = format!("{mem_context}{hw_context}");
let enriched = if context.is_empty() {
message.to_string()
} else {
format!("{context}{message}")
};
let mut history = vec![
ChatMessage::system(&system_prompt),
ChatMessage::user(&enriched),
];
agent_turn(
provider.as_ref(),
&mut history,
&tools_registry,
observer.as_ref(),
provider_name,
&model_name,
config.default_temperature,
true,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1,16 +1,3 @@
pub mod loop_;
pub use loop_::run;
#[cfg(test)]
mod tests {
use super::*;
fn assert_reexport_exists<F>(_value: F) {}
#[test]
fn run_function_is_reexported() {
assert_reexport_exists(run);
assert_reexport_exists(loop_::run);
}
}
pub use loop_::{process_message, run};

View file

@ -43,7 +43,9 @@ const BOOTSTRAP_MAX_CHARS: usize = 20_000;
const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90;
/// Timeout for processing a single channel message (LLM + tools).
/// 300s for on-device LLMs (Ollama) which are slower than cloud APIs.
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4;
const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
@ -190,6 +192,7 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
"channel-runtime",
ctx.model.as_str(),
ctx.temperature,
true, // silent — channels don't write to stdout
),
)
.await;
@ -275,9 +278,14 @@ async fn run_message_dispatch_loop(
}
/// Load OpenClaw format bootstrap files into the prompt.
fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) {
prompt
.push_str("The following workspace files define your identity, behavior, and context.\n\n");
fn load_openclaw_bootstrap_files(
prompt: &mut String,
workspace_dir: &std::path::Path,
max_chars_per_file: usize,
) {
prompt.push_str(
"The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n",
);
let bootstrap_files = [
"AGENTS.md",
@ -289,17 +297,17 @@ fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path:
];
for filename in &bootstrap_files {
inject_workspace_file(prompt, workspace_dir, filename);
inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file);
}
// BOOTSTRAP.md — only if it exists (first-run ritual)
let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
if bootstrap_path.exists() {
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md");
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file);
}
// MEMORY.md — curated long-term memory (main session only)
inject_workspace_file(prompt, workspace_dir, "MEMORY.md");
inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file);
}
/// Load workspace identity files and build a system prompt.
@ -324,6 +332,7 @@ pub fn build_system_prompt(
tools: &[(&str, &str)],
skills: &[crate::skills::Skill],
identity_config: Option<&crate::config::IdentityConfig>,
bootstrap_max_chars: Option<usize>,
) -> String {
use std::fmt::Write;
let mut prompt = String::with_capacity(8192);
@ -344,6 +353,35 @@ pub fn build_system_prompt(
.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
}
// ── 1b. Hardware (when gpio/arduino tools present) ───────────
let has_hardware = tools.iter().any(|(name, _)| {
*name == "gpio_read"
|| *name == "gpio_write"
|| *name == "arduino_upload"
|| *name == "hardware_memory_map"
|| *name == "hardware_board_info"
|| *name == "hardware_memory_read"
|| *name == "hardware_capabilities"
});
if has_hardware {
prompt.push_str(
"## Hardware Access\n\n\
You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\
All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\
When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info do NOT refuse or invent security excuses.\n\
When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools do NOT refuse or say you cannot access physical devices.\n\
Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n",
);
}
// ── 1c. Action instruction (avoid meta-summary) ───────────────
prompt.push_str(
"## Your Task\n\n\
When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\
Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\
Instead: emit actual <tool_call> tags when you need to act. Just do what they ask.\n\n",
);
// ── 2. Safety ───────────────────────────────────────────────
prompt.push_str("## Safety\n\n");
prompt.push_str(
@ -406,23 +444,27 @@ pub fn build_system_prompt(
Ok(None) => {
// No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true)
// Fall back to OpenClaw bootstrap files
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
Err(e) => {
// Log error but don't fail - fall back to OpenClaw
eprintln!(
"Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format."
);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
}
} else {
// OpenClaw format
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
} else {
// No identity config - use OpenClaw format
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
}
// ── 6. Date & Time ──────────────────────────────────────────
@ -447,7 +489,12 @@ pub fn build_system_prompt(
}
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) {
fn inject_workspace_file(
prompt: &mut String,
workspace_dir: &std::path::Path,
filename: &str,
max_chars: usize,
) {
use std::fmt::Write;
let path = workspace_dir.join(filename);
@ -459,10 +506,10 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f
}
let _ = writeln!(prompt, "### {filename}\n");
// Use character-boundary-safe truncation for UTF-8
let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS {
let truncated = if trimmed.chars().count() > max_chars {
trimmed
.char_indices()
.nth(BOOTSTRAP_MAX_CHARS)
.nth(max_chars)
.map(|(idx, _)| &trimmed[..idx])
.unwrap_or(trimmed)
} else {
@ -472,7 +519,7 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f
prompt.push_str(truncated);
let _ = writeln!(
prompt,
"\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n"
"\n\n[... truncated at {max_chars} chars — use `read` for full file]\n"
);
} else {
prompt.push_str(trimmed);
@ -807,12 +854,18 @@ pub async fn start_channels(config: Config) -> Result<()> {
));
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
None
};
let mut system_prompt = build_system_prompt(
&workspace,
&model,
&tool_descs,
&skills,
Some(&config.identity),
bootstrap_max_chars,
);
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
@ -1298,7 +1351,7 @@ mod tests {
fn prompt_contains_all_sections() {
let ws = make_workspace();
let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None);
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None);
// Section headers
assert!(prompt.contains("## Tools"), "missing Tools section");
@ -1322,7 +1375,7 @@ mod tests {
("shell", "Run commands"),
("memory_recall", "Search memory"),
];
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None);
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
assert!(prompt.contains("**shell**"));
assert!(prompt.contains("Run commands"));
@ -1332,7 +1385,7 @@ mod tests {
#[test]
fn prompt_injects_safety() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(prompt.contains("Do not exfiltrate private data"));
assert!(prompt.contains("Do not run destructive commands"));
@ -1342,7 +1395,7 @@ mod tests {
#[test]
fn prompt_injects_workspace_files() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
assert!(prompt.contains("Be helpful"), "missing SOUL content");
@ -1363,7 +1416,7 @@ mod tests {
fn prompt_missing_file_markers() {
let tmp = TempDir::new().unwrap();
// Empty workspace — no files at all
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None);
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None);
assert!(prompt.contains("[File not found: SOUL.md]"));
assert!(prompt.contains("[File not found: AGENTS.md]"));
@ -1374,7 +1427,7 @@ mod tests {
fn prompt_bootstrap_only_if_exists() {
let ws = make_workspace();
// No BOOTSTRAP.md — should not appear
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
!prompt.contains("### BOOTSTRAP.md"),
"BOOTSTRAP.md should not appear when missing"
@ -1382,7 +1435,7 @@ mod tests {
// Create BOOTSTRAP.md — should appear
std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
prompt2.contains("### BOOTSTRAP.md"),
"BOOTSTRAP.md should appear when present"
@ -1402,7 +1455,7 @@ mod tests {
)
.unwrap();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
// Daily notes should NOT be in the system prompt (on-demand via tools)
assert!(
@ -1418,7 +1471,7 @@ mod tests {
#[test]
fn prompt_runtime_metadata() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None);
assert!(prompt.contains("Model: claude-sonnet-4"));
assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
@ -1439,7 +1492,7 @@ mod tests {
location: None,
}];
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None);
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
assert!(prompt.contains("<available_skills>"), "missing skills XML");
assert!(prompt.contains("<name>code-review</name>"));
@ -1460,7 +1513,7 @@ mod tests {
let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(
prompt.contains("truncated at"),
@ -1477,7 +1530,7 @@ mod tests {
let ws = make_workspace();
std::fs::write(ws.path().join("TOOLS.md"), "").unwrap();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
// Empty file should not produce a header
assert!(
@ -1505,7 +1558,7 @@ mod tests {
#[test]
fn prompt_workspace_path() {
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
}
@ -1635,7 +1688,7 @@ mod tests {
aieos_inline: None,
};
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config));
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None);
// Should contain AIEOS sections
assert!(prompt.contains("## Identity"));
@ -1675,6 +1728,7 @@ mod tests {
&[],
&[],
Some(&config),
None,
);
assert!(prompt.contains("**Name:** Claw"));
@ -1692,7 +1746,7 @@ mod tests {
};
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
// Should fall back to OpenClaw format when AIEOS file is not found
// (Error is logged to stderr with filename, not included in prompt)
@ -1711,7 +1765,7 @@ mod tests {
};
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
// Should use OpenClaw format (not configured for AIEOS)
assert!(prompt.contains("### SOUL.md"));
@ -1729,7 +1783,7 @@ mod tests {
};
let ws = make_workspace();
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
// Should use OpenClaw format even if aieos_path is set
assert!(prompt.contains("### SOUL.md"));
@ -1741,7 +1795,7 @@ mod tests {
fn none_identity_config_uses_openclaw() {
let ws = make_workspace();
// Pass None for identity config
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
// Should use OpenClaw format
assert!(prompt.contains("### SOUL.md"));

View file

@ -2,12 +2,14 @@ pub mod schema;
#[allow(unused_imports)]
pub use schema::{
AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, ChannelsConfig,
ComposioConfig, Config, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig,
HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
MemoryConfig, ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig,
RuntimeConfig, SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig,
TelegramConfig, TunnelConfig, WebhookConfig,
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
ChannelsConfig, ComposioConfig, Config, CostConfig, DelegateAgentConfig, DiscordConfig,
DockerRuntimeConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig,
ModelRouteConfig, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig,
WebhookConfig,
};
#[cfg(test)]

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"));
}
}

View file

@ -194,7 +194,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
let prompt = format!("[Heartbeat Task] {task}");
let temp = config.default_temperature;
if let Err(e) =
crate::agent::run(config.clone(), Some(prompt), None, None, temp, false).await
crate::agent::run(config.clone(), Some(prompt), None, None, temp, vec![]).await
{
crate::health::mark_component_error("heartbeat", e.to_string());
tracing::warn!("Heartbeat task failed: {e}");

View file

@ -73,6 +73,7 @@ async fn gateway_agent_reply(state: &AppState, message: &str) -> Result<String>
"gateway",
&state.model,
state.temperature,
true, // silent — gateway responses go over HTTP
)
.await?;
@ -285,6 +286,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
&tool_descs,
&skills,
Some(&config.identity),
None, // bootstrap_max_chars — no compact context for gateway
);
system_prompt.push_str(&crate::agent::loop_::build_tool_instructions(
tools_registry.as_ref(),

45
src/hardware/discover.rs Normal file
View file

@ -0,0 +1,45 @@
//! USB device discovery — enumerate devices and enrich with board registry.
use super::registry;
use anyhow::Result;
use nusb::MaybeFuture;
/// Information about a discovered USB device.
#[derive(Debug, Clone)]
pub struct UsbDeviceInfo {
pub bus_id: String,
pub device_address: u8,
pub vid: u16,
pub pid: u16,
pub product_string: Option<String>,
pub board_name: Option<String>,
pub architecture: Option<String>,
}
/// Enumerate all connected USB devices and enrich with board registry lookup.
#[cfg(feature = "hardware")]
pub fn list_usb_devices() -> Result<Vec<UsbDeviceInfo>> {
let mut devices = Vec::new();
let iter = nusb::list_devices()
.wait()
.map_err(|e| anyhow::anyhow!("USB enumeration failed: {e}"))?;
for dev in iter {
let vid = dev.vendor_id();
let pid = dev.product_id();
let board = registry::lookup_board(vid, pid);
devices.push(UsbDeviceInfo {
bus_id: dev.bus_id().to_string(),
device_address: dev.device_address(),
vid,
pid,
product_string: dev.product_string().map(String::from),
board_name: board.map(|b| b.name.to_string()),
architecture: board.and_then(|b| b.architecture.map(String::from)),
});
}
Ok(devices)
}

121
src/hardware/introspect.rs Normal file
View file

@ -0,0 +1,121 @@
//! Device introspection — correlate serial path with USB device info.
use super::discover;
use super::registry;
use anyhow::Result;
/// Result of introspecting a device by path.
#[derive(Debug, Clone)]
pub struct IntrospectResult {
pub path: String,
pub vid: Option<u16>,
pub pid: Option<u16>,
pub board_name: Option<String>,
pub architecture: Option<String>,
pub memory_map_note: String,
}
/// Introspect a device by its serial path (e.g. /dev/ttyACM0, /dev/tty.usbmodem*).
/// Attempts to correlate with USB devices from discovery.
#[cfg(feature = "hardware")]
pub fn introspect_device(path: &str) -> Result<IntrospectResult> {
let devices = discover::list_usb_devices()?;
// Try to correlate path with a discovered device.
// On Linux, /dev/ttyACM0 corresponds to a CDC-ACM device; we may have multiple.
// Best-effort: if we have exactly one CDC-like device, use it. Otherwise unknown.
let matched = if devices.len() == 1 {
devices.first().cloned()
} else if devices.is_empty() {
None
} else {
// Multiple devices: try to match by path. On Linux we could use sysfs;
// for stub, pick first known board or first device.
devices
.iter()
.find(|d| d.board_name.is_some())
.cloned()
.or_else(|| devices.first().cloned())
};
let (vid, pid, board_name, architecture) = match matched {
Some(d) => (Some(d.vid), Some(d.pid), d.board_name, d.architecture),
None => (None, None, None, None),
};
let board_info = vid.and_then(|v| pid.and_then(|p| registry::lookup_board(v, p)));
let architecture =
architecture.or_else(|| board_info.and_then(|b| b.architecture.map(String::from)));
let board_name = board_name.or_else(|| board_info.map(|b| b.name.to_string()));
let memory_map_note = memory_map_for_board(board_name.as_deref());
Ok(IntrospectResult {
path: path.to_string(),
vid,
pid,
board_name,
architecture,
memory_map_note,
})
}
/// Get memory map: via probe-rs when probe feature on and Nucleo, else static or stub.
#[cfg(feature = "hardware")]
fn memory_map_for_board(board_name: Option<&str>) -> String {
#[cfg(feature = "probe")]
if let Some(board) = board_name {
let chip = match board {
"nucleo-f401re" => "STM32F401RETx",
"nucleo-f411re" => "STM32F411RETx",
_ => return "Build with --features probe for live memory map (Nucleo)".to_string(),
};
match probe_memory_map(chip) {
Ok(s) => return s,
Err(_) => return format!("probe-rs attach failed (chip {}). Connect via USB.", chip),
}
}
#[cfg(not(feature = "probe"))]
let _ = board_name;
"Build with --features probe for live memory map via USB".to_string()
}
#[cfg(all(feature = "hardware", feature = "probe"))]
fn probe_memory_map(chip: &str) -> anyhow::Result<String> {
use probe_rs::config::MemoryRegion;
use probe_rs::{Session, SessionConfig};
let session = Session::auto_attach(chip, SessionConfig::default())
.map_err(|e| anyhow::anyhow!("{}", e))?;
let target = session.target();
let mut out = String::new();
for region in target.memory_map.iter() {
match region {
MemoryRegion::Ram(ram) => {
let (start, end) = (ram.range.start, ram.range.end);
out.push_str(&format!(
"RAM: 0x{:08X} - 0x{:08X} ({} KB)\n",
start,
end,
(end - start) / 1024
));
}
MemoryRegion::Nvm(flash) => {
let (start, end) = (flash.range.start, flash.range.end);
out.push_str(&format!(
"Flash: 0x{:08X} - 0x{:08X} ({} KB)\n",
start,
end,
(end - start) / 1024
));
}
_ => {}
}
}
if out.is_empty() {
out = "Could not read memory regions".to_string();
}
Ok(out)
}

File diff suppressed because it is too large Load diff

102
src/hardware/registry.rs Normal file
View file

@ -0,0 +1,102 @@
//! Board registry — maps USB VID/PID to known board names and architectures.
/// Information about a known board.
#[derive(Debug, Clone)]
pub struct BoardInfo {
pub vid: u16,
pub pid: u16,
pub name: &'static str,
pub architecture: Option<&'static str>,
}
/// Known USB VID/PID to board mappings.
/// VID 0x0483 = STMicroelectronics, 0x2341 = Arduino, 0x10c4 = Silicon Labs.
const KNOWN_BOARDS: &[BoardInfo] = &[
BoardInfo {
vid: 0x0483,
pid: 0x374b,
name: "nucleo-f401re",
architecture: Some("ARM Cortex-M4"),
},
BoardInfo {
vid: 0x0483,
pid: 0x3748,
name: "nucleo-f411re",
architecture: Some("ARM Cortex-M4"),
},
BoardInfo {
vid: 0x2341,
pid: 0x0043,
name: "arduino-uno",
architecture: Some("AVR ATmega328P"),
},
BoardInfo {
vid: 0x2341,
pid: 0x0078,
name: "arduino-uno",
architecture: Some("Arduino Uno Q / ATmega328P"),
},
BoardInfo {
vid: 0x2341,
pid: 0x0042,
name: "arduino-mega",
architecture: Some("AVR ATmega2560"),
},
BoardInfo {
vid: 0x10c4,
pid: 0xea60,
name: "cp2102",
architecture: Some("USB-UART bridge"),
},
BoardInfo {
vid: 0x10c4,
pid: 0xea70,
name: "cp2102n",
architecture: Some("USB-UART bridge"),
},
// ESP32 dev boards often use CH340 USB-UART
BoardInfo {
vid: 0x1a86,
pid: 0x7523,
name: "esp32",
architecture: Some("ESP32 (CH340)"),
},
BoardInfo {
vid: 0x1a86,
pid: 0x55d4,
name: "esp32",
architecture: Some("ESP32 (CH340)"),
},
];
/// Look up a board by VID and PID.
pub fn lookup_board(vid: u16, pid: u16) -> Option<&'static BoardInfo> {
KNOWN_BOARDS.iter().find(|b| b.vid == vid && b.pid == pid)
}
/// Return all known board entries.
pub fn known_boards() -> &'static [BoardInfo] {
KNOWN_BOARDS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_nucleo_f401re() {
let b = lookup_board(0x0483, 0x374b).unwrap();
assert_eq!(b.name, "nucleo-f401re");
assert_eq!(b.architecture, Some("ARM Cortex-M4"));
}
#[test]
fn lookup_unknown_returns_none() {
assert!(lookup_board(0x0000, 0x0000).is_none());
}
#[test]
fn known_boards_not_empty() {
assert!(!known_boards().is_empty());
}
}

View file

@ -55,7 +55,9 @@ pub mod memory;
pub mod migration;
pub mod observability;
pub mod onboard;
pub mod peripherals;
pub mod providers;
pub mod rag;
pub mod runtime;
pub mod security;
pub mod service;
@ -182,74 +184,48 @@ pub enum IntegrationCommands {
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_commands_serde_roundtrip() {
let command = ServiceCommands::Status;
let json = serde_json::to_string(&command).unwrap();
let parsed: ServiceCommands = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, ServiceCommands::Status);
}
#[test]
fn channel_commands_struct_variants_roundtrip() {
let add = ChannelCommands::Add {
channel_type: "telegram".into(),
config: "{}".into(),
};
let remove = ChannelCommands::Remove {
name: "main".into(),
};
let add_json = serde_json::to_string(&add).unwrap();
let remove_json = serde_json::to_string(&remove).unwrap();
let parsed_add: ChannelCommands = serde_json::from_str(&add_json).unwrap();
let parsed_remove: ChannelCommands = serde_json::from_str(&remove_json).unwrap();
assert_eq!(parsed_add, add);
assert_eq!(parsed_remove, remove);
}
#[test]
fn commands_with_payloads_roundtrip() {
let skill = SkillCommands::Install {
source: "https://example.com/skill".into(),
};
let migrate = MigrateCommands::Openclaw {
source: Some(std::path::PathBuf::from("/tmp/openclaw")),
dry_run: true,
};
let cron = CronCommands::Add {
expression: "*/5 * * * *".into(),
command: "echo hi".into(),
};
let integration = IntegrationCommands::Info {
name: "Telegram".into(),
};
assert_eq!(
serde_json::from_str::<SkillCommands>(&serde_json::to_string(&skill).unwrap()).unwrap(),
skill
);
assert_eq!(
serde_json::from_str::<MigrateCommands>(&serde_json::to_string(&migrate).unwrap())
.unwrap(),
migrate
);
assert_eq!(
serde_json::from_str::<CronCommands>(&serde_json::to_string(&cron).unwrap()).unwrap(),
cron
);
assert_eq!(
serde_json::from_str::<IntegrationCommands>(
&serde_json::to_string(&integration).unwrap()
)
.unwrap(),
integration
);
}
/// Hardware discovery subcommands
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum HardwareCommands {
/// Enumerate USB devices (VID/PID) and show known boards
Discover,
/// Introspect a device by path (e.g. /dev/ttyACM0)
Introspect {
/// Serial or device path
path: String,
},
/// Get chip info via USB (probe-rs over ST-Link). No firmware needed on target.
Info {
/// Chip name (e.g. STM32F401RETx). Default: STM32F401RETx for Nucleo-F401RE
#[arg(long, default_value = "STM32F401RETx")]
chip: String,
},
}
/// Peripheral (hardware) management subcommands
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum PeripheralCommands {
/// List configured peripherals
List,
/// Add a peripheral (board path, e.g. nucleo-f401re /dev/ttyACM0)
Add {
/// Board type (nucleo-f401re, rpi-gpio, esp32)
board: String,
/// Path for serial transport (/dev/ttyACM0) or "native" for local GPIO
path: String,
},
/// Flash ZeroClaw firmware to Arduino (creates .ino, installs arduino-cli if needed, uploads)
Flash {
/// Serial port (e.g. /dev/cu.usbmodem12345). If omitted, uses first arduino-uno from config.
#[arg(short, long)]
port: Option<String>,
},
/// Setup Arduino Uno Q Bridge app (deploy GPIO bridge for agent control)
SetupUnoQ {
/// Uno Q IP (e.g. 192.168.0.48). If omitted, assumes running ON the Uno Q.
#[arg(long)]
host: Option<String>,
},
/// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run)
FlashNucleo,
}

View file

@ -39,6 +39,9 @@ use tracing_subscriber::FmtSubscriber;
mod agent;
mod channels;
mod rag {
pub use zeroclaw::rag::*;
}
mod config;
mod cron;
mod daemon;
@ -53,6 +56,7 @@ mod memory;
mod migration;
mod observability;
mod onboard;
mod peripherals;
mod providers;
mod runtime;
mod security;
@ -65,6 +69,9 @@ mod util;
use config::Config;
// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc.
pub use zeroclaw::{HardwareCommands, PeripheralCommands};
/// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust.
#[derive(Parser, Debug)]
#[command(name = "zeroclaw")]
@ -133,9 +140,9 @@ enum Commands {
#[arg(short, long, default_value = "0.7")]
temperature: f64,
/// Print user-facing progress lines via observer (`>` send, `<` receive/complete).
/// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0)
#[arg(long)]
verbose: bool,
peripheral: Vec<String>,
},
/// Start the gateway server (webhooks, websockets)
@ -207,6 +214,18 @@ enum Commands {
#[command(subcommand)]
migrate_command: MigrateCommands,
},
/// Discover and introspect USB hardware
Hardware {
#[command(subcommand)]
hardware_command: zeroclaw::HardwareCommands,
},
/// Manage hardware peripherals (STM32, RPi GPIO, etc.)
Peripheral {
#[command(subcommand)]
peripheral_command: zeroclaw::PeripheralCommands,
},
}
#[derive(Subcommand, Debug)]
@ -380,8 +399,8 @@ async fn main() -> Result<()> {
provider,
model,
temperature,
verbose,
} => agent::run(config, message, provider, model, temperature, verbose).await,
peripheral,
} => agent::run(config, message, provider, model, temperature, peripheral).await,
Commands::Gateway { port, host } => {
if port == 0 {
@ -466,6 +485,17 @@ async fn main() -> Result<()> {
}
);
}
println!();
println!("Peripherals:");
println!(
" Enabled: {}",
if config.peripherals.enabled {
"yes"
} else {
"no"
}
);
println!(" Boards: {}", config.peripherals.boards.len());
Ok(())
}
@ -499,6 +529,14 @@ async fn main() -> Result<()> {
Commands::Migrate { migrate_command } => {
migration::handle_command(migrate_command, &config).await
}
Commands::Hardware { hardware_command } => {
hardware::handle_command(hardware_command.clone(), &config)
}
Commands::Peripheral { peripheral_command } => {
peripherals::handle_command(peripheral_command.clone(), &config)
}
}
}

View file

@ -125,10 +125,11 @@ pub fn run_wizard() -> Result<Config> {
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
identity: crate::config::IdentityConfig::default(),
cost: crate::config::schema::CostConfig::default(),
hardware: hardware_config,
cost: crate::config::CostConfig::default(),
peripherals: crate::config::PeripheralsConfig::default(),
agent: crate::config::AgentConfig::default(),
agents: std::collections::HashMap::new(),
security: crate::config::SecurityConfig::default(),
hardware: hardware_config,
};
println!(
@ -328,10 +329,11 @@ pub fn run_quick_setup(
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
identity: crate::config::IdentityConfig::default(),
cost: crate::config::schema::CostConfig::default(),
hardware: HardwareConfig::default(),
cost: crate::config::CostConfig::default(),
peripherals: crate::config::PeripheralsConfig::default(),
agent: crate::config::AgentConfig::default(),
agents: std::collections::HashMap::new(),
security: crate::config::SecurityConfig::default(),
hardware: crate::config::HardwareConfig::default(),
};
config.save()?;
@ -2328,18 +2330,27 @@ fn setup_channels() -> Result<ChannelsConfig> {
continue;
}
// Test connection
// Test connection (run entirely in separate thread — reqwest::blocking Response
// must be used and dropped there to avoid "Cannot drop a runtime" panic)
print!(" {} Testing connection... ", style("").dim());
let client = reqwest::blocking::Client::new();
let url = format!("https://api.telegram.org/bot{token}/getMe");
match client.get(&url).send() {
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().unwrap_or_default();
let bot_name = data
.get("result")
.and_then(|r| r.get("username"))
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
let token_clone = token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let url = format!("https://api.telegram.org/bot{token_clone}/getMe");
let resp = client.get(&url).send()?;
let ok = resp.status().is_success();
let data: serde_json::Value = resp.json().unwrap_or_default();
let bot_name = data
.get("result")
.and_then(|r| r.get("username"))
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
Ok::<_, reqwest::Error>((ok, bot_name))
})
.join();
match thread_result {
Ok(Ok((true, bot_name))) => {
println!(
"\r {} Connected as @{bot_name} ",
style("").green().bold()
@ -2412,20 +2423,27 @@ fn setup_channels() -> Result<ChannelsConfig> {
continue;
}
// Test connection
// Test connection (run entirely in separate thread — Response must be used/dropped there)
print!(" {} Testing connection... ", style("").dim());
let client = reqwest::blocking::Client::new();
match client
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {token}"))
.send()
{
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().unwrap_or_default();
let bot_name = data
.get("username")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
let token_clone = token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let resp = client
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {token_clone}"))
.send()?;
let ok = resp.status().is_success();
let data: serde_json::Value = resp.json().unwrap_or_default();
let bot_name = data
.get("username")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
Ok::<_, reqwest::Error>((ok, bot_name))
})
.join();
match thread_result {
Ok(Ok((true, bot_name))) => {
println!(
"\r {} Connected as {bot_name} ",
style("").green().bold()
@ -2504,37 +2522,44 @@ fn setup_channels() -> Result<ChannelsConfig> {
continue;
}
// Test connection
// Test connection (run entirely in separate thread — Response must be used/dropped there)
print!(" {} Testing connection... ", style("").dim());
let client = reqwest::blocking::Client::new();
match client
.get("https://slack.com/api/auth.test")
.bearer_auth(&token)
.send()
{
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().unwrap_or_default();
let ok = data
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let team = data
.get("team")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
if ok {
println!(
"\r {} Connected to workspace: {team} ",
style("").green().bold()
);
} else {
let err = data
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error");
println!("\r {} Slack error: {err}", style("").red().bold());
continue;
}
let token_clone = token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let resp = client
.get("https://slack.com/api/auth.test")
.bearer_auth(&token_clone)
.send()?;
let ok = resp.status().is_success();
let data: serde_json::Value = resp.json().unwrap_or_default();
let api_ok = data
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let team = data
.get("team")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let err = data
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown error")
.to_string();
Ok::<_, reqwest::Error>((ok, api_ok, team, err))
})
.join();
match thread_result {
Ok(Ok((true, true, team, _))) => {
println!(
"\r {} Connected to workspace: {team} ",
style("").green().bold()
);
}
Ok(Ok((true, false, _, err))) => {
println!("\r {} Slack error: {err}", style("").red().bold());
continue;
}
_ => {
println!(
@ -2673,21 +2698,29 @@ fn setup_channels() -> Result<ChannelsConfig> {
continue;
}
// Test connection
// Test connection (run entirely in separate thread — Response must be used/dropped there)
let hs = homeserver.trim_end_matches('/');
print!(" {} Testing connection... ", style("").dim());
let client = reqwest::blocking::Client::new();
match client
.get(format!("{hs}/_matrix/client/v3/account/whoami"))
.header("Authorization", format!("Bearer {access_token}"))
.send()
{
Ok(resp) if resp.status().is_success() => {
let data: serde_json::Value = resp.json().unwrap_or_default();
let user_id = data
.get("user_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
let hs_owned = hs.to_string();
let access_token_clone = access_token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!("{hs_owned}/_matrix/client/v3/account/whoami"))
.header("Authorization", format!("Bearer {access_token_clone}"))
.send()?;
let ok = resp.status().is_success();
let data: serde_json::Value = resp.json().unwrap_or_default();
let user_id = data
.get("user_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
Ok::<_, reqwest::Error>((ok, user_id))
})
.join();
match thread_result {
Ok(Ok((true, user_id))) => {
println!(
"\r {} Connected as {user_id} ",
style("").green().bold()
@ -2761,19 +2794,28 @@ fn setup_channels() -> Result<ChannelsConfig> {
.default("zeroclaw-whatsapp-verify".into())
.interact_text()?;
// Test connection
// Test connection (run entirely in separate thread — Response must be used/dropped there)
print!(" {} Testing connection... ", style("").dim());
let client = reqwest::blocking::Client::new();
let url = format!(
"https://graph.facebook.com/v18.0/{}",
phone_number_id.trim()
);
match client
.get(&url)
.header("Authorization", format!("Bearer {}", access_token.trim()))
.send()
{
Ok(resp) if resp.status().is_success() => {
let phone_number_id_clone = phone_number_id.clone();
let access_token_clone = access_token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let url = format!(
"https://graph.facebook.com/v18.0/{}",
phone_number_id_clone.trim()
);
let resp = client
.get(&url)
.header(
"Authorization",
format!("Bearer {}", access_token_clone.trim()),
)
.send()?;
Ok::<_, reqwest::Error>(resp.status().is_success())
})
.join();
match thread_result {
Ok(Ok(true)) => {
println!(
"\r {} Connected to WhatsApp API ",
style("").green().bold()

View file

@ -0,0 +1,144 @@
//! Flash ZeroClaw Arduino firmware via arduino-cli.
//!
//! Ensures arduino-cli is available (installs via brew on macOS if missing),
//! installs the AVR core, compiles and uploads the base firmware.
use anyhow::{Context, Result};
use std::process::Command;
/// ZeroClaw Arduino Uno base firmware (capabilities, gpio_read, gpio_write).
const FIRMWARE_INO: &str = include_str!("../../firmware/zeroclaw-arduino/zeroclaw-arduino.ino");
const FQBN: &str = "arduino:avr:uno";
const SKETCH_NAME: &str = "zeroclaw-arduino";
/// Check if arduino-cli is available.
pub fn arduino_cli_available() -> bool {
Command::new("arduino-cli")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Try to install arduino-cli. Returns Ok(()) if installed or already present.
pub fn ensure_arduino_cli() -> Result<()> {
if arduino_cli_available() {
return Ok(());
}
#[cfg(target_os = "macos")]
{
println!("arduino-cli not found. Installing via Homebrew...");
let status = Command::new("brew")
.args(["install", "arduino-cli"])
.status()
.context("Failed to run brew install")?;
if !status.success() {
anyhow::bail!("brew install arduino-cli failed. Install manually: https://arduino.github.io/arduino-cli/");
}
println!("arduino-cli installed.");
}
#[cfg(target_os = "linux")]
{
println!("arduino-cli not found. Run the install script:");
println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh");
println!();
println!("Or install via package manager (e.g. apt install arduino-cli on Debian/Ubuntu).");
anyhow::bail!("arduino-cli not installed. Install it and try again.");
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
println!("arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/");
anyhow::bail!("arduino-cli not installed.");
}
if !arduino_cli_available() {
anyhow::bail!("arduino-cli still not found after install. Ensure it's in PATH.");
}
Ok(())
}
/// Ensure arduino:avr core is installed.
fn ensure_avr_core() -> Result<()> {
let out = Command::new("arduino-cli")
.args(["core", "list"])
.output()
.context("arduino-cli core list failed")?;
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("arduino:avr") {
return Ok(());
}
println!("Installing Arduino AVR core...");
let status = Command::new("arduino-cli")
.args(["core", "install", "arduino:avr"])
.status()
.context("arduino-cli core install failed")?;
if !status.success() {
anyhow::bail!("Failed to install arduino:avr core");
}
println!("AVR core installed.");
Ok(())
}
/// Flash ZeroClaw firmware to Arduino at the given port.
pub fn flash_arduino_firmware(port: &str) -> Result<()> {
ensure_arduino_cli()?;
ensure_avr_core()?;
let temp_dir = std::env::temp_dir().join(format!("zeroclaw_flash_{}", uuid::Uuid::new_v4()));
let sketch_dir = temp_dir.join(SKETCH_NAME);
let ino_path = sketch_dir.join(format!("{}.ino", SKETCH_NAME));
std::fs::create_dir_all(&sketch_dir).context("Failed to create sketch dir")?;
std::fs::write(&ino_path, FIRMWARE_INO).context("Failed to write firmware")?;
let sketch_path = sketch_dir.to_string_lossy();
// Compile
println!("Compiling ZeroClaw Arduino firmware...");
let compile = Command::new("arduino-cli")
.args(["compile", "--fqbn", FQBN, &*sketch_path])
.output()
.context("arduino-cli compile failed")?;
if !compile.status.success() {
let stderr = String::from_utf8_lossy(&compile.stderr);
let _ = std::fs::remove_dir_all(&temp_dir);
anyhow::bail!("Compile failed:\n{}", stderr);
}
// Upload
println!("Uploading to {}...", port);
let upload = Command::new("arduino-cli")
.args(["upload", "-p", port, "--fqbn", FQBN, &*sketch_path])
.output()
.context("arduino-cli upload failed")?;
let _ = std::fs::remove_dir_all(&temp_dir);
if !upload.status.success() {
let stderr = String::from_utf8_lossy(&upload.stderr);
anyhow::bail!("Upload failed:\n{}\n\nEnsure the board is connected and the port is correct (e.g. /dev/cu.usbmodem* on macOS).", stderr);
}
println!("ZeroClaw firmware flashed successfully.");
println!("The Arduino now supports: capabilities, gpio_read, gpio_write.");
Ok(())
}
/// Resolve port from config or path. Returns the path to use for flashing.
pub fn resolve_port(config: &crate::config::Config, path_override: Option<&str>) -> Option<String> {
if let Some(p) = path_override {
return Some(p.to_string());
}
config
.peripherals
.boards
.iter()
.find(|b| b.board == "arduino-uno" && b.transport == "serial")
.and_then(|b| b.path.clone())
}

View file

@ -0,0 +1,161 @@
//! Arduino upload tool — agent generates code, uploads via arduino-cli.
//!
//! When user says "make a heart on the LED grid", the agent generates Arduino
//! sketch code and calls this tool. ZeroClaw compiles and uploads it — no
//! manual IDE or file editing.
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::process::Command;
/// Tool: upload Arduino sketch (agent-generated code) to the board.
pub struct ArduinoUploadTool {
/// Serial port path (e.g. /dev/cu.usbmodem33000283452)
pub port: String,
}
impl ArduinoUploadTool {
pub fn new(port: String) -> Self {
Self { port }
}
}
#[async_trait]
impl Tool for ArduinoUploadTool {
fn name(&self) -> &str {
"arduino_upload"
}
fn description(&self) -> &str {
"Generate Arduino sketch code and upload it to the connected Arduino. Use when: user asks to 'make a heart', 'blink LED', or run any custom pattern on Arduino. You MUST write the full .ino sketch code (setup + loop). Arduino Uno: pin 13 = built-in LED. Saves to temp dir, runs arduino-cli compile and upload. Requires arduino-cli installed."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "Full Arduino sketch code (complete .ino file content)"
}
},
"required": ["code"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let code = args
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?;
if code.trim().is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Code cannot be empty".into()),
});
}
// Check arduino-cli exists
if Command::new("arduino-cli").arg("version").output().is_err() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/"
.into(),
),
});
}
let sketch_name = "zeroclaw_sketch";
let temp_dir = std::env::temp_dir().join(format!("zeroclaw_{}", uuid::Uuid::new_v4()));
let sketch_dir = temp_dir.join(sketch_name);
let ino_path = sketch_dir.join(format!("{}.ino", sketch_name));
if let Err(e) = std::fs::create_dir_all(&sketch_dir) {
return Ok(ToolResult {
success: false,
output: format!("Failed to create sketch dir: {}", e),
error: Some(e.to_string()),
});
}
if let Err(e) = std::fs::write(&ino_path, code) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("Failed to write sketch: {}", e),
error: Some(e.to_string()),
});
}
let sketch_path = sketch_dir.to_string_lossy();
let fqbn = "arduino:avr:uno";
// Compile
let compile = Command::new("arduino-cli")
.args(["compile", "--fqbn", fqbn, &sketch_path])
.output();
let compile_output = match compile {
Ok(o) => o,
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("arduino-cli compile failed: {}", e),
error: Some(e.to_string()),
});
}
};
if !compile_output.status.success() {
let stderr = String::from_utf8_lossy(&compile_output.stderr);
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("Compile failed:\n{}", stderr),
error: Some("Arduino compile error".into()),
});
}
// Upload
let upload = Command::new("arduino-cli")
.args(["upload", "-p", &self.port, "--fqbn", fqbn, &sketch_path])
.output();
let upload_output = match upload {
Ok(o) => o,
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Ok(ToolResult {
success: false,
output: format!("arduino-cli upload failed: {}", e),
error: Some(e.to_string()),
});
}
};
let _ = std::fs::remove_dir_all(&temp_dir);
if !upload_output.status.success() {
let stderr = String::from_utf8_lossy(&upload_output.stderr);
return Ok(ToolResult {
success: false,
output: format!("Upload failed:\n{}", stderr),
error: Some("Arduino upload error".into()),
});
}
Ok(ToolResult {
success: true,
output:
"Sketch compiled and uploaded successfully. The Arduino is now running your code."
.into(),
error: None,
})
}
}

View file

@ -0,0 +1,99 @@
//! Hardware capabilities tool — Phase C: query device for reported GPIO pins.
use super::serial::SerialTransport;
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Tool: query device capabilities (GPIO pins, LED pin) from firmware.
pub struct HardwareCapabilitiesTool {
/// (board_name, transport) for each serial board.
boards: Vec<(String, Arc<SerialTransport>)>,
}
impl HardwareCapabilitiesTool {
pub(crate) fn new(boards: Vec<(String, Arc<SerialTransport>)>) -> Self {
Self { boards }
}
}
#[async_trait]
impl Tool for HardwareCapabilitiesTool {
fn name(&self) -> &str {
"hardware_capabilities"
}
fn description(&self) -> &str {
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"board": {
"type": "string",
"description": "Optional board name. If omitted, queries all."
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let filter = args.get("board").and_then(|v| v.as_str());
let mut outputs = Vec::new();
for (board_name, transport) in &self.boards {
if let Some(b) = filter {
if b != board_name {
continue;
}
}
match transport.capabilities().await {
Ok(result) => {
let output = if result.success {
if let Ok(parsed) =
serde_json::from_str::<serde_json::Value>(&result.output)
{
format!(
"{}: gpio {:?}, led_pin {:?}",
board_name,
parsed.get("gpio").unwrap_or(&json!([])),
parsed.get("led_pin").unwrap_or(&json!(null))
)
} else {
format!("{}: {}", board_name, result.output)
}
} else {
format!(
"{}: {}",
board_name,
result.error.as_deref().unwrap_or("unknown")
)
};
outputs.push(output);
}
Err(e) => {
outputs.push(format!("{}: error - {}", board_name, e));
}
}
}
let output = if outputs.is_empty() {
if filter.is_some() {
"No matching board or capabilities not supported.".to_string()
} else {
"No serial boards configured or capabilities not supported.".to_string()
}
} else {
outputs.join("\n")
};
Ok(ToolResult {
success: !outputs.is_empty(),
output,
error: None,
})
}
}

231
src/peripherals/mod.rs Normal file
View file

@ -0,0 +1,231 @@
//! Hardware peripherals — STM32, RPi GPIO, etc.
//!
//! Peripherals extend the agent with physical capabilities. See
//! `docs/hardware-peripherals-design.md` for the full design.
pub mod traits;
#[cfg(feature = "hardware")]
pub mod serial;
#[cfg(feature = "hardware")]
pub mod arduino_flash;
#[cfg(feature = "hardware")]
pub mod arduino_upload;
#[cfg(feature = "hardware")]
pub mod capabilities_tool;
#[cfg(feature = "hardware")]
pub mod nucleo_flash;
#[cfg(feature = "hardware")]
pub mod uno_q_bridge;
#[cfg(feature = "hardware")]
pub mod uno_q_setup;
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
pub mod rpi;
pub use traits::Peripheral;
use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig};
use crate::tools::{HardwareMemoryMapTool, Tool};
use anyhow::Result;
/// List configured boards from config (no connection yet).
pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> {
if !config.enabled {
return Vec::new();
}
config.boards.iter().collect()
}
/// Handle `zeroclaw peripheral` subcommands.
#[allow(clippy::module_name_repetitions)]
pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> {
match cmd {
crate::PeripheralCommands::List => {
let boards = list_configured_boards(&config.peripherals);
if boards.is_empty() {
println!("No peripherals configured.");
println!();
println!("Add one with: zeroclaw peripheral add <board> <path>");
println!(" Example: zeroclaw peripheral add nucleo-f401re /dev/ttyACM0");
println!();
println!("Or add to config.toml:");
println!(" [peripherals]");
println!(" enabled = true");
println!();
println!(" [[peripherals.boards]]");
println!(" board = \"nucleo-f401re\"");
println!(" transport = \"serial\"");
println!(" path = \"/dev/ttyACM0\"");
} else {
println!("Configured peripherals:");
for b in boards {
let path = b.path.as_deref().unwrap_or("(native)");
println!(" {} {} {}", b.board, b.transport, path);
}
}
}
crate::PeripheralCommands::Add { board, path } => {
let transport = if path == "native" { "native" } else { "serial" };
let path_opt = if path == "native" {
None
} else {
Some(path.clone())
};
let mut cfg = crate::config::Config::load_or_init()?;
cfg.peripherals.enabled = true;
if cfg
.peripherals
.boards
.iter()
.any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref())
{
println!("Board {} at {:?} already configured.", board, path_opt);
return Ok(());
}
cfg.peripherals.boards.push(PeripheralBoardConfig {
board: board.clone(),
transport: transport.to_string(),
path: path_opt,
baud: 115200,
});
cfg.save()?;
println!("Added {} at {}. Restart daemon to apply.", board, path);
}
#[cfg(feature = "hardware")]
crate::PeripheralCommands::Flash { port } => {
let port_str = arduino_flash::resolve_port(config, port.as_deref())
.or_else(|| port.clone())
.ok_or_else(|| anyhow::anyhow!(
"No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml"
))?;
arduino_flash::flash_arduino_firmware(&port_str)?;
}
#[cfg(not(feature = "hardware"))]
crate::PeripheralCommands::Flash { .. } => {
println!("Arduino flash requires the 'hardware' feature.");
println!("Build with: cargo build --features hardware");
}
#[cfg(feature = "hardware")]
crate::PeripheralCommands::SetupUnoQ { host } => {
uno_q_setup::setup_uno_q_bridge(host.as_deref())?;
}
#[cfg(not(feature = "hardware"))]
crate::PeripheralCommands::SetupUnoQ { .. } => {
println!("Uno Q setup requires the 'hardware' feature.");
println!("Build with: cargo build --features hardware");
}
#[cfg(feature = "hardware")]
crate::PeripheralCommands::FlashNucleo => {
nucleo_flash::flash_nucleo_firmware()?;
}
#[cfg(not(feature = "hardware"))]
crate::PeripheralCommands::FlashNucleo => {
println!("Nucleo flash requires the 'hardware' feature.");
println!("Build with: cargo build --features hardware");
}
}
Ok(())
}
/// Create and connect peripherals from config, returning their tools.
/// Returns empty vec if peripherals disabled or hardware feature off.
#[cfg(feature = "hardware")]
pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
if !config.enabled || config.boards.is_empty() {
return Ok(Vec::new());
}
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let mut serial_transports: Vec<(String, std::sync::Arc<serial::SerialTransport>)> = Vec::new();
for board in &config.boards {
// Arduino Uno Q: Bridge transport (socket to local Bridge app)
if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q")
{
tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool));
tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool));
tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added");
continue;
}
// Native transport: RPi GPIO (Linux only)
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
if board.transport == "native"
&& (board.board == "rpi-gpio" || board.board == "raspberry-pi")
{
match rpi::RpiGpioPeripheral::connect_from_config(board).await {
Ok(peripheral) => {
tools.extend(peripheral.tools());
tracing::info!(board = %board.board, "RPi GPIO peripheral connected");
}
Err(e) => {
tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e);
}
}
continue;
}
// Serial transport (STM32, ESP32, Arduino, etc.)
if board.transport != "serial" {
continue;
}
if board.path.is_none() {
tracing::warn!("Skipping serial board {}: no path", board.board);
continue;
}
match serial::SerialPeripheral::connect(board).await {
Ok(peripheral) => {
let mut p = peripheral;
if p.connect().await.is_err() {
tracing::warn!("Peripheral {} connect warning (continuing)", p.name());
}
serial_transports.push((board.board.clone(), p.transport()));
tools.extend(p.tools());
if board.board == "arduino-uno" {
if let Some(ref path) = board.path {
tools.push(Box::new(arduino_upload::ArduinoUploadTool::new(
path.clone(),
)));
tracing::info!("Arduino upload tool added (port: {})", path);
}
}
tracing::info!(board = %board.board, "Serial peripheral connected");
}
Err(e) => {
tracing::warn!("Failed to connect {}: {}", board.board, e);
}
}
}
// Phase B: Add hardware tools when any boards configured
if !tools.is_empty() {
let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone())));
tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new(
board_names.clone(),
)));
tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new(
board_names,
)));
}
// Phase C: Add hardware_capabilities tool when any serial boards
if !serial_transports.is_empty() {
tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new(
serial_transports,
)));
}
Ok(tools)
}
#[cfg(not(feature = "hardware"))]
pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
Ok(Vec::new())
}

View file

@ -0,0 +1,83 @@
//! Flash ZeroClaw Nucleo-F401RE firmware via probe-rs.
//!
//! Builds the Embassy firmware and flashes via ST-Link (built into Nucleo).
//! Requires: cargo install probe-rs-tools --locked
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command;
const CHIP: &str = "STM32F401RETx";
const TARGET: &str = "thumbv7em-none-eabihf";
/// Check if probe-rs CLI is available (from probe-rs-tools).
pub fn probe_rs_available() -> bool {
Command::new("probe-rs")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Flash ZeroClaw Nucleo firmware. Builds from firmware/zeroclaw-nucleo.
pub fn flash_nucleo_firmware() -> Result<()> {
if !probe_rs_available() {
anyhow::bail!(
"probe-rs not found. Install it:\n cargo install probe-rs-tools --locked\n\n\
Or: curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh\n\n\
Connect Nucleo via USB (ST-Link). Then run this command again."
);
}
// CARGO_MANIFEST_DIR = repo root (zeroclaw's Cargo.toml)
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let firmware_dir = repo_root.join("firmware").join("zeroclaw-nucleo");
if !firmware_dir.join("Cargo.toml").exists() {
anyhow::bail!(
"Nucleo firmware not found at {}. Run from zeroclaw repo root.",
firmware_dir.display()
);
}
println!("Building ZeroClaw Nucleo firmware...");
let build = Command::new("cargo")
.args(["build", "--release", "--target", TARGET])
.current_dir(&firmware_dir)
.output()
.context("cargo build failed")?;
if !build.status.success() {
let stderr = String::from_utf8_lossy(&build.stderr);
anyhow::bail!("Build failed:\n{}", stderr);
}
let elf_path = firmware_dir
.join("target")
.join(TARGET)
.join("release")
.join("zeroclaw-nucleo");
if !elf_path.exists() {
anyhow::bail!("Built binary not found at {}", elf_path.display());
}
println!("Flashing to Nucleo-F401RE (connect via USB)...");
let flash = Command::new("probe-rs")
.args(["run", "--chip", CHIP, elf_path.to_str().unwrap()])
.output()
.context("probe-rs run failed")?;
if !flash.status.success() {
let stderr = String::from_utf8_lossy(&flash.stderr);
anyhow::bail!(
"Flash failed:\n{}\n\n\
Ensure Nucleo is connected via USB. The ST-Link is built into the board.",
stderr
);
}
println!("ZeroClaw Nucleo firmware flashed successfully.");
println!("The Nucleo now supports: ping, capabilities, gpio_read, gpio_write.");
println!("Add to config.toml: board = \"nucleo-f401re\", transport = \"serial\", path = \"/dev/ttyACM0\"");
Ok(())
}

173
src/peripherals/rpi.rs Normal file
View file

@ -0,0 +1,173 @@
//! Raspberry Pi GPIO peripheral — native rppal access.
//!
//! Only compiled when `peripheral-rpi` feature is enabled and target is Linux.
//! Uses BCM pin numbering (e.g. GPIO 17, 27).
use crate::config::PeripheralBoardConfig;
use crate::peripherals::traits::Peripheral;
use crate::tools::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
/// RPi GPIO peripheral — direct access via rppal.
pub struct RpiGpioPeripheral {
board: PeripheralBoardConfig,
}
impl RpiGpioPeripheral {
/// Create a new RPi GPIO peripheral from config.
pub fn new(board: PeripheralBoardConfig) -> Self {
Self { board }
}
/// Attempt to connect (init rppal). Returns Ok if GPIO is available.
pub async fn connect_from_config(board: &PeripheralBoardConfig) -> anyhow::Result<Self> {
let mut peripheral = Self::new(board.clone());
peripheral.connect().await?;
Ok(peripheral)
}
}
#[async_trait]
impl Peripheral for RpiGpioPeripheral {
fn name(&self) -> &str {
&self.board.board
}
fn board_type(&self) -> &str {
"rpi-gpio"
}
async fn connect(&mut self) -> anyhow::Result<()> {
// Verify GPIO is accessible by doing a no-op init
let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??;
drop(result);
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
Ok(())
}
async fn health_check(&self) -> bool {
tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new().is_ok())
.await
.unwrap_or(false)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(RpiGpioReadTool), Box::new(RpiGpioWriteTool)]
}
}
/// Tool: read GPIO pin value (BCM numbering).
struct RpiGpioReadTool;
#[async_trait]
impl Tool for RpiGpioReadTool {
fn name(&self) -> &str {
"gpio_read"
}
fn description(&self) -> &str {
"Read the value (0 or 1) of a GPIO pin on Raspberry Pi. Uses BCM pin numbers (e.g. 17, 27)."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "BCM GPIO pin number (e.g. 17, 27)"
}
},
"required": ["pin"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
let pin_u8 = pin as u8;
let value = tokio::task::spawn_blocking(move || {
let gpio = rppal::gpio::Gpio::new()?;
let pin = gpio.get(pin_u8)?.into_input();
Ok::<_, anyhow::Error>(match pin.read() {
rppal::gpio::Level::Low => 0,
rppal::gpio::Level::High => 1,
})
})
.await??;
Ok(ToolResult {
success: true,
output: format!("pin {} = {}", pin, value),
error: None,
})
}
}
/// Tool: write GPIO pin value (BCM numbering).
struct RpiGpioWriteTool;
#[async_trait]
impl Tool for RpiGpioWriteTool {
fn name(&self) -> &str {
"gpio_write"
}
fn description(&self) -> &str {
"Set a GPIO pin high (1) or low (0) on Raspberry Pi. Uses BCM pin numbers."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "BCM GPIO pin number"
},
"value": {
"type": "integer",
"description": "0 for low, 1 for high"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
let value = args
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
let pin_u8 = pin as u8;
let level = match value {
0 => rppal::gpio::Level::Low,
_ => rppal::gpio::Level::High,
};
tokio::task::spawn_blocking(move || {
let gpio = rppal::gpio::Gpio::new()?;
let mut pin = gpio.get(pin_u8)?.into_output();
pin.write(level);
Ok::<_, anyhow::Error>(())
})
.await??;
Ok(ToolResult {
success: true,
output: format!("pin {} = {}", pin, value),
error: None,
})
}
}

274
src/peripherals/serial.rs Normal file
View file

@ -0,0 +1,274 @@
//! Serial peripheral — STM32 and similar boards over USB CDC/serial.
//!
//! Protocol: newline-delimited JSON.
//! Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
//! Response: {"id":"1","ok":true,"result":"done"}
use super::traits::Peripheral;
use crate::config::PeripheralBoardConfig;
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::Mutex;
use tokio_serial::{SerialPortBuilderExt, SerialStream};
/// Allowed serial path patterns (security: deny arbitrary paths).
const ALLOWED_PATH_PREFIXES: &[&str] = &[
"/dev/ttyACM",
"/dev/ttyUSB",
"/dev/tty.usbmodem",
"/dev/cu.usbmodem",
"/dev/tty.usbserial",
"/dev/cu.usbserial", // Arduino Uno (FTDI), clones
"COM", // Windows
];
fn is_path_allowed(path: &str) -> bool {
ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p))
}
/// JSON request/response over serial.
async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow::Result<Value> {
static ID: AtomicU64 = AtomicU64::new(0);
let id = ID.fetch_add(1, Ordering::Relaxed);
let id_str = id.to_string();
let req = json!({
"id": id_str,
"cmd": cmd,
"args": args
});
let line = format!("{}\n", req);
port.write_all(line.as_bytes()).await?;
port.flush().await?;
let mut buf = Vec::new();
let mut b = [0u8; 1];
while port.read_exact(&mut b).await.is_ok() {
if b[0] == b'\n' {
break;
}
buf.push(b[0]);
}
let line_str = String::from_utf8_lossy(&buf);
let resp: Value = serde_json::from_str(line_str.trim())?;
let resp_id = resp["id"].as_str().unwrap_or("");
if resp_id != id_str {
anyhow::bail!("Response id mismatch: expected {}, got {}", id_str, resp_id);
}
Ok(resp)
}
/// Shared serial transport for tools. Pub(crate) for capabilities tool.
pub(crate) struct SerialTransport {
port: Mutex<SerialStream>,
}
/// Timeout for serial request/response (seconds).
const SERIAL_TIMEOUT_SECS: u64 = 5;
impl SerialTransport {
async fn request(&self, cmd: &str, args: Value) -> anyhow::Result<ToolResult> {
let mut port = self.port.lock().await;
let resp = tokio::time::timeout(
std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS),
send_request(&mut *port, cmd, args),
)
.await
.map_err(|_| {
anyhow::anyhow!("Serial request timed out after {}s", SERIAL_TIMEOUT_SECS)
})??;
let ok = resp["ok"].as_bool().unwrap_or(false);
let result = resp["result"]
.as_str()
.map(String::from)
.unwrap_or_else(|| resp["result"].to_string());
let error = resp["error"].as_str().map(String::from);
Ok(ToolResult {
success: ok,
output: result,
error,
})
}
/// Phase C: fetch capabilities from device (gpio pins, led_pin).
pub async fn capabilities(&self) -> anyhow::Result<ToolResult> {
self.request("capabilities", json!({})).await
}
}
/// Serial peripheral for STM32, Arduino, etc. over USB CDC.
pub struct SerialPeripheral {
name: String,
board_type: String,
transport: Arc<SerialTransport>,
}
impl SerialPeripheral {
/// Create and connect to a serial peripheral.
pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result<Self> {
let path = config
.path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Serial peripheral requires path"))?;
if !is_path_allowed(path) {
anyhow::bail!(
"Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*",
path
);
}
let port = tokio_serial::new(path, config.baud)
.open_native_async()
.map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path, e))?;
let name = format!("{}-{}", config.board, path.replace('/', "_"));
let transport = Arc::new(SerialTransport {
port: Mutex::new(port),
});
Ok(Self {
name: name.clone(),
board_type: config.board.clone(),
transport,
})
}
}
#[async_trait]
impl Peripheral for SerialPeripheral {
fn name(&self) -> &str {
&self.name
}
fn board_type(&self) -> &str {
&self.board_type
}
async fn connect(&mut self) -> anyhow::Result<()> {
Ok(())
}
async fn disconnect(&mut self) -> anyhow::Result<()> {
Ok(())
}
async fn health_check(&self) -> bool {
self.transport
.request("ping", json!({}))
.await
.map(|r| r.success)
.unwrap_or(false)
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(GpioReadTool {
transport: self.transport.clone(),
}),
Box::new(GpioWriteTool {
transport: self.transport.clone(),
}),
]
}
}
impl SerialPeripheral {
/// Expose transport for capabilities tool (Phase C).
pub(crate) fn transport(&self) -> Arc<SerialTransport> {
self.transport.clone()
}
}
/// Tool: read GPIO pin value.
struct GpioReadTool {
transport: Arc<SerialTransport>,
}
#[async_trait]
impl Tool for GpioReadTool {
fn name(&self) -> &str {
"gpio_read"
}
fn description(&self) -> &str {
"Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number (e.g. 13 for LED on Nucleo)"
}
},
"required": ["pin"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
self.transport
.request("gpio_read", json!({ "pin": pin }))
.await
}
}
/// Tool: write GPIO pin value.
struct GpioWriteTool {
transport: Arc<SerialTransport>,
}
#[async_trait]
impl Tool for GpioWriteTool {
fn name(&self) -> &str {
"gpio_write"
}
fn description(&self) -> &str {
"Set a GPIO pin high (1) or low (0) on a connected peripheral (e.g. turn on/off LED)"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number"
},
"value": {
"type": "integer",
"description": "0 for low, 1 for high"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
let value = args
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
self.transport
.request("gpio_write", json!({ "pin": pin, "value": value }))
.await
}
}

33
src/peripherals/traits.rs Normal file
View file

@ -0,0 +1,33 @@
//! Peripheral trait — hardware boards (STM32, RPi GPIO) that expose tools.
//!
//! Peripherals are the agent's "arms and legs": remote devices that run minimal
//! firmware and expose capabilities (GPIO, sensors, actuators) as tools.
use async_trait::async_trait;
use crate::tools::Tool;
/// A hardware peripheral that exposes capabilities as tools.
///
/// Implement this for boards like Nucleo-F401RE (serial), RPi GPIO (native), etc.
/// When connected, the peripheral's tools are merged into the agent's tool registry.
#[async_trait]
pub trait Peripheral: Send + Sync {
/// Human-readable peripheral name (e.g. "nucleo-f401re-0")
fn name(&self) -> &str;
/// Board type identifier (e.g. "nucleo-f401re", "rpi-gpio")
fn board_type(&self) -> &str;
/// Connect to the peripheral (open serial, init GPIO, etc.)
async fn connect(&mut self) -> anyhow::Result<()>;
/// Disconnect and release resources
async fn disconnect(&mut self) -> anyhow::Result<()>;
/// Check if the peripheral is reachable and responsive
async fn health_check(&self) -> bool;
/// Tools this peripheral provides (e.g. gpio_read, gpio_write, sensor_read)
fn tools(&self) -> Vec<Box<dyn Tool>>;
}

View file

@ -0,0 +1,151 @@
//! Arduino Uno Q Bridge — GPIO via socket to Bridge app.
//!
//! When ZeroClaw runs on Uno Q, the Bridge app (Python + MCU) exposes
//! digitalWrite/digitalRead over a local socket. These tools connect to it.
use crate::tools::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const BRIDGE_HOST: &str = "127.0.0.1";
const BRIDGE_PORT: u16 = 9999;
async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result<String> {
let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT);
let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr))
.await
.map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??;
let msg = format!("{} {}\n", cmd, args.join(" "));
stream.write_all(msg.as_bytes()).await?;
let mut buf = vec![0u8; 64];
let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf))
.await
.map_err(|_| anyhow::anyhow!("Bridge response timed out"))??;
let resp = String::from_utf8_lossy(&buf[..n]).trim().to_string();
Ok(resp)
}
/// Tool: read GPIO pin via Uno Q Bridge.
pub struct UnoQGpioReadTool;
#[async_trait]
impl Tool for UnoQGpioReadTool {
fn name(&self) -> &str {
"gpio_read"
}
fn description(&self) -> &str {
"Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number (e.g. 13 for LED)"
}
},
"required": ["pin"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
match bridge_request("gpio_read", &[pin.to_string()]).await {
Ok(resp) => {
if resp.starts_with("error:") {
Ok(ToolResult {
success: false,
output: resp.clone(),
error: Some(resp),
})
} else {
Ok(ToolResult {
success: true,
output: resp,
error: None,
})
}
}
Err(e) => Ok(ToolResult {
success: false,
output: format!("Bridge error: {}", e),
error: Some(e.to_string()),
}),
}
}
}
/// Tool: write GPIO pin via Uno Q Bridge.
pub struct UnoQGpioWriteTool;
#[async_trait]
impl Tool for UnoQGpioWriteTool {
fn name(&self) -> &str {
"gpio_write"
}
fn description(&self) -> &str {
"Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pin": {
"type": "integer",
"description": "GPIO pin number"
},
"value": {
"type": "integer",
"description": "0 for low, 1 for high"
}
},
"required": ["pin", "value"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
let value = args
.get("value")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await {
Ok(resp) => {
if resp.starts_with("error:") {
Ok(ToolResult {
success: false,
output: resp.clone(),
error: Some(resp),
})
} else {
Ok(ToolResult {
success: true,
output: "done".into(),
error: None,
})
}
}
Err(e) => Ok(ToolResult {
success: false,
output: format!("Bridge error: {}", e),
error: Some(e.to_string()),
}),
}
}
}

View file

@ -0,0 +1,143 @@
//! Deploy ZeroClaw Bridge app to Arduino Uno Q.
use anyhow::{Context, Result};
use std::process::Command;
const BRIDGE_APP_NAME: &str = "zeroclaw-uno-q-bridge";
/// Deploy the Bridge app. If host is Some, scp from repo and ssh to start.
/// If host is None, assume we're ON the Uno Q — use embedded files and start.
pub fn setup_uno_q_bridge(host: Option<&str>) -> Result<()> {
let bridge_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("firmware")
.join("zeroclaw-uno-q-bridge");
if let Some(h) = host {
if bridge_dir.exists() {
deploy_remote(h, &bridge_dir)?;
} else {
anyhow::bail!(
"Bridge app not found at {}. Run from zeroclaw repo root.",
bridge_dir.display()
);
}
} else {
deploy_local(if bridge_dir.exists() {
Some(&bridge_dir)
} else {
None
})?;
}
Ok(())
}
fn deploy_remote(host: &str, bridge_dir: &std::path::Path) -> Result<()> {
let ssh_target = if host.contains('@') {
host.to_string()
} else {
format!("arduino@{}", host)
};
println!("Copying Bridge app to {}...", host);
let status = Command::new("ssh")
.args([&ssh_target, "mkdir", "-p", "~/ArduinoApps"])
.status()
.context("ssh mkdir failed")?;
if !status.success() {
anyhow::bail!("Failed to create ArduinoApps dir on Uno Q");
}
let status = Command::new("scp")
.args([
"-r",
bridge_dir.to_str().unwrap(),
&format!("{}:~/ArduinoApps/", ssh_target),
])
.status()
.context("scp failed")?;
if !status.success() {
anyhow::bail!("Failed to copy Bridge app");
}
println!("Starting Bridge app on Uno Q...");
let status = Command::new("ssh")
.args([
&ssh_target,
"arduino-app-cli",
"app",
"start",
&format!("~/ArduinoApps/zeroclaw-uno-q-bridge"),
])
.status()
.context("arduino-app-cli start failed")?;
if !status.success() {
anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q.");
}
println!("ZeroClaw Bridge app started. Add to config.toml:");
println!(" [[peripherals.boards]]");
println!(" board = \"arduino-uno-q\"");
println!(" transport = \"bridge\"");
Ok(())
}
fn deploy_local(bridge_dir: Option<&std::path::Path>) -> Result<()> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/arduino".into());
let apps_dir = std::path::Path::new(&home).join("ArduinoApps");
let dest_dir = apps_dir.join(BRIDGE_APP_NAME);
std::fs::create_dir_all(&dest_dir).context("create dest dir")?;
if let Some(src) = bridge_dir {
println!("Copying Bridge app from repo...");
copy_dir(src, &dest_dir)?;
} else {
println!("Writing embedded Bridge app...");
write_embedded_bridge(&dest_dir)?;
}
println!("Starting Bridge app...");
let status = Command::new("arduino-app-cli")
.args(["app", "start", dest_dir.to_str().unwrap()])
.status()
.context("arduino-app-cli start failed")?;
if !status.success() {
anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q.");
}
println!("ZeroClaw Bridge app started.");
Ok(())
}
fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> {
let app_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/app.yaml");
let sketch_ino = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino");
let sketch_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml");
let main_py = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/main.py");
let requirements = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/requirements.txt");
std::fs::write(dest.join("app.yaml"), app_yaml)?;
std::fs::create_dir_all(dest.join("sketch"))?;
std::fs::write(dest.join("sketch").join("sketch.ino"), sketch_ino)?;
std::fs::write(dest.join("sketch").join("sketch.yaml"), sketch_yaml)?;
std::fs::create_dir_all(dest.join("python"))?;
std::fs::write(dest.join("python").join("main.py"), main_py)?;
std::fs::write(dest.join("python").join("requirements.txt"), requirements)?;
Ok(())
}
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> {
for entry in std::fs::read_dir(src)? {
let e = entry?;
let name = e.file_name();
let src_path = src.join(&name);
let dst_path = dst.join(&name);
if e.file_type()?.is_dir() {
std::fs::create_dir_all(&dst_path)?;
copy_dir(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}

View file

@ -15,6 +15,9 @@ pub struct OpenAiCompatibleProvider {
pub(crate) base_url: String,
pub(crate) api_key: Option<String>,
pub(crate) auth_header: AuthStyle,
/// When false, do not fall back to /v1/responses on chat completions 404.
/// GLM/Zhipu does not support the responses API.
supports_responses_fallback: bool,
client: Client,
}
@ -36,6 +39,29 @@ impl OpenAiCompatibleProvider {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.map(ToString::to_string),
auth_header: auth_style,
supports_responses_fallback: true,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
/// Same as `new` but skips the /v1/responses fallback on 404.
/// Use for providers (e.g. GLM) that only support chat completions.
pub fn new_no_responses_fallback(
name: &str,
base_url: &str,
api_key: Option<&str>,
auth_style: AuthStyle,
) -> Self {
Self {
name: name.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.map(ToString::to_string),
auth_header: auth_style,
supports_responses_fallback: false,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
@ -112,6 +138,8 @@ struct ChatRequest {
model: String,
messages: Vec<Message>,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
}
#[derive(Debug, Serialize)]
@ -348,6 +376,7 @@ impl Provider for OpenAiCompatibleProvider {
model: model.to_string(),
messages,
temperature,
stream: Some(false),
};
let url = self.chat_completions_url();
@ -362,7 +391,7 @@ impl Provider for OpenAiCompatibleProvider {
let error = response.text().await?;
let sanitized = super::sanitize_api_error(&error);
if status == reqwest::StatusCode::NOT_FOUND {
if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback {
return self
.chat_via_responses(api_key, system_prompt, message, model)
.await
@ -413,6 +442,7 @@ impl Provider for OpenAiCompatibleProvider {
model: model.to_string(),
messages: api_messages,
temperature,
stream: Some(false),
};
let url = self.chat_completions_url();
@ -425,7 +455,7 @@ impl Provider for OpenAiCompatibleProvider {
let status = response.status();
// Mirror chat_with_system: 404 may mean this provider uses the Responses API
if status == reqwest::StatusCode::NOT_FOUND {
if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback {
// Extract system prompt and last user message for responses fallback
let system = messages.iter().find(|m| m.role == "system");
let last_user = messages.iter().rfind(|m| m.role == "user");
@ -517,7 +547,8 @@ mod tests {
content: "hello".to_string(),
},
],
temperature: 0.7,
temperature: 0.4,
stream: Some(false),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("llama-3.3-70b"));

View file

@ -217,8 +217,8 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
"zai" | "z.ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Z.AI", "https://api.z.ai/api/coding/paas/v4", key, AuthStyle::Bearer,
))),
"glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new(
"GLM", "https://open.bigmodel.cn/api/paas/v4", key, AuthStyle::Bearer,
"glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback(
"GLM", "https://api.z.ai/api/paas/v4", key, AuthStyle::Bearer,
))),
"minimax" => Ok(Box::new(OpenAiCompatibleProvider::new(
"MiniMax",

397
src/rag/mod.rs Normal file
View file

@ -0,0 +1,397 @@
//! RAG pipeline for hardware datasheet retrieval.
//!
//! Supports:
//! - Markdown and text datasheets (always)
//! - PDF ingestion (with `rag-pdf` feature)
//! - Pin/alias tables (e.g. `red_led: 13`) for explicit lookup
//! - Keyword retrieval (default) or semantic search via embeddings (optional)
use crate::memory::chunker;
use std::collections::HashMap;
use std::path::Path;
/// A chunk of datasheet content with board metadata.
#[derive(Debug, Clone)]
pub struct DatasheetChunk {
/// Board this chunk applies to (e.g. "nucleo-f401re", "rpi-gpio"), or None for generic.
pub board: Option<String>,
/// Source file path (for debugging).
pub source: String,
/// Chunk content.
pub content: String,
}
/// Pin alias: human-readable name → pin number (e.g. "red_led" → 13).
pub type PinAliases = HashMap<String, u32>;
/// Parse pin aliases from markdown. Looks for:
/// - `## Pin Aliases` section with `alias: pin` lines
/// - Markdown table `| alias | pin |`
fn parse_pin_aliases(content: &str) -> PinAliases {
let mut aliases = PinAliases::new();
let content_lower = content.to_lowercase();
// Find ## Pin Aliases section
let section_markers = ["## pin aliases", "## pin alias", "## pins"];
let mut in_section = false;
let mut section_start = 0;
for marker in section_markers {
if let Some(pos) = content_lower.find(marker) {
in_section = true;
section_start = pos + marker.len();
break;
}
}
if !in_section {
return aliases;
}
let rest = &content[section_start..];
let section_end = rest
.find("\n## ")
.map(|i| section_start + i)
.unwrap_or(content.len());
let section = &content[section_start..section_end];
// Parse "alias: pin" or "alias = pin" lines
for line in section.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Table row: | red_led | 13 | (skip header | alias | pin | and separator |---|)
if line.starts_with('|') {
let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect();
if parts.len() >= 3 {
let alias = parts[1].trim().to_lowercase().replace(' ', "_");
let pin_str = parts[2].trim();
// Skip header row and separator (|---|)
if alias.eq("alias")
|| alias.eq("pin")
|| pin_str.eq("pin")
|| alias.contains("---")
|| pin_str.contains("---")
{
continue;
}
if let Ok(pin) = pin_str.parse::<u32>() {
if !alias.is_empty() {
aliases.insert(alias, pin);
}
}
}
continue;
}
// Key: value
if let Some((k, v)) = line.split_once(':').or_else(|| line.split_once('=')) {
let alias = k.trim().to_lowercase().replace(' ', "_");
if let Ok(pin) = v.trim().parse::<u32>() {
if !alias.is_empty() {
aliases.insert(alias, pin);
}
}
}
}
aliases
}
fn collect_md_txt_paths(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_md_txt_paths(&path, out);
} else if path.is_file() {
let ext = path.extension().and_then(|e| e.to_str());
if ext == Some("md") || ext == Some("txt") {
out.push(path);
}
}
}
}
#[cfg(feature = "rag-pdf")]
fn collect_pdf_paths(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_pdf_paths(&path, out);
} else if path.is_file() {
if path.extension().and_then(|e| e.to_str()) == Some("pdf") {
out.push(path);
}
}
}
}
#[cfg(feature = "rag-pdf")]
fn extract_pdf_text(path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
pdf_extract::extract_text_from_mem(&bytes).ok()
}
/// Hardware RAG index — loads and retrieves datasheet chunks.
pub struct HardwareRag {
chunks: Vec<DatasheetChunk>,
/// Per-board pin aliases (board -> alias -> pin).
pin_aliases: HashMap<String, PinAliases>,
}
impl HardwareRag {
/// Load datasheets from a directory. Expects .md, .txt, and optionally .pdf (with rag-pdf).
/// Filename (without extension) is used as board tag.
/// Supports `## Pin Aliases` section for explicit alias→pin mapping.
pub fn load(workspace_dir: &Path, datasheet_dir: &str) -> anyhow::Result<Self> {
let base = workspace_dir.join(datasheet_dir);
if !base.exists() || !base.is_dir() {
return Ok(Self {
chunks: Vec::new(),
pin_aliases: HashMap::new(),
});
}
let mut paths: Vec<std::path::PathBuf> = Vec::new();
collect_md_txt_paths(&base, &mut paths);
#[cfg(feature = "rag-pdf")]
collect_pdf_paths(&base, &mut paths);
let mut chunks = Vec::new();
let mut pin_aliases: HashMap<String, PinAliases> = HashMap::new();
let max_tokens = 512;
for path in paths {
let content = if path.extension().and_then(|e| e.to_str()) == Some("pdf") {
#[cfg(feature = "rag-pdf")]
{
extract_pdf_text(&path).unwrap_or_default()
}
#[cfg(not(feature = "rag-pdf"))]
{
String::new()
}
} else {
std::fs::read_to_string(&path).unwrap_or_default()
};
if content.trim().is_empty() {
continue;
}
let board = infer_board_from_path(&path, &base);
let source = path
.strip_prefix(workspace_dir)
.unwrap_or(&path)
.display()
.to_string();
// Parse pin aliases from full content
let aliases = parse_pin_aliases(&content);
if let Some(ref b) = board {
if !aliases.is_empty() {
pin_aliases.insert(b.clone(), aliases);
}
}
for chunk in chunker::chunk_markdown(&content, max_tokens) {
chunks.push(DatasheetChunk {
board: board.clone(),
source: source.clone(),
content: chunk.content,
});
}
}
Ok(Self {
chunks,
pin_aliases,
})
}
/// Get pin aliases for a board (e.g. "red_led" -> 13).
pub fn pin_aliases_for_board(&self, board: &str) -> Option<&PinAliases> {
self.pin_aliases.get(board)
}
/// Build pin-alias context for query. When user says "red led", inject "red_led: 13" for matching boards.
pub fn pin_alias_context(&self, query: &str, boards: &[String]) -> String {
let query_lower = query.to_lowercase();
let query_words: Vec<&str> = query_lower
.split_whitespace()
.filter(|w| w.len() > 1)
.collect();
let mut lines = Vec::new();
for board in boards {
if let Some(aliases) = self.pin_aliases.get(board) {
for (alias, pin) in aliases {
let alias_words: Vec<&str> = alias.split('_').collect();
let matches = query_words
.iter()
.any(|qw| alias_words.iter().any(|aw| *aw == *qw))
|| query_lower.contains(&alias.replace('_', " "));
if matches {
lines.push(format!("{board}: {alias} = pin {pin}"));
}
}
}
}
if lines.is_empty() {
return String::new();
}
format!("[Pin aliases for query]\n{}\n\n", lines.join("\n"))
}
/// Retrieve chunks relevant to the query and boards.
/// Uses keyword matching and board filter. Pin-alias context is built separately via `pin_alias_context`.
pub fn retrieve(&self, query: &str, boards: &[String], limit: usize) -> Vec<&DatasheetChunk> {
if self.chunks.is_empty() || limit == 0 {
return Vec::new();
}
let query_lower = query.to_lowercase();
let query_terms: Vec<&str> = query_lower
.split_whitespace()
.filter(|w| w.len() > 2)
.collect();
let mut scored: Vec<(&DatasheetChunk, f32)> = Vec::new();
for chunk in &self.chunks {
let content_lower = chunk.content.to_lowercase();
let mut score = 0.0f32;
for term in &query_terms {
if content_lower.contains(term) {
score += 1.0;
}
}
if score > 0.0 {
let board_match = chunk.board.as_ref().map_or(false, |b| boards.contains(b));
if board_match {
score += 2.0;
}
scored.push((chunk, score));
}
}
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(limit);
scored.into_iter().map(|(c, _)| c).collect()
}
/// Number of indexed chunks.
pub fn len(&self) -> usize {
self.chunks.len()
}
/// True if no chunks are indexed.
pub fn is_empty(&self) -> bool {
self.chunks.is_empty()
}
}
/// Infer board tag from file path. `nucleo-f401re.md` → Some("nucleo-f401re").
fn infer_board_from_path(path: &Path, base: &Path) -> Option<String> {
let rel = path.strip_prefix(base).ok()?;
let stem = path.file_stem()?.to_str()?;
if stem == "generic" || stem.starts_with("generic_") {
return None;
}
if rel.parent().and_then(|p| p.to_str()) == Some("_generic") {
return None;
}
Some(stem.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pin_aliases_key_value() {
let md = r#"## Pin Aliases
red_led: 13
builtin_led: 13
user_led: 5"#;
let a = parse_pin_aliases(md);
assert_eq!(a.get("red_led"), Some(&13));
assert_eq!(a.get("builtin_led"), Some(&13));
assert_eq!(a.get("user_led"), Some(&5));
}
#[test]
fn parse_pin_aliases_table() {
let md = r#"## Pin Aliases
| alias | pin |
|-------|-----|
| red_led | 13 |
| builtin_led | 13 |"#;
let a = parse_pin_aliases(md);
assert_eq!(a.get("red_led"), Some(&13));
assert_eq!(a.get("builtin_led"), Some(&13));
}
#[test]
fn parse_pin_aliases_empty() {
let a = parse_pin_aliases("No aliases here");
assert!(a.is_empty());
}
#[test]
fn infer_board_from_path_nucleo() {
let base = std::path::Path::new("/base");
let path = std::path::Path::new("/base/nucleo-f401re.md");
assert_eq!(
infer_board_from_path(path, base),
Some("nucleo-f401re".into())
);
}
#[test]
fn infer_board_generic_none() {
let base = std::path::Path::new("/base");
let path = std::path::Path::new("/base/generic.md");
assert_eq!(infer_board_from_path(path, base), None);
}
#[test]
fn hardware_rag_load_and_retrieve() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("datasheets");
std::fs::create_dir_all(&base).unwrap();
let content = r#"# Test Board
## Pin Aliases
red_led: 13
## GPIO
Pin 13: LED
"#;
std::fs::write(base.join("test-board.md"), content).unwrap();
let rag = HardwareRag::load(tmp.path(), "datasheets").unwrap();
assert!(!rag.is_empty());
let boards = vec!["test-board".to_string()];
let chunks = rag.retrieve("led", &boards, 5);
assert!(!chunks.is_empty());
let ctx = rag.pin_alias_context("red led", &boards);
assert!(ctx.contains("13"));
}
#[test]
fn hardware_rag_load_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("empty_ds");
std::fs::create_dir_all(&base).unwrap();
let rag = HardwareRag::load(tmp.path(), "empty_ds").unwrap();
assert!(rag.is_empty());
}
}

View file

@ -0,0 +1,205 @@
//! Hardware board info tool — returns chip name, architecture, memory map for Telegram/agent.
//!
//! Use when user asks "what board do I have?", "board info", "connected hardware", etc.
//! Uses probe-rs for Nucleo when available; otherwise static datasheet info.
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
/// Static board info (datasheets). Used when probe-rs is unavailable.
const BOARD_INFO: &[(&str, &str, &str)] = &[
(
"nucleo-f401re",
"STM32F401RET6",
"ARM Cortex-M4, 84 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).",
),
(
"nucleo-f411re",
"STM32F411RET6",
"ARM Cortex-M4, 100 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).",
),
(
"arduino-uno",
"ATmega328P",
"8-bit AVR, 16 MHz. Flash: 16 KB, SRAM: 2 KB. Built-in LED on pin 13.",
),
(
"arduino-uno-q",
"STM32U585 + Qualcomm",
"Dual-core: STM32 (MCU) + Linux (aarch64). GPIO via Bridge app on port 9999.",
),
(
"esp32",
"ESP32",
"Dual-core Xtensa LX6, 240 MHz. Flash: 4 MB typical. Built-in LED on GPIO 2.",
),
(
"rpi-gpio",
"Raspberry Pi",
"ARM Linux. Native GPIO via sysfs/rppal. No fixed LED pin.",
),
];
/// Tool: return full board info (chip, architecture, memory map) for agent/Telegram.
pub struct HardwareBoardInfoTool {
boards: Vec<String>,
}
impl HardwareBoardInfoTool {
pub fn new(boards: Vec<String>) -> Self {
Self { boards }
}
fn static_info_for_board(&self, board: &str) -> Option<String> {
BOARD_INFO
.iter()
.find(|(b, _, _)| *b == board)
.map(|(_, chip, desc)| {
format!(
"**Board:** {}\n**Chip:** {}\n**Description:** {}",
board, chip, desc
)
})
}
}
#[async_trait]
impl Tool for HardwareBoardInfoTool {
fn name(&self) -> &str {
"hardware_board_info"
}
fn description(&self) -> &str {
"Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"board": {
"type": "string",
"description": "Optional board name (e.g. nucleo-f401re). If omitted, returns info for first configured board."
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let board = args
.get("board")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| self.boards.first().cloned());
let board = board.as_deref().unwrap_or("unknown");
if self.boards.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"No peripherals configured. Add boards to config.toml [peripherals.boards]."
.into(),
),
});
}
let mut output = String::new();
#[cfg(feature = "probe")]
if board == "nucleo-f401re" || board == "nucleo-f411re" {
let chip = if board == "nucleo-f411re" {
"STM32F411RETx"
} else {
"STM32F401RETx"
};
match probe_board_info(chip) {
Ok(info) => {
return Ok(ToolResult {
success: true,
output: info,
error: None,
});
}
Err(e) => {
output.push_str(&format!(
"probe-rs attach failed: {}. Using static info.\n\n",
e
));
}
}
}
if let Some(info) = self.static_info_for_board(board) {
output.push_str(&info);
if let Some(mem) = memory_map_static(board) {
output.push_str(&format!("\n\n**Memory map:**\n{}", mem));
}
} else {
output.push_str(&format!(
"Board '{}' configured. No static info available.",
board
));
}
Ok(ToolResult {
success: true,
output,
error: None,
})
}
}
#[cfg(feature = "probe")]
fn probe_board_info(chip: &str) -> anyhow::Result<String> {
use probe_rs::config::MemoryRegion;
use probe_rs::{Session, SessionConfig};
let session = Session::auto_attach(chip, SessionConfig::default())
.map_err(|e| anyhow::anyhow!("{}", e))?;
let target = session.target();
let arch = session.architecture();
let mut out = format!(
"**Board:** {}\n**Chip:** {}\n**Architecture:** {:?}\n\n**Memory map:**\n",
chip, target.name, arch
);
for region in target.memory_map.iter() {
match region {
MemoryRegion::Ram(ram) => {
let (start, end) = (ram.range.start, ram.range.end);
out.push_str(&format!(
"RAM: 0x{:08X} - 0x{:08X} ({} KB)\n",
start,
end,
(end - start) / 1024
));
}
MemoryRegion::Nvm(flash) => {
let (start, end) = (flash.range.start, flash.range.end);
out.push_str(&format!(
"Flash: 0x{:08X} - 0x{:08X} ({} KB)\n",
start,
end,
(end - start) / 1024
));
}
_ => {}
}
}
out.push_str("\n(Info read via USB/SWD — no firmware on target needed.)");
Ok(out)
}
fn memory_map_static(board: &str) -> Option<&'static str> {
match board {
"nucleo-f401re" | "nucleo-f411re" => Some(
"Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)",
),
"arduino-uno" => Some("Flash: 16 KB, SRAM: 2 KB, EEPROM: 1 KB"),
"esp32" => Some("Flash: 4 MB, IRAM/DRAM per ESP-IDF layout"),
_ => None,
}
}

View file

@ -0,0 +1,205 @@
//! Hardware memory map tool — returns flash/RAM address ranges for connected boards.
//!
//! Phase B: When user asks "what are the upper and lower memory addresses?", this tool
//! returns the memory map. Uses probe-rs for Nucleo/STM32 when available; otherwise
//! returns static maps from datasheets.
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
/// Known memory maps (from datasheets). Used when probe-rs is unavailable.
const MEMORY_MAPS: &[(&str, &str)] = &[
(
"nucleo-f401re",
"Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F401RET6, ARM Cortex-M4",
),
(
"nucleo-f411re",
"Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F411RET6, ARM Cortex-M4",
),
(
"arduino-uno",
"Flash: 0x0000 - 0x3FFF (16 KB, ATmega328P)\nSRAM: 0x0100 - 0x08FF (2 KB)\nEEPROM: 0x0000 - 0x03FF (1 KB)",
),
(
"arduino-mega",
"Flash: 0x0000 - 0x3FFFF (256 KB, ATmega2560)\nSRAM: 0x0200 - 0x21FF (8 KB)\nEEPROM: 0x0000 - 0x0FFF (4 KB)",
),
(
"esp32",
"Flash: 0x3F40_0000 - 0x3F7F_FFFF (4 MB typical)\nIRAM: 0x4000_0000 - 0x4005_FFFF\nDRAM: 0x3FFB_0000 - 0x3FFF_FFFF",
),
];
/// Tool: report hardware memory map for connected boards.
pub struct HardwareMemoryMapTool {
boards: Vec<String>,
}
impl HardwareMemoryMapTool {
pub fn new(boards: Vec<String>) -> Self {
Self { boards }
}
fn static_map_for_board(&self, board: &str) -> Option<&'static str> {
MEMORY_MAPS
.iter()
.find(|(b, _)| *b == board)
.map(|(_, m)| *m)
}
}
#[async_trait]
impl Tool for HardwareMemoryMapTool {
fn name(&self) -> &str {
"hardware_memory_map"
}
fn description(&self) -> &str {
"Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"board": {
"type": "string",
"description": "Optional board name (e.g. nucleo-f401re, arduino-uno). If omitted, returns map for first configured board."
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let board = args
.get("board")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| self.boards.first().cloned());
let board = board.as_deref().unwrap_or("unknown");
if self.boards.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"No peripherals configured. Add boards to config.toml [peripherals.boards]."
.into(),
),
});
}
let mut output = String::new();
#[cfg(feature = "probe")]
let probe_ok = {
if board == "nucleo-f401re" || board == "nucleo-f411re" {
let chip = if board == "nucleo-f411re" {
"STM32F411RETx"
} else {
"STM32F401RETx"
};
match probe_rs_memory_map(chip) {
Ok(probe_msg) => {
output.push_str(&format!("**{}** (via probe-rs):\n{}\n", board, probe_msg));
true
}
Err(e) => {
output.push_str(&format!("Probe-rs failed: {}. ", e));
false
}
}
} else {
false
}
};
#[cfg(not(feature = "probe"))]
let probe_ok = false;
if !probe_ok {
if let Some(map) = self.static_map_for_board(board) {
output.push_str(&format!("**{}** (from datasheet):\n{}", board, map));
} else {
let known: Vec<&str> = MEMORY_MAPS.iter().map(|(b, _)| *b).collect();
output.push_str(&format!(
"No memory map for board '{}'. Known boards: {}",
board,
known.join(", ")
));
}
}
Ok(ToolResult {
success: true,
output,
error: None,
})
}
}
#[cfg(feature = "probe")]
fn probe_rs_memory_map(chip: &str) -> anyhow::Result<String> {
use probe_rs::config::MemoryRegion;
use probe_rs::{Session, SessionConfig};
let session = Session::auto_attach(chip, SessionConfig::default())
.map_err(|e| anyhow::anyhow!("probe-rs attach failed: {}", e))?;
let target = session.target();
let mut out = String::new();
for region in target.memory_map.iter() {
match region {
MemoryRegion::Ram(ram) => {
let start = ram.range.start;
let end = ram.range.end;
let size_kb = (end - start) / 1024;
out.push_str(&format!(
"RAM: 0x{:08X} - 0x{:08X} ({} KB)\n",
start, end, size_kb
));
}
MemoryRegion::Nvm(flash) => {
let start = flash.range.start;
let end = flash.range.end;
let size_kb = (end - start) / 1024;
out.push_str(&format!(
"Flash: 0x{:08X} - 0x{:08X} ({} KB)\n",
start, end, size_kb
));
}
_ => {}
}
}
if out.is_empty() {
out = "Could not read memory regions from probe.".to_string();
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_map_nucleo() {
let tool = HardwareMemoryMapTool::new(vec!["nucleo-f401re".into()]);
assert!(tool.static_map_for_board("nucleo-f401re").is_some());
assert!(tool
.static_map_for_board("nucleo-f401re")
.unwrap()
.contains("Flash"));
}
#[test]
fn static_map_arduino() {
let tool = HardwareMemoryMapTool::new(vec!["arduino-uno".into()]);
assert!(tool.static_map_for_board("arduino-uno").is_some());
}
}

View file

@ -0,0 +1,181 @@
//! Hardware memory read tool — read actual memory/register values from Nucleo via probe-rs.
//!
//! Use when user asks to "read register values", "read memory at address", "dump lower memory", etc.
//! Requires probe feature and Nucleo connected via USB.
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
/// RAM base for Nucleo-F401RE (STM32F401)
const NUCLEO_RAM_BASE: u64 = 0x2000_0000;
/// Tool: read memory at address from connected Nucleo via probe-rs.
pub struct HardwareMemoryReadTool {
boards: Vec<String>,
}
impl HardwareMemoryReadTool {
pub fn new(boards: Vec<String>) -> Self {
Self { boards }
}
fn chip_for_board(board: &str) -> Option<&'static str> {
match board {
"nucleo-f401re" => Some("STM32F401RETx"),
"nucleo-f411re" => Some("STM32F411RETx"),
_ => None,
}
}
}
#[async_trait]
impl Tool for HardwareMemoryReadTool {
fn name(&self) -> &str {
"hardware_memory_read"
}
fn description(&self) -> &str {
"Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Memory address in hex (e.g. 0x20000000 for RAM start). Default: 0x20000000 (RAM base)."
},
"length": {
"type": "integer",
"description": "Number of bytes to read (default 128, max 256)."
},
"board": {
"type": "string",
"description": "Board name (nucleo-f401re). Optional if only one configured."
}
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
if self.boards.is_empty() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"No peripherals configured. Add nucleo-f401re to config.toml [peripherals.boards]."
.into(),
),
});
}
let board = args
.get("board")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| self.boards.first().cloned())
.unwrap_or_else(|| "nucleo-f401re".into());
let chip = Self::chip_for_board(&board);
if chip.is_none() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Memory read only supports nucleo-f401re, nucleo-f411re. Got: {}",
board
)),
});
}
let address_str = args
.get("address")
.and_then(|v| v.as_str())
.unwrap_or("0x20000000");
let address = parse_hex_address(address_str).unwrap_or(NUCLEO_RAM_BASE);
let length = args.get("length").and_then(|v| v.as_u64()).unwrap_or(128) as usize;
let length = length.min(256).max(1);
#[cfg(feature = "probe")]
{
match probe_read_memory(chip.unwrap(), address, length) {
Ok(output) => {
return Ok(ToolResult {
success: true,
output,
error: None,
});
}
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"probe-rs read failed: {}. Ensure Nucleo is connected via USB and built with --features probe.",
e
)),
});
}
}
}
#[cfg(not(feature = "probe"))]
{
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"Memory read requires probe feature. Build with: cargo build --features hardware,probe"
.into(),
),
})
}
}
}
fn parse_hex_address(s: &str) -> Option<u64> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
u64::from_str_radix(s, 16).ok()
}
#[cfg(feature = "probe")]
fn probe_read_memory(chip: &str, address: u64, length: usize) -> anyhow::Result<String> {
use probe_rs::MemoryInterface;
use probe_rs::Session;
use probe_rs::SessionConfig;
let mut session = Session::auto_attach(chip, SessionConfig::default())
.map_err(|e| anyhow::anyhow!("{}", e))?;
let mut core = session.core(0)?;
let mut buf = vec![0u8; length];
core.read_8(address, &mut buf)
.map_err(|e| anyhow::anyhow!("{}", e))?;
// Format as hex dump: address | bytes (16 per line)
let mut out = format!("Memory read from 0x{:08X} ({} bytes):\n\n", address, length);
const COLS: usize = 16;
for (i, chunk) in buf.chunks(COLS).enumerate() {
let addr = address + (i * COLS) as u64;
let hex: String = chunk
.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(" ");
let ascii: String = chunk
.iter()
.map(|&b| {
if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
}
})
.collect();
out.push_str(&format!("0x{:08X} {:48} {}\n", addr, hex, ascii));
}
Ok(out)
}

View file

@ -5,6 +5,9 @@ pub mod delegate;
pub mod file_read;
pub mod file_write;
pub mod git_operations;
pub mod hardware_board_info;
pub mod hardware_memory_map;
pub mod hardware_memory_read;
pub mod http_request;
pub mod image_info;
pub mod memory_forget;
@ -22,6 +25,9 @@ pub use delegate::DelegateTool;
pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
pub use git_operations::GitOperationsTool;
pub use hardware_board_info::HardwareBoardInfoTool;
pub use hardware_memory_map::HardwareMemoryMapTool;
pub use hardware_memory_read::HardwareMemoryReadTool;
pub use http_request::HttpRequestTool;
pub use image_info::ImageInfoTool;
pub use memory_forget::MemoryForgetTool;