From 2fbf4cd15cf935481b2720014114dc3f22c2b711 Mon Sep 17 00:00:00 2001 From: ehushubhamshaw Date: Sat, 14 Mar 2026 21:58:01 -0400 Subject: [PATCH] fix(hardware): add missing serial/uf2/pico modules declared in mod.rs cargo fmt was exiting with code 1 because mod.rs declared pub mod serial, uf2, pico_flash, pico_code but those files were missing from the branch. Also apply auto-formatting to loader.rs. --- src/hardware/loader.rs | 15 +- src/hardware/pico_code.rs | 723 +++++++++++++++++++++++++++++++++++++ src/hardware/pico_flash.rs | 296 +++++++++++++++ src/hardware/serial.rs | 298 +++++++++++++++ src/hardware/uf2.rs | 351 ++++++++++++++++++ 5 files changed, 1679 insertions(+), 4 deletions(-) create mode 100644 src/hardware/pico_code.rs create mode 100644 src/hardware/pico_flash.rs create mode 100644 src/hardware/serial.rs create mode 100644 src/hardware/uf2.rs diff --git a/src/hardware/loader.rs b/src/hardware/loader.rs index da2aa69e9..ae475042c 100644 --- a/src/hardware/loader.rs +++ b/src/hardware/loader.rs @@ -152,12 +152,19 @@ fn load_one_plugin(plugin_dir: &Path, manifest_path: &Path) -> Result, + device_alias: Option<&str>, +) -> Result<(String, String, DeviceRuntime), ToolResult> { + let reg = registry.read().await; + + let alias: String = match device_alias { + Some(a) => a.to_string(), + None => { + // Auto-select the first device. + let all_aliases: Vec = + reg.aliases().into_iter().map(|a| a.to_string()).collect(); + match all_aliases.as_slice() { + [single] => single.clone(), + [] => { + return Err(ToolResult { + success: false, + output: String::new(), + error: Some("no device found — is a board connected via USB?".to_string()), + }); + } + multiple => { + return Err(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "multiple devices found ({}); specify the \"device\" parameter", + multiple.join(", ") + )), + }); + } + } + } + }; + + let device = reg.get_device(&alias).ok_or_else(|| ToolResult { + success: false, + output: String::new(), + error: Some(format!("device '{alias}' not found in registry")), + })?; + + let runtime = device.runtime; + + let port = device.port().ok_or_else(|| ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "device '{alias}' has no serial port — is it connected?" + )), + })?; + + Ok((alias, port.to_string(), runtime)) +} + +/// Return an unsupported-runtime error `ToolResult` for a given tool name. +fn unsupported_runtime(runtime: &DeviceRuntime, tool: &str) -> ToolResult { + ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "{runtime} runtime is not yet supported for {tool} — coming soon" + )), + } +} + +/// Run an `mpremote` command with a timeout and return (stdout, stderr). +async fn run_mpremote(args: &[&str], timeout_secs: u64) -> Result<(String, String), String> { + use tokio::time::timeout; + + let result = timeout( + std::time::Duration::from_secs(timeout_secs), + tokio::process::Command::new("mpremote").args(args).output(), + ) + .await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + Ok((stdout, stderr)) + } else { + Err(format!( + "mpremote failed (exit {}): {}", + output.status, + stderr.trim() + )) + } + } + Ok(Err(e)) => Err(format!( + "mpremote not found or could not start ({e}). \ + Install it with: pip install mpremote" + )), + Err(_) => Err(format!( + "mpremote timed out after {timeout_secs}s — \ + the device may be unresponsive" + )), + } +} + +// ── DeviceReadCodeTool ──────────────────────────────────────────────────────── + +/// Tool: read the current `main.py` from a connected device. +/// +/// The LLM uses this to understand the current program before modifying it. +pub struct DeviceReadCodeTool { + registry: Arc>, +} + +impl DeviceReadCodeTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for DeviceReadCodeTool { + fn name(&self) -> &str { + "device_read_code" + } + + fn description(&self) -> &str { + "Read the current program (main.py) running on a connected device. \ + Use this before writing new code so you understand the current state." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, esp0. Auto-selected if only one device is connected." + } + }, + "required": [] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let device_alias = args.get("device").and_then(|v| v.as_str()); + + let (alias, port, runtime) = match resolve_device_port(&self.registry, device_alias).await { + Ok(v) => v, + Err(tool_result) => return Ok(tool_result), + }; + + // Runtime dispatch. + match runtime { + DeviceRuntime::MicroPython | DeviceRuntime::CircuitPython => {} + other => return Ok(unsupported_runtime(&other, "device_read_code")), + } + + tracing::info!(alias = %alias, port = %port, runtime = %runtime, "reading main.py from device"); + + match run_mpremote( + &["connect", &port, "cat", ":main.py"], + MPREMOTE_TIMEOUT_SECS, + ) + .await + { + Ok((stdout, _stderr)) => Ok(ToolResult { + success: true, + output: if stdout.trim().is_empty() { + format!("main.py on {alias} is empty or not found.") + } else { + format!( + "Current main.py on {alias}:\n\n```python\n{}\n```", + stdout.trim() + ) + }, + error: None, + }), + Err(e) => { + // mpremote cat fails if main.py doesn't exist — not a fatal error. + if e.contains("OSError") || e.contains("no such file") || e.contains("ENOENT") { + Ok(ToolResult { + success: true, + output: format!( + "No main.py found on {alias} — the device has no program yet." + ), + error: None, + }) + } else { + Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to read code from {alias}: {e}")), + }) + } + } + } + } +} + +// ── DeviceWriteCodeTool ─────────────────────────────────────────────────────── + +/// Tool: write a complete program to a device as `main.py`. +/// +/// This replaces the current `main.py` on the device and resets it so the new +/// program starts executing immediately. +pub struct DeviceWriteCodeTool { + registry: Arc>, +} + +impl DeviceWriteCodeTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for DeviceWriteCodeTool { + fn name(&self) -> &str { + "device_write_code" + } + + fn description(&self) -> &str { + "Write a complete program to a device — replaces main.py and restarts the device. \ + Always read the current code first with device_read_code." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, esp0. Auto-selected if only one device is connected." + }, + "code": { + "type": "string", + "description": "Complete program to write as main.py" + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let code = match args.get("code").and_then(|v| v.as_str()) { + Some(c) => c, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: code".to_string()), + }); + } + }; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("code parameter is empty — provide a program to write".to_string()), + }); + } + + let device_alias = args.get("device").and_then(|v| v.as_str()); + + let (alias, port, runtime) = match resolve_device_port(&self.registry, device_alias).await { + Ok(v) => v, + Err(tool_result) => return Ok(tool_result), + }; + + // Runtime dispatch. + match runtime { + DeviceRuntime::MicroPython | DeviceRuntime::CircuitPython => {} + other => return Ok(unsupported_runtime(&other, "device_write_code")), + } + + tracing::info!(alias = %alias, port = %port, runtime = %runtime, code_len = code.len(), "writing main.py to device"); + + // Write code to an atomic, owner-only temp file via tempfile crate. + let named_tmp = match tokio::task::spawn_blocking(|| { + tempfile::Builder::new() + .prefix("zeroclaw_main_") + .suffix(".py") + .tempfile() + }) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to create temp file: {e}")), + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("temp file task failed: {e}")), + }); + } + }; + let tmp_path = named_tmp.path().to_path_buf(); + let tmp_str = tmp_path.to_string_lossy().to_string(); + + if let Err(e) = tokio::fs::write(&tmp_path, code).await { + // named_tmp dropped here — auto-removes the file. + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to write temp file: {e}")), + }); + } + + // Deploy via mpremote: copy + reset. + let result = run_mpremote( + &["connect", &port, "cp", &tmp_str, ":main.py", "+", "reset"], + MPREMOTE_TIMEOUT_SECS, + ) + .await; + + // Explicit cleanup — log if removal fails rather than silently ignoring. + if let Err(e) = named_tmp.close() { + tracing::warn!(path = %tmp_str, err = %e, "failed to clean up temp file"); + } + + match result { + Ok((_stdout, _stderr)) => { + tracing::info!(alias = %alias, "main.py deployed and device reset"); + + // Wait for the serial port to reappear after reset. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + let port_reappeared = wait_for_port( + &port, + std::time::Duration::from_secs(PORT_WAIT_SECS), + std::time::Duration::from_millis(PORT_POLL_MS), + ) + .await; + + if port_reappeared { + Ok(ToolResult { + success: true, + output: format!( + "Code deployed to {alias} — main.py updated and device reset. \ + {alias} is back online." + ), + error: None, + }) + } else { + Ok(ToolResult { + success: true, + output: format!( + "Code deployed to {alias} — main.py updated and device reset. \ + Note: serial port did not reappear within {PORT_WAIT_SECS}s; \ + the device may still be booting." + ), + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to deploy code to {alias}: {e}")), + }), + } + } +} + +// ── DeviceExecTool ──────────────────────────────────────────────────────────── + +/// Tool: run a one-off code snippet on a device without modifying `main.py`. +/// +/// Good for one-time commands, sensor reads, and testing code before committing. +pub struct DeviceExecTool { + registry: Arc>, +} + +impl DeviceExecTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for DeviceExecTool { + fn name(&self) -> &str { + "device_exec" + } + + fn description(&self) -> &str { + "Execute a code snippet on a connected device without modifying main.py. \ + Good for one-time actions, sensor reads, and testing before writing permanent code." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "device": { + "type": "string", + "description": "Device alias e.g. pico0, esp0. Auto-selected if only one device is connected." + }, + "code": { + "type": "string", + "description": "Code to execute. Output is captured and returned." + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let code = match args.get("code").and_then(|v| v.as_str()) { + Some(c) => c, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("missing required parameter: code".to_string()), + }); + } + }; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "code parameter is empty — provide a code snippet to execute".to_string(), + ), + }); + } + + let device_alias = args.get("device").and_then(|v| v.as_str()); + + let (alias, port, runtime) = match resolve_device_port(&self.registry, device_alias).await { + Ok(v) => v, + Err(tool_result) => return Ok(tool_result), + }; + + // Runtime dispatch. + match runtime { + DeviceRuntime::MicroPython | DeviceRuntime::CircuitPython => {} + other => return Ok(unsupported_runtime(&other, "device_exec")), + } + + tracing::info!(alias = %alias, port = %port, runtime = %runtime, code_len = code.len(), "executing snippet on device"); + + // Write snippet to an atomic, owner-only temp file via tempfile crate. + let named_tmp = match tokio::task::spawn_blocking(|| { + tempfile::Builder::new() + .prefix("zeroclaw_exec_") + .suffix(".py") + .tempfile() + }) + .await + { + Ok(Ok(f)) => f, + Ok(Err(e)) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to create temp file: {e}")), + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("temp file task failed: {e}")), + }); + } + }; + let tmp_path = named_tmp.path().to_path_buf(); + let tmp_str = tmp_path.to_string_lossy().to_string(); + + if let Err(e) = tokio::fs::write(&tmp_path, code).await { + // named_tmp dropped here — auto-removes the file. + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("failed to write temp file: {e}")), + }); + } + + // Execute via mpremote run (does NOT modify main.py). + let result = + run_mpremote(&["connect", &port, "run", &tmp_str], MPREMOTE_TIMEOUT_SECS).await; + + // Explicit cleanup — log if removal fails rather than silently ignoring. + if let Err(e) = named_tmp.close() { + tracing::warn!(path = %tmp_str, err = %e, "failed to clean up temp file"); + } + + match result { + Ok((stdout, stderr)) => { + let output = if stdout.trim().is_empty() && !stderr.trim().is_empty() { + // Some MicroPython output goes to stderr (e.g. exceptions). + stderr.trim().to_string() + } else { + stdout.trim().to_string() + }; + + Ok(ToolResult { + success: true, + output: if output.is_empty() { + format!("Code executed on {alias} — no output produced.") + } else { + format!("Output from {alias}:\n{output}") + }, + error: None, + }) + } + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to execute code on {alias}: {e}")), + }), + } + } +} + +// ── port wait helper ────────────────────────────────────────────────────────── + +/// Poll for a specific serial port to reappear after a device reset. +/// +/// Returns `true` if the port exists within the timeout, `false` otherwise. +async fn wait_for_port( + port_path: &str, + timeout: std::time::Duration, + interval: std::time::Duration, +) -> bool { + let deadline = tokio::time::Instant::now() + timeout; + while tokio::time::Instant::now() < deadline { + if std::path::Path::new(port_path).exists() { + return true; + } + tokio::time::sleep(interval).await; + } + false +} + +/// Factory function: create all Phase 7 dynamic code tools. +pub fn device_code_tools(registry: Arc>) -> Vec> { + vec![ + Box::new(DeviceReadCodeTool::new(registry.clone())), + Box::new(DeviceWriteCodeTool::new(registry.clone())), + Box::new(DeviceExecTool::new(registry)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_registry() -> Arc> { + Arc::new(RwLock::new(DeviceRegistry::new())) + } + + // ── DeviceReadCodeTool ─────────────────────────────────────────── + + #[test] + fn device_read_code_name() { + let tool = DeviceReadCodeTool::new(empty_registry()); + assert_eq!(tool.name(), "device_read_code"); + } + + #[test] + fn device_read_code_schema_valid() { + let tool = DeviceReadCodeTool::new(empty_registry()); + let schema = tool.parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"]["device"].is_object()); + } + + #[tokio::test] + async fn device_read_code_no_device_returns_error() { + let tool = DeviceReadCodeTool::new(empty_registry()); + let result = tool.execute(json!({})).await.unwrap(); + assert!(!result.success); + assert!( + result.error.as_deref().unwrap_or("").contains("no device"), + "expected 'no device' error; got: {:?}", + result.error + ); + } + + // ── DeviceWriteCodeTool ────────────────────────────────────────── + + #[test] + fn device_write_code_name() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + assert_eq!(tool.name(), "device_write_code"); + } + + #[test] + fn device_write_code_schema_requires_code() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!( + required.iter().any(|v| v.as_str() == Some("code")), + "code should be required" + ); + } + + #[tokio::test] + async fn device_write_code_empty_code_rejected() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + let result = tool.execute(json!({"code": ""})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("empty")); + } + + #[tokio::test] + async fn device_write_code_no_device_returns_error() { + let tool = DeviceWriteCodeTool::new(empty_registry()); + let result = tool + .execute(json!({"code": "print('hello')"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("no device"),); + } + + // ── DeviceExecTool ─────────────────────────────────────────────── + + #[test] + fn device_exec_name() { + let tool = DeviceExecTool::new(empty_registry()); + assert_eq!(tool.name(), "device_exec"); + } + + #[test] + fn device_exec_schema_requires_code() { + let tool = DeviceExecTool::new(empty_registry()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!( + required.iter().any(|v| v.as_str() == Some("code")), + "code should be required" + ); + } + + #[tokio::test] + async fn device_exec_empty_code_rejected() { + let tool = DeviceExecTool::new(empty_registry()); + let result = tool.execute(json!({"code": " "})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("empty")); + } + + #[tokio::test] + async fn device_exec_no_device_returns_error() { + let tool = DeviceExecTool::new(empty_registry()); + let result = tool.execute(json!({"code": "print(1+1)"})).await.unwrap(); + assert!(!result.success); + assert!(result.error.as_deref().unwrap_or("").contains("no device"),); + } + + // ── Factory ────────────────────────────────────────────────────── + + #[test] + fn factory_returns_three_tools() { + let reg = empty_registry(); + let tools = device_code_tools(reg); + assert_eq!(tools.len(), 3); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"device_read_code")); + assert!(names.contains(&"device_write_code")); + assert!(names.contains(&"device_exec")); + } + + #[test] + fn all_specs_valid() { + let reg = empty_registry(); + let tools = device_code_tools(reg); + for tool in &tools { + let spec = tool.spec(); + assert!(!spec.name.is_empty()); + assert!(!spec.description.is_empty()); + assert_eq!(spec.parameters["type"], "object"); + } + } +} diff --git a/src/hardware/pico_flash.rs b/src/hardware/pico_flash.rs new file mode 100644 index 000000000..f604ae51f --- /dev/null +++ b/src/hardware/pico_flash.rs @@ -0,0 +1,296 @@ +//! `pico_flash` tool — flash ZeroClaw firmware to a Pico in BOOTSEL mode. +//! +//! # Happy path +//! 1. User holds BOOTSEL while plugging in Pico → RPI-RP2 drive appears. +//! 2. User asks "flash my pico". +//! 3. LLM calls `pico_flash(confirm=true)`. +//! 4. Tool copies UF2 to RPI-RP2 drive; Pico reboots into MicroPython. +//! 5. Tool waits up to 20 s for `/dev/cu.usbmodem*` to appear. +//! 6. Tool deploys `main.py` via `mpremote` and resets the Pico. +//! 7. Tool waits for the serial port to reappear after reset. +//! 8. Tool returns success; user restarts ZeroClaw to get `pico0`. + +use super::device::DeviceRegistry; +use super::uf2; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// How long to wait for the Pico serial port after flashing (seconds). +const PORT_WAIT_SECS: u64 = 20; + +/// How often to poll for the serial port. +const PORT_POLL_MS: u64 = 500; + +// ── PicoFlashTool ───────────────────────────────────────────────────────────── + +/// Tool: flash ZeroClaw MicroPython firmware to a Pico in BOOTSEL mode. +/// +/// The Pico must be connected with BOOTSEL held so it mounts as `RPI-RP2`. +/// After flashing, the tool deploys `main.py` via `mpremote`, then reconnects +/// the serial transport in the [`DeviceRegistry`] so subsequent `gpio_write` +/// calls work immediately without restarting ZeroClaw. +pub struct PicoFlashTool { + registry: Arc>, +} + +impl PicoFlashTool { + pub fn new(registry: Arc>) -> Self { + Self { registry } + } +} + +#[async_trait] +impl Tool for PicoFlashTool { + fn name(&self) -> &str { + "pico_flash" + } + + fn description(&self) -> &str { + "Flash ZeroClaw firmware to a Raspberry Pi Pico in BOOTSEL mode. \ + The Pico must be connected with the BOOTSEL button held (shows as RPI-RP2 drive in Finder). \ + After flashing the Pico reboots, main.py is deployed, and the serial \ + connection is refreshed automatically — no restart needed." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "description": "Set to true to confirm flashing the Pico firmware" + } + }, + "required": ["confirm"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + // ── 1. Require explicit confirmation ────────────────────────────── + let confirmed = args + .get("confirm") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !confirmed { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "Set confirm=true to proceed with flashing. \ + This will overwrite the firmware on the connected Pico." + .to_string(), + ), + }); + } + + // ── 2. Detect BOOTSEL-mode Pico ─────────────────────────────────── + let mount = match uf2::find_rpi_rp2_mount() { + Some(m) => m, + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No Pico in BOOTSEL mode found (RPI-RP2 drive not detected). \ + Hold the BOOTSEL button while plugging the Pico in via USB, \ + then try again." + .to_string(), + ), + }); + } + }; + + tracing::info!(mount = %mount.display(), "RPI-RP2 volume found"); + + // ── 3. Ensure firmware files are extracted ──────────────────────── + let firmware_dir = match uf2::ensure_firmware_dir() { + Ok(d) => d, + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("firmware error: {e}")), + }); + } + }; + + // ── 4. Flash UF2 ───────────────────────────────────────────────── + if let Err(e) = uf2::flash_uf2(&mount, &firmware_dir).await { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("flash failed: {e}")), + }); + } + + // ── 5. Wait for serial port to appear ───────────────────────────── + let port = uf2::wait_for_serial_port( + std::time::Duration::from_secs(PORT_WAIT_SECS), + std::time::Duration::from_millis(PORT_POLL_MS), + ) + .await; + + let port = match port { + Some(p) => p, + None => { + // Flash likely succeeded even if port didn't appear in time — + // some host systems are slower to enumerate the new port. + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "UF2 copied to {} but serial port did not appear within {PORT_WAIT_SECS}s. \ + Unplug and replug the Pico, then run:\n \ + mpremote connect cp ~/.zeroclaw/firmware/pico/main.py :main.py + reset", + mount.display() + )), + }); + } + }; + + tracing::info!(port = %port.display(), "Pico serial port online after UF2 flash"); + + // ── 6. Deploy main.py via mpremote ──────────────────────────────── + if let Err(e) = uf2::deploy_main_py(&port, &firmware_dir).await { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("main.py deploy failed: {e}")), + }); + } + + // ── 7. Wait for serial port after mpremote reset ────────────────── + // + // mpremote resets the Pico so the serial port disappears briefly. + // Give the OS a moment to drop the old entry before polling. + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + let final_port = uf2::wait_for_serial_port( + std::time::Duration::from_secs(PORT_WAIT_SECS), + std::time::Duration::from_millis(PORT_POLL_MS), + ) + .await; + + // ── 8. Reconnect serial transport in DeviceRegistry ────────────── + // + // The old transport still points at a stale port handle from before + // the flash. Reconnect so gpio_write works immediately. + let reconnect_result = match &final_port { + Some(p) => { + let port_str = p.to_string_lossy(); + let mut reg = self.registry.write().await; + // Try to find a pico alias in the registry. + match reg.aliases().into_iter().find(|a| a.starts_with("pico")) { + Some(a) => { + let alias = a.to_string(); + reg.reconnect(&alias, Some(&port_str)).await + } + None => Err(anyhow::anyhow!( + "no pico alias found in registry; cannot reconnect transport" + )), + } + } + None => Err(anyhow::anyhow!("no serial port to reconnect")), + }; + + // ── 9. Return result ────────────────────────────────────────────── + match final_port { + Some(p) => { + let port_str = p.display().to_string(); + let reconnected = reconnect_result.is_ok(); + if reconnected { + tracing::info!(port = %port_str, "Pico online with main.py — transport reconnected"); + } else { + let err = reconnect_result.unwrap_err(); + tracing::warn!(port = %port_str, err = %err, "Pico online but reconnect failed"); + } + let suffix = if reconnected { + "pico0 is ready — you can use gpio_write immediately." + } else { + "Restart ZeroClaw to reconnect as pico0." + }; + Ok(ToolResult { + success: true, + output: format!( + "Pico flashed and main.py deployed successfully. \ + Firmware is online at {port_str}. {suffix}" + ), + error: None, + }) + } + None => Ok(ToolResult { + success: true, + output: format!( + "Pico flashed and main.py deployed. \ + Serial port did not reappear within {PORT_WAIT_SECS}s after reset — \ + unplug and replug the Pico, then restart ZeroClaw to connect as pico0." + ), + error: None, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::device::DeviceRegistry; + use super::*; + + fn tool() -> PicoFlashTool { + let registry = Arc::new(RwLock::new(DeviceRegistry::new())); + PicoFlashTool::new(registry) + } + + #[test] + fn name_is_pico_flash() { + let t = tool(); + assert_eq!(t.name(), "pico_flash"); + } + + #[test] + fn schema_requires_confirm() { + let schema = tool().parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!( + required.iter().any(|v| v.as_str() == Some("confirm")), + "confirm should be required" + ); + } + + #[tokio::test] + async fn execute_without_confirm_returns_error() { + let result = tool() + .execute(serde_json::json!({"confirm": false})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.is_some()); + let err = result.error.unwrap(); + assert!( + err.contains("confirm=true"), + "error should mention confirm=true; got: {err}" + ); + } + + #[tokio::test] + async fn execute_missing_confirm_returns_error() { + let result = tool().execute(serde_json::json!({})).await.unwrap(); + assert!(!result.success); + } + + #[tokio::test] + async fn execute_with_confirm_true_but_no_pico_returns_error() { + // In CI there's no Pico attached — the tool should report missing device, not panic. + let result = tool() + .execute(serde_json::json!({"confirm": true})) + .await + .unwrap(); + // Either success (if a Pico happens to be connected) or the BOOTSEL error. + // What must NOT happen: panic or anyhow error propagation. + let _ = result; // just verify it didn't panic + } +} diff --git a/src/hardware/serial.rs b/src/hardware/serial.rs new file mode 100644 index 000000000..960bed2b5 --- /dev/null +++ b/src/hardware/serial.rs @@ -0,0 +1,298 @@ +//! Hardware serial transport — newline-delimited JSON over USB CDC. +//! +//! Implements the [`Transport`] trait with **lazy port opening**: the port is +//! opened for each `send()` call and closed immediately after the response is +//! received. This means multiple tools can use the same device path without +//! one holding the port exclusively. +//! +//! Wire protocol (ZeroClaw serial JSON): +//! ```text +//! Host → Device: {"cmd":"gpio_write","params":{"pin":25,"value":1}}\n +//! Device → Host: {"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n +//! ``` +//! +//! All I/O is wrapped in `tokio::time::timeout` — no blocking reads. + +use super::{ + protocol::{ZcCommand, ZcResponse}, + transport::{Transport, TransportError, TransportKind}, +}; +use async_trait::async_trait; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio_serial::SerialPortBuilderExt; + +/// Default timeout for a single send→receive round-trip (seconds). +const SEND_TIMEOUT_SECS: u64 = 5; + +/// Default baud rate for ZeroClaw serial devices. +pub const DEFAULT_BAUD: u32 = 115_200; + +/// Timeout for the ping handshake during device discovery (milliseconds). +const PING_TIMEOUT_MS: u64 = 300; + +/// Allowed serial device path prefixes — reject arbitrary paths for security. +/// Uses the shared allowlist from `crate::util`. +use crate::util::is_serial_path_allowed as is_path_allowed; + +/// Serial transport for ZeroClaw hardware devices. +/// +/// The port is **opened lazily** on each `send()` call and released immediately +/// after the response is read. This avoids exclusive-hold conflicts between +/// multiple tools or processes. +pub struct HardwareSerialTransport { + port_path: String, + baud_rate: u32, +} + +impl HardwareSerialTransport { + /// Create a new lazy-open serial transport. + /// + /// Does NOT open the port — that happens on the first `send()` call. + pub fn new(port_path: impl Into, baud_rate: u32) -> Self { + Self { + port_path: port_path.into(), + baud_rate, + } + } + + /// Create with the default baud rate (115 200). + pub fn with_default_baud(port_path: impl Into) -> Self { + Self::new(port_path, DEFAULT_BAUD) + } + + /// Port path this transport is bound to. + pub fn port_path(&self) -> &str { + &self.port_path + } + + /// Attempt a ping handshake to verify ZeroClaw firmware is running. + /// + /// Opens the port, sends `{"cmd":"ping","params":{}}`, waits up to + /// `PING_TIMEOUT_MS` for a response with `data.firmware == "zeroclaw"`. + /// + /// Returns `true` if a ZeroClaw device responds, `false` otherwise. + /// This method never returns an error — discovery must not hang on failure. + pub async fn ping_handshake(&self) -> bool { + let ping = ZcCommand::simple("ping"); + let json = match serde_json::to_string(&ping) { + Ok(j) => j, + Err(_) => return false, + }; + let result = tokio::time::timeout( + std::time::Duration::from_millis(PING_TIMEOUT_MS), + do_send(&self.port_path, self.baud_rate, &json), + ) + .await; + + match result { + Ok(Ok(resp)) => { + // Accept if firmware field is "zeroclaw" (in data or top-level) + resp.ok + && resp + .data + .get("firmware") + .and_then(|v| v.as_str()) + .map(|s| s == "zeroclaw") + .unwrap_or(false) + } + _ => false, + } + } +} + +#[async_trait] +impl Transport for HardwareSerialTransport { + async fn send(&self, cmd: &ZcCommand) -> Result { + if !is_path_allowed(&self.port_path) { + return Err(TransportError::Other(format!( + "serial path not allowed: {}", + self.port_path + ))); + } + + let json = serde_json::to_string(cmd) + .map_err(|e| TransportError::Protocol(format!("failed to serialize command: {e}")))?; + // Log command name only — never log the full payload (may contain large or sensitive data). + tracing::info!(port = %self.port_path, cmd = %cmd.cmd, "serial send"); + + tokio::time::timeout( + std::time::Duration::from_secs(SEND_TIMEOUT_SECS), + do_send(&self.port_path, self.baud_rate, &json), + ) + .await + .map_err(|_| TransportError::Timeout(SEND_TIMEOUT_SECS))? + } + + fn kind(&self) -> TransportKind { + TransportKind::Serial + } + + fn is_connected(&self) -> bool { + // Lightweight connectivity check: the device file must exist. + std::path::Path::new(&self.port_path).exists() + } +} + +/// Open the port, write the command, read one response line, return the parsed response. +/// +/// This is the inner function wrapped with `tokio::time::timeout` by the caller. +/// Do NOT add a timeout here — the outer caller owns the deadline. +async fn do_send(path: &str, baud: u32, json: &str) -> Result { + // Open port lazily — released when this function returns + let mut port = tokio_serial::new(path, baud) + .open_native_async() + .map_err(|e| { + // Match on the error kind for robust cross-platform disconnect detection. + match e.kind { + tokio_serial::ErrorKind::NoDevice => TransportError::Disconnected, + tokio_serial::ErrorKind::Io(io_kind) if io_kind == std::io::ErrorKind::NotFound => { + TransportError::Disconnected + } + _ => TransportError::Other(format!("failed to open {path}: {e}")), + } + })?; + + // Write command line + port.write_all(format!("{json}\n").as_bytes()) + .await + .map_err(TransportError::Io)?; + port.flush().await.map_err(TransportError::Io)?; + + // Read response line — port is moved into BufReader; write phase complete + let mut reader = BufReader::new(port); + let mut response_line = String::new(); + reader + .read_line(&mut response_line) + .await + .map_err(|e: std::io::Error| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + TransportError::Disconnected + } else { + TransportError::Io(e) + } + })?; + + let trimmed = response_line.trim(); + if trimmed.is_empty() { + return Err(TransportError::Protocol( + "empty response from device".to_string(), + )); + } + + serde_json::from_str(trimmed).map_err(|e| { + TransportError::Protocol(format!("invalid JSON response: {e} — got: {trimmed:?}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serial_transport_new_stores_path_and_baud() { + let t = HardwareSerialTransport::new("/dev/ttyACM0", 115_200); + assert_eq!(t.port_path(), "/dev/ttyACM0"); + assert_eq!(t.baud_rate, 115_200); + } + + #[test] + fn serial_transport_default_baud() { + let t = HardwareSerialTransport::with_default_baud("/dev/ttyACM0"); + assert_eq!(t.baud_rate, DEFAULT_BAUD); + } + + #[test] + fn serial_transport_kind_is_serial() { + let t = HardwareSerialTransport::with_default_baud("/dev/ttyACM0"); + assert_eq!(t.kind(), TransportKind::Serial); + } + + #[test] + fn is_connected_false_for_nonexistent_path() { + let t = HardwareSerialTransport::with_default_baud("/dev/ttyACM_does_not_exist_99"); + assert!(!t.is_connected()); + } + + #[test] + fn allowed_paths_accept_valid_prefixes() { + // Linux-only paths + #[cfg(target_os = "linux")] + { + assert!(is_path_allowed("/dev/ttyACM0")); + assert!(is_path_allowed("/dev/ttyUSB1")); + } + // macOS-only paths + #[cfg(target_os = "macos")] + { + assert!(is_path_allowed("/dev/tty.usbmodem14101")); + assert!(is_path_allowed("/dev/cu.usbmodem14201")); + assert!(is_path_allowed("/dev/tty.usbserial-1410")); + assert!(is_path_allowed("/dev/cu.usbserial-1410")); + } + // Windows-only paths + #[cfg(target_os = "windows")] + assert!(is_path_allowed("COM3")); + // Cross-platform: macOS paths always work on macOS, Linux paths on Linux + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + assert!(is_path_allowed("/dev/ttyACM0")); + assert!(is_path_allowed("/dev/tty.usbmodem14101")); + assert!(is_path_allowed("COM3")); + } + } + + #[test] + fn allowed_paths_reject_invalid_prefixes() { + assert!(!is_path_allowed("/dev/sda")); + assert!(!is_path_allowed("/etc/passwd")); + assert!(!is_path_allowed("/tmp/evil")); + assert!(!is_path_allowed("")); + } + + #[tokio::test] + async fn send_rejects_disallowed_path() { + let t = HardwareSerialTransport::new("/dev/sda", 115_200); + let result = t.send(&ZcCommand::simple("ping")).await; + assert!(matches!(result, Err(TransportError::Other(_)))); + } + + #[tokio::test] + async fn send_returns_disconnected_for_missing_device() { + // Use a platform-appropriate path that passes the serialpath allowlist + // but refers to a device that doesn't actually exist. + #[cfg(target_os = "linux")] + let path = "/dev/ttyACM_phase2_test_99"; + #[cfg(target_os = "macos")] + let path = "/dev/tty.usbmodemfake9900"; + #[cfg(target_os = "windows")] + let path = "COM99"; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + let path = "/dev/ttyACM_phase2_test_99"; + + let t = HardwareSerialTransport::new(path, 115_200); + let result = t.send(&ZcCommand::simple("ping")).await; + // Missing device → Disconnected or Timeout (system-dependent) + assert!( + matches!( + result, + Err(TransportError::Disconnected | TransportError::Timeout(_)) + ), + "expected Disconnected or Timeout, got {result:?}" + ); + } + + #[tokio::test] + async fn ping_handshake_returns_false_for_missing_device() { + #[cfg(target_os = "linux")] + let path = "/dev/ttyACM_phase2_test_99"; + #[cfg(target_os = "macos")] + let path = "/dev/tty.usbmodemfake9900"; + #[cfg(target_os = "windows")] + let path = "COM99"; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + let path = "/dev/ttyACM_phase2_test_99"; + + let t = HardwareSerialTransport::new(path, 115_200); + assert!(!t.ping_handshake().await); + } +} diff --git a/src/hardware/uf2.rs b/src/hardware/uf2.rs new file mode 100644 index 000000000..b0ba8fabe --- /dev/null +++ b/src/hardware/uf2.rs @@ -0,0 +1,351 @@ +//! UF2 flashing support — detect BOOTSEL-mode Pico and deploy firmware. +//! +//! # Workflow +//! 1. [`find_rpi_rp2_mount`] — check well-known mount points for the RPI-RP2 volume +//! that appears when a Pico is held in BOOTSEL mode. +//! 2. [`ensure_firmware_dir`] — extract the bundled firmware files to +//! `~/.zeroclaw/firmware/pico/` if they aren't there yet. +//! 3. [`flash_uf2`] — copy the UF2 to the mount point; the Pico reboots automatically. +//! +//! # Embedded assets +//! Both firmware files are compiled into the binary with `include_bytes!` so +//! users never need to download them separately. + +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; + +// ── Embedded firmware ───────────────────────────────────────────────────────── + +/// MicroPython UF2 binary — copied to RPI-RP2 to install the base runtime. +const PICO_UF2: &[u8] = include_bytes!("../firmware/pico/zeroclaw-pico.uf2"); + +/// ZeroClaw serial protocol handler — written to the Pico after MicroPython boots. +pub const PICO_MAIN_PY: &[u8] = include_bytes!("../firmware/pico/main.py"); + +/// UF2 magic word 1 (little-endian bytes at offset 0 of every UF2 block). +const UF2_MAGIC1: [u8; 4] = [0x55, 0x46, 0x32, 0x0A]; + +// ── Volume detection ────────────────────────────────────────────────────────── + +/// Find the RPI-RP2 mount point if a Pico is connected in BOOTSEL mode. +/// +/// Checks: +/// - macOS: `/Volumes/RPI-RP2` +/// - Linux: `/media/*/RPI-RP2` and `/run/media/*/RPI-RP2` +pub fn find_rpi_rp2_mount() -> Option { + // macOS + let mac = PathBuf::from("/Volumes/RPI-RP2"); + if mac.exists() { + return Some(mac); + } + + // Linux — /media//RPI-RP2 or /run/media//RPI-RP2 + for base in &["/media", "/run/media"] { + if let Ok(entries) = std::fs::read_dir(base) { + for entry in entries.flatten() { + let candidate = entry.path().join("RPI-RP2"); + if candidate.exists() { + return Some(candidate); + } + } + } + } + + None +} + +// ── Firmware directory management ───────────────────────────────────────────── + +/// Ensure `~/.zeroclaw/firmware/pico/` exists and contains the bundled assets. +/// +/// Files are only written if they are absent — existing files are never overwritten +/// so users can substitute their own firmware. +/// +/// Returns the firmware directory path. +pub fn ensure_firmware_dir() -> Result { + use directories::BaseDirs; + + let base = BaseDirs::new().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?; + + let firmware_dir = base + .home_dir() + .join(".zeroclaw") + .join("firmware") + .join("pico"); + std::fs::create_dir_all(&firmware_dir)?; + + // UF2 — validate magic before writing so a broken stub is caught early. + let uf2_path = firmware_dir.join("zeroclaw-pico.uf2"); + if !uf2_path.exists() { + if PICO_UF2.len() < 8 || PICO_UF2[..4] != UF2_MAGIC1 { + bail!( + "Bundled UF2 is a placeholder — download the real MicroPython UF2 from \ + https://micropython.org/download/RPI_PICO/ and place it at \ + src/firmware/pico/zeroclaw-pico.uf2, then rebuild ZeroClaw." + ); + } + std::fs::write(&uf2_path, PICO_UF2)?; + tracing::info!(path = %uf2_path.display(), "extracted bundled UF2"); + } + + // main.py — always check UF2 magic even if path already exists (user may + // have placed a stub). main.py has no such check — it's just text. + let main_py_path = firmware_dir.join("main.py"); + if !main_py_path.exists() { + std::fs::write(&main_py_path, PICO_MAIN_PY)?; + tracing::info!(path = %main_py_path.display(), "extracted bundled main.py"); + } + + Ok(firmware_dir) +} + +// ── Flashing ────────────────────────────────────────────────────────────────── + +/// Copy the UF2 file to the RPI-RP2 mount point. +/// +/// macOS often returns "Operation not permitted" for `std::fs::copy` on FAT +/// volumes presented by BOOTSEL-mode Picos. We try four approaches in order +/// and return a clear manual-fallback message if all fail: +/// +/// 1. `std::fs::copy` — fast, no subprocess; works on most Linux setups. +/// 2. `cp ` — bypasses some macOS VFS permission layers. +/// 3. `sudo cp …` — escalates for locked volumes. +/// 4. Error — instructs the user to run the `sudo cp` manually. +pub async fn flash_uf2(mount_point: &Path, firmware_dir: &Path) -> Result<()> { + let uf2_src = firmware_dir.join("zeroclaw-pico.uf2"); + let uf2_dst = mount_point.join("firmware.uf2"); + let src_str = uf2_src.to_string_lossy().into_owned(); + let dst_str = uf2_dst.to_string_lossy().into_owned(); + + tracing::info!( + src = %src_str, + dst = %dst_str, + "flashing UF2" + ); + + // Validate UF2 magic before any copy attempt — prevents flashing a stub. + let data = std::fs::read(&uf2_src)?; + if data.len() < 8 || data[..4] != UF2_MAGIC1 { + bail!( + "UF2 at {} does not look like a valid UF2 file (magic mismatch). \ + Download from https://micropython.org/download/RPI_PICO/ and delete \ + the existing file so ZeroClaw can re-extract it.", + uf2_src.display() + ); + } + + // ── Attempt 1: std::fs::copy (works on Linux, sometimes blocked on macOS) ─ + { + let src = uf2_src.clone(); + let dst = uf2_dst.clone(); + let result = tokio::task::spawn_blocking(move || std::fs::copy(&src, &dst)) + .await + .map_err(|e| anyhow::anyhow!("copy task panicked: {e}")); + + match result { + Ok(Ok(_)) => { + tracing::info!("UF2 copy complete (std::fs::copy) — Pico will reboot"); + return Ok(()); + } + Ok(Err(e)) => tracing::warn!("std::fs::copy failed ({}), trying cp", e), + Err(e) => tracing::warn!("std::fs::copy task failed ({}), trying cp", e), + } + } + + // ── Attempt 2: cp via subprocess ────────────────────────────────────────── + { + /// Timeout for subprocess copy attempts (seconds). + const CP_TIMEOUT_SECS: u64 = 10; + + let out = tokio::time::timeout( + std::time::Duration::from_secs(CP_TIMEOUT_SECS), + tokio::process::Command::new("cp") + .arg(&src_str) + .arg(&dst_str) + .output(), + ) + .await; + + match out { + Err(_elapsed) => { + tracing::warn!("cp timed out after {}s, trying sudo cp", CP_TIMEOUT_SECS); + } + Ok(Ok(o)) if o.status.success() => { + tracing::info!("UF2 copy complete (cp) — Pico will reboot"); + return Ok(()); + } + Ok(Ok(o)) => { + let stderr = String::from_utf8_lossy(&o.stderr); + tracing::warn!("cp failed ({}), trying sudo cp", stderr.trim()); + } + Ok(Err(e)) => tracing::warn!("cp spawn failed ({}), trying sudo cp", e), + } + } + + // ── Attempt 3: sudo cp (non-interactive) ───────────────────────────────── + { + const SUDO_CP_TIMEOUT_SECS: u64 = 10; + + let out = tokio::time::timeout( + std::time::Duration::from_secs(SUDO_CP_TIMEOUT_SECS), + tokio::process::Command::new("sudo") + .args(["-n", "cp", &src_str, &dst_str]) + .output(), + ) + .await; + + match out { + Err(_elapsed) => { + tracing::warn!("sudo cp timed out after {}s", SUDO_CP_TIMEOUT_SECS); + } + Ok(Ok(o)) if o.status.success() => { + tracing::info!("UF2 copy complete (sudo cp) — Pico will reboot"); + return Ok(()); + } + Ok(Ok(o)) => { + let stderr = String::from_utf8_lossy(&o.stderr); + tracing::warn!("sudo cp failed: {}", stderr.trim()); + } + Ok(Err(e)) => tracing::warn!("sudo cp spawn failed: {}", e), + } + } + + // ── All attempts failed — give the user a clear manual command ──────────── + bail!( + "All copy methods failed. Run this command manually, then restart ZeroClaw:\n\ + \n sudo cp {src_str} {dst_str}\n" + ) +} + +/// Wait for `/dev/cu.usbmodem*` (macOS) or `/dev/ttyACM*` (Linux) to appear. +/// +/// Polls every `interval` for up to `timeout`. Returns the first matching path +/// found, or `None` if the deadline expires. +pub async fn wait_for_serial_port( + timeout: std::time::Duration, + interval: std::time::Duration, +) -> Option { + #[cfg(target_os = "macos")] + let patterns = &["/dev/cu.usbmodem*"]; + #[cfg(target_os = "linux")] + let patterns = &["/dev/ttyACM*"]; + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let patterns: &[&str] = &[]; + + let deadline = tokio::time::Instant::now() + timeout; + + loop { + for pattern in *patterns { + if let Ok(mut hits) = glob::glob(pattern) { + if let Some(Ok(path)) = hits.next() { + return Some(path); + } + } + } + + if tokio::time::Instant::now() >= deadline { + return None; + } + + tokio::time::sleep(interval).await; + } +} + +// ── Deploy main.py via mpremote ─────────────────────────────────────────────── + +/// Copy `main.py` to the Pico's MicroPython filesystem and soft-reset it. +/// +/// After the UF2 is flashed the Pico reboots into MicroPython but has no +/// `main.py` on its internal filesystem. This function uses `mpremote` to +/// upload the bundled `main.py` and issue a reset so it starts executing +/// immediately. +/// +/// Returns `Ok(())` on success or an error with a helpful fallback command. +pub async fn deploy_main_py(port: &Path, firmware_dir: &Path) -> Result<()> { + let main_py_src = firmware_dir.join("main.py"); + let src_str = main_py_src.to_string_lossy().into_owned(); + let port_str = port.to_string_lossy().into_owned(); + + if !main_py_src.exists() { + bail!( + "main.py not found at {} — run ensure_firmware_dir() first", + main_py_src.display() + ); + } + + tracing::info!( + src = %src_str, + port = %port_str, + "deploying main.py via mpremote" + ); + + let out = tokio::process::Command::new("mpremote") + .args([ + "connect", &port_str, "cp", &src_str, ":main.py", "+", "reset", + ]) + .output() + .await; + + match out { + Ok(o) if o.status.success() => { + tracing::info!("main.py deployed and Pico reset via mpremote"); + Ok(()) + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr); + bail!( + "mpremote failed (exit {}): {}.\n\ + Run manually:\n mpremote connect {port_str} cp {src_str} :main.py + reset", + o.status, + stderr.trim() + ) + } + Err(e) => { + bail!( + "mpremote not found or could not start ({e}).\n\ + Install it with: pip install mpremote\n\ + Then run: mpremote connect {port_str} cp {src_str} :main.py + reset" + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pico_uf2_has_valid_magic() { + assert!( + PICO_UF2.len() >= 8, + "bundled UF2 too small ({} bytes) — replace with real MicroPython UF2", + PICO_UF2.len() + ); + assert_eq!( + &PICO_UF2[..4], + &UF2_MAGIC1, + "bundled UF2 has wrong magic — replace with real MicroPython UF2 from \ + https://micropython.org/download/RPI_PICO/" + ); + } + + #[test] + fn pico_main_py_is_non_empty() { + assert!(!PICO_MAIN_PY.is_empty(), "bundled main.py is empty"); + } + + #[test] + fn pico_main_py_contains_zeroclaw_marker() { + let src = std::str::from_utf8(PICO_MAIN_PY).expect("main.py is not valid UTF-8"); + assert!( + src.contains("zeroclaw"), + "main.py should contain 'zeroclaw' firmware marker" + ); + } + + #[test] + fn find_rpi_rp2_mount_returns_none_when_not_connected() { + // This test runs on CI without a Pico attached — just verify it doesn't panic. + let _ = find_rpi_rp2_mount(); // may be Some or None depending on environment + } +}