From 8eeea3fca1d9b126ad74e8616ea0c7f4e17dedcc Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Sat, 28 Feb 2026 19:57:12 -0500 Subject: [PATCH] feat(hardware): add device registry and serial transport foundations --- src/hardware/device.rs | 799 ++++++++++++++++++++++++++++++++++++++ src/hardware/discover.rs | 169 +++++++- src/hardware/mod.rs | 19 + src/hardware/protocol.rs | 148 +++++++ src/hardware/registry.rs | 39 ++ src/hardware/serial.rs | 298 ++++++++++++++ src/hardware/transport.rs | 112 ++++++ src/util.rs | 52 +++ 8 files changed, 1630 insertions(+), 6 deletions(-) create mode 100644 src/hardware/device.rs create mode 100644 src/hardware/protocol.rs create mode 100644 src/hardware/serial.rs create mode 100644 src/hardware/transport.rs diff --git a/src/hardware/device.rs b/src/hardware/device.rs new file mode 100644 index 000000000..91b348069 --- /dev/null +++ b/src/hardware/device.rs @@ -0,0 +1,799 @@ +//! Device types and registry — stable aliases for discovered hardware. +//! +//! The LLM always refers to devices by alias (`"pico0"`, `"arduino0"`), never +//! by raw `/dev/` paths. The `DeviceRegistry` assigns these aliases at startup +//! and provides lookup + context building for tool execution. + +use super::transport::Transport; +use std::collections::HashMap; +use std::sync::Arc; + +// ── DeviceRuntime ───────────────────────────────────────────────────────────── + +/// The software runtime / execution environment of a device. +/// +/// Determines which host-side tooling is used for code deployment and execution. +/// Currently only [`MicroPython`](DeviceRuntime::MicroPython) is implemented; +/// other variants return a clear "not yet supported" error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceRuntime { + /// MicroPython — uses `mpremote` for code read/write/exec. + MicroPython, + /// CircuitPython — `mpremote`-compatible (future). + CircuitPython, + /// Arduino — `arduino-cli` for sketch upload (future). + Arduino, + /// STM32 / probe-rs based flashing and debugging (future). + Nucleus, + /// Linux / Raspberry Pi — ssh/shell execution (future). + Linux, +} + +impl DeviceRuntime { + /// Derive the default runtime from a [`DeviceKind`]. + pub fn from_kind(kind: &DeviceKind) -> Self { + match kind { + DeviceKind::Pico | DeviceKind::Esp32 | DeviceKind::Generic => Self::MicroPython, + DeviceKind::Arduino => Self::Arduino, + DeviceKind::Nucleo => Self::Nucleus, + } + } +} + +impl std::fmt::Display for DeviceRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MicroPython => write!(f, "MicroPython"), + Self::CircuitPython => write!(f, "CircuitPython"), + Self::Arduino => write!(f, "Arduino"), + Self::Nucleus => write!(f, "Nucleus"), + Self::Linux => write!(f, "Linux"), + } + } +} + +// ── DeviceKind ──────────────────────────────────────────────────────────────── + +/// The category of a discovered hardware device. +/// +/// Derived from USB Vendor ID or, for unknown VIDs, from a successful +/// ping handshake (which yields `Generic`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeviceKind { + /// Raspberry Pi Pico / Pico W (VID `0x2E8A`). + Pico, + /// Arduino Uno, Mega, etc. (VID `0x2341`). + Arduino, + /// ESP32 via CP2102 bridge (VID `0x10C4`). + Esp32, + /// STM32 Nucleo (VID `0x0483`). + Nucleo, + /// Unknown VID that passed the ZeroClaw firmware ping handshake. + Generic, +} + +impl DeviceKind { + /// Derive the device kind from a USB Vendor ID. + /// Returns `None` if the VID is unknown (0 or unrecognised). + pub fn from_vid(vid: u16) -> Option { + match vid { + 0x2e8a => Some(Self::Pico), + 0x2341 => Some(Self::Arduino), + 0x10c4 => Some(Self::Esp32), + 0x0483 => Some(Self::Nucleo), + _ => None, + } + } +} + +impl std::fmt::Display for DeviceKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Pico => write!(f, "pico"), + Self::Arduino => write!(f, "arduino"), + Self::Esp32 => write!(f, "esp32"), + Self::Nucleo => write!(f, "nucleo"), + Self::Generic => write!(f, "generic"), + } + } +} + +/// Capability flags for a connected device. +/// +/// Populated from device handshake or static board metadata. +/// Tools can check capabilities before attempting unsupported operations. +#[derive(Debug, Clone, Default)] +pub struct DeviceCapabilities { + pub gpio: bool, + pub i2c: bool, + pub spi: bool, + pub swd: bool, + pub uart: bool, + pub adc: bool, + pub pwm: bool, +} + +/// A discovered and registered hardware device. +#[derive(Debug, Clone)] +pub struct Device { + /// Stable session alias (e.g. `"pico0"`, `"arduino0"`, `"nucleo0"`). + pub alias: String, + /// Board name from registry (e.g. `"raspberry-pi-pico"`, `"arduino-uno"`). + pub board_name: String, + /// Device category derived from VID or ping handshake. + pub kind: DeviceKind, + /// Software runtime that determines how code is deployed/executed. + pub runtime: DeviceRuntime, + /// USB Vendor ID (if USB-connected). + pub vid: Option, + /// USB Product ID (if USB-connected). + pub pid: Option, + /// Raw device path (e.g. `"/dev/ttyACM0"`) — internal use only. + /// Tools MUST NOT use this directly; always go through Transport. + pub device_path: Option, + /// Architecture description (e.g. `"ARM Cortex-M0+"`). + pub architecture: Option, + /// Firmware identifier reported by device during ping handshake. + pub firmware: Option, +} + +impl Device { + /// Convenience accessor — same as `device_path` (matches the Phase 2 spec naming). + pub fn port(&self) -> Option<&str> { + self.device_path.as_deref() + } +} + +/// Context passed to hardware tools during execution. +/// +/// Provides the tool with access to the device identity, transport layer, +/// and capability flags without the tool managing connections itself. +pub struct DeviceContext { + /// The device this tool is operating on. + pub device: Arc, + /// Transport for sending commands to the device. + pub transport: Arc, + /// Device capabilities (gpio, i2c, spi, etc.). + pub capabilities: DeviceCapabilities, +} + +/// A registered device entry with its transport and capabilities. +struct RegisteredDevice { + device: Arc, + transport: Option>, + capabilities: DeviceCapabilities, +} + +/// Summary string returned by [`DeviceRegistry::prompt_summary`] when no +/// devices are registered. Exported so callers can compare against it without +/// duplicating the literal. +pub const NO_HW_DEVICES_SUMMARY: &str = "No hardware devices connected."; + +/// Registry of discovered devices with stable session aliases. +/// +/// - Scans at startup (via `hardware::discover`) +/// - Assigns aliases: `pico0`, `pico1`, `arduino0`, `nucleo0`, `device0`, etc. +/// - Provides alias-based lookup for tool dispatch +/// - Generates prompt summaries for LLM context +pub struct DeviceRegistry { + devices: HashMap, + alias_counters: HashMap, +} + +impl DeviceRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + devices: HashMap::new(), + alias_counters: HashMap::new(), + } + } + + /// Register a discovered device and assign a stable alias. + /// + /// Returns the assigned alias (e.g. `"pico0"`). + pub fn register( + &mut self, + board_name: &str, + vid: Option, + pid: Option, + device_path: Option, + architecture: Option, + ) -> String { + let prefix = alias_prefix(board_name); + let counter = self.alias_counters.entry(prefix.clone()).or_insert(0); + let alias = format!("{}{}", prefix, counter); + *counter += 1; + + let kind = vid + .and_then(DeviceKind::from_vid) + .unwrap_or(DeviceKind::Generic); + let runtime = DeviceRuntime::from_kind(&kind); + + let device = Arc::new(Device { + alias: alias.clone(), + board_name: board_name.to_string(), + kind, + runtime, + vid, + pid, + device_path, + architecture, + firmware: None, + }); + + self.devices.insert( + alias.clone(), + RegisteredDevice { + device, + transport: None, + capabilities: DeviceCapabilities::default(), + }, + ); + + alias + } + + /// Attach a transport and capabilities to a previously registered device. + /// + /// Returns `Err` when `alias` is not found in the registry (should not + /// happen in normal usage because callers pass aliases from `register`). + pub fn attach_transport( + &mut self, + alias: &str, + transport: Arc, + capabilities: DeviceCapabilities, + ) -> anyhow::Result<()> { + if let Some(entry) = self.devices.get_mut(alias) { + entry.transport = Some(transport); + entry.capabilities = capabilities; + Ok(()) + } else { + Err(anyhow::anyhow!("unknown device alias: {}", alias)) + } + } + + /// Look up a device by alias. + pub fn get_device(&self, alias: &str) -> Option> { + self.devices.get(alias).map(|e| e.device.clone()) + } + + /// Build a `DeviceContext` for a device by alias. + /// + /// Returns `None` if the alias is unknown or no transport is attached. + pub fn context(&self, alias: &str) -> Option { + self.devices.get(alias).and_then(|e| { + e.transport.as_ref().map(|t| DeviceContext { + device: e.device.clone(), + transport: t.clone(), + capabilities: e.capabilities.clone(), + }) + }) + } + + /// List all registered device aliases. + pub fn aliases(&self) -> Vec<&str> { + self.devices.keys().map(|s| s.as_str()).collect() + } + + /// Return a summary of connected devices for the LLM system prompt. + pub fn prompt_summary(&self) -> String { + if self.devices.is_empty() { + return NO_HW_DEVICES_SUMMARY.to_string(); + } + + let mut lines = vec!["Connected devices:".to_string()]; + let mut sorted_aliases: Vec<&String> = self.devices.keys().collect(); + sorted_aliases.sort(); + for alias in sorted_aliases { + let entry = &self.devices[alias]; + let status = entry + .transport + .as_ref() + .map(|t| { + if t.is_connected() { + "connected" + } else { + "disconnected" + } + }) + .unwrap_or("no transport"); + let arch = entry + .device + .architecture + .as_deref() + .unwrap_or("unknown arch"); + lines.push(format!( + " {} — {} ({}) [{}]", + alias, entry.device.board_name, arch, status + )); + } + lines.join("\n") + } + + /// Resolve a GPIO-capable device alias from tool arguments. + /// + /// If `args["device"]` is provided, uses that alias directly. + /// Otherwise, auto-selects the single GPIO-capable device, returning an + /// error description if zero or multiple GPIO devices are available. + /// + /// On success returns `(alias, DeviceContext)` — both are owned / Arc-based + /// so the caller can drop the registry lock before doing async I/O. + pub fn resolve_gpio_device( + &self, + args: &serde_json::Value, + ) -> Result<(String, DeviceContext), String> { + let device_alias: String = match args.get("device").and_then(|v| v.as_str()) { + Some(a) => a.to_string(), + None => { + let gpio_aliases: Vec = self + .aliases() + .into_iter() + .filter(|a| { + self.context(a) + .map(|c| c.capabilities.gpio) + .unwrap_or(false) + }) + .map(|a| a.to_string()) + .collect(); + match gpio_aliases.as_slice() { + [single] => single.clone(), + [] => { + return Err("no GPIO-capable device found; specify \"device\" parameter" + .to_string()); + } + _ => { + return Err(format!( + "multiple devices available ({}); specify \"device\" parameter", + gpio_aliases.join(", ") + )); + } + } + } + }; + + let ctx = self.context(&device_alias).ok_or_else(|| { + format!( + "device '{}' not found or has no transport attached", + device_alias + ) + })?; + + // Verify the device advertises GPIO capability. + if !ctx.capabilities.gpio { + return Err(format!( + "device '{}' does not support GPIO; specify a GPIO-capable device", + device_alias + )); + } + + Ok((device_alias, ctx)) + } + + /// Number of registered devices. + pub fn len(&self) -> usize { + self.devices.len() + } + + /// Whether the registry is empty. + pub fn is_empty(&self) -> bool { + self.devices.is_empty() + } + + /// Look up a device by alias (alias for `get_device` matching the Phase 2 spec). + pub fn get(&self, alias: &str) -> Option> { + self.get_device(alias) + } + + /// Return all registered devices. + pub fn all(&self) -> Vec> { + self.devices.values().map(|e| e.device.clone()).collect() + } + + /// One-line summary per device: `"pico0: raspberry-pi-pico /dev/ttyACM0"`. + /// + /// Suitable for CLI output and debug logging. + pub fn summary(&self) -> String { + if self.devices.is_empty() { + return String::new(); + } + let mut lines: Vec = self + .devices + .values() + .map(|e| { + let path = e.device.port().unwrap_or("(native)"); + format!("{}: {} {}", e.device.alias, e.device.board_name, path) + }) + .collect(); + lines.sort(); // deterministic for tests + lines.join("\n") + } + + /// Discover all connected serial devices and populate the registry. + /// + /// Steps: + /// 1. Call `discover::scan_serial_devices()` to enumerate port paths + VID/PID. + /// 2. For each device with a recognised VID: register and attach a transport. + /// 3. For unknown VID (`0`): attempt a 300 ms ping handshake; register only + /// if the device responds with ZeroClaw firmware. + /// 4. Return the populated registry. + /// + /// Returns an empty registry when no devices are found or the `hardware` + /// feature is disabled. + #[cfg(feature = "hardware")] + pub async fn discover() -> Self { + use super::{ + discover::scan_serial_devices, + serial::{HardwareSerialTransport, DEFAULT_BAUD}, + }; + + let mut registry = Self::new(); + + for info in scan_serial_devices() { + let is_known_vid = info.vid != 0; + + // For unknown VIDs, run the ping handshake before registering. + // This avoids registering random USB-serial adapters. + // If the probe succeeds we reuse the same transport instance below. + let probe_transport = if !is_known_vid { + let probe = HardwareSerialTransport::new(&info.port_path, DEFAULT_BAUD); + if !probe.ping_handshake().await { + tracing::debug!( + port = %info.port_path, + "skipping unknown device: no ZeroClaw firmware response" + ); + continue; + } + Some(probe) + } else { + None + }; + + let board_name = info.board_name.as_deref().unwrap_or("unknown").to_string(); + + let alias = registry.register( + &board_name, + if info.vid != 0 { Some(info.vid) } else { None }, + if info.pid != 0 { Some(info.pid) } else { None }, + Some(info.port_path.clone()), + info.architecture, + ); + + // For unknown-VID devices that passed ping: mark as Generic. + // (register() will have already set kind = Generic for vid=None) + + let transport: Arc = + if let Some(probe) = probe_transport { + Arc::new(probe) + } else { + Arc::new(HardwareSerialTransport::new(&info.port_path, DEFAULT_BAUD)) + }; + let caps = DeviceCapabilities { + gpio: true, // assume GPIO; Phase 3 will populate via capabilities handshake + ..DeviceCapabilities::default() + }; + registry.attach_transport(&alias, transport, caps) + .unwrap_or_else(|e| tracing::warn!(alias = %alias, err = %e, "attach_transport: unexpected unknown alias")); + + tracing::info!( + alias = %alias, + port = %info.port_path, + vid = %info.vid, + "device registered" + ); + } + + registry + } +} + +impl DeviceRegistry { + /// Reconnect a device after reboot/reflash. + /// + /// Drops the old transport, creates a fresh [`HardwareSerialTransport`] for + /// the given (or existing) port path, runs the ping handshake to confirm + /// ZeroClaw firmware is alive, and re-attaches the transport. + /// + /// Pass `new_port` when the OS assigned a different path after reboot; + /// pass `None` to reuse the device's current path. + #[cfg(feature = "hardware")] + pub async fn reconnect(&mut self, alias: &str, new_port: Option<&str>) -> anyhow::Result<()> { + use super::serial::{HardwareSerialTransport, DEFAULT_BAUD}; + + let entry = self + .devices + .get_mut(alias) + .ok_or_else(|| anyhow::anyhow!("unknown device alias: {alias}"))?; + + // Determine the port path — prefer the caller's override. + let port_path = match new_port { + Some(p) => { + // Update the device record with the new path. + let mut updated = (*entry.device).clone(); + updated.device_path = Some(p.to_string()); + entry.device = Arc::new(updated); + p.to_string() + } + None => entry + .device + .device_path + .clone() + .ok_or_else(|| anyhow::anyhow!("device {alias} has no port path"))?, + }; + + // Drop the stale transport. + entry.transport = None; + + // Create a fresh transport and verify firmware is alive. + let transport = HardwareSerialTransport::new(&port_path, DEFAULT_BAUD); + if !transport.ping_handshake().await { + anyhow::bail!( + "ping handshake failed after reconnect on {port_path} — \ + firmware may not be running" + ); + } + + entry.transport = Some(Arc::new(transport) as Arc); + entry.capabilities.gpio = true; + + tracing::info!(alias = %alias, port = %port_path, "device reconnected"); + Ok(()) + } +} + +impl Default for DeviceRegistry { + fn default() -> Self { + Self::new() + } +} + +/// Derive alias prefix from board name. +fn alias_prefix(board_name: &str) -> String { + match board_name { + s if s.starts_with("raspberry-pi-pico") || s.starts_with("pico") => "pico".to_string(), + s if s.starts_with("arduino") => "arduino".to_string(), + s if s.starts_with("esp32") || s.starts_with("esp") => "esp".to_string(), + s if s.starts_with("nucleo") || s.starts_with("stm32") => "nucleo".to_string(), + s if s.starts_with("rpi") || s == "raspberry-pi" => "rpi".to_string(), + _ => "device".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alias_prefix_pico_variants() { + assert_eq!(alias_prefix("raspberry-pi-pico"), "pico"); + assert_eq!(alias_prefix("pico-w"), "pico"); + assert_eq!(alias_prefix("pico"), "pico"); + } + + #[test] + fn alias_prefix_arduino() { + assert_eq!(alias_prefix("arduino-uno"), "arduino"); + assert_eq!(alias_prefix("arduino-mega"), "arduino"); + } + + #[test] + fn alias_prefix_esp() { + assert_eq!(alias_prefix("esp32"), "esp"); + assert_eq!(alias_prefix("esp32-s3"), "esp"); + } + + #[test] + fn alias_prefix_nucleo() { + assert_eq!(alias_prefix("nucleo-f401re"), "nucleo"); + assert_eq!(alias_prefix("stm32-discovery"), "nucleo"); + } + + #[test] + fn alias_prefix_rpi() { + assert_eq!(alias_prefix("rpi-gpio"), "rpi"); + assert_eq!(alias_prefix("raspberry-pi"), "rpi"); + } + + #[test] + fn alias_prefix_unknown() { + assert_eq!(alias_prefix("custom-board"), "device"); + } + + #[test] + fn registry_assigns_sequential_aliases() { + let mut reg = DeviceRegistry::new(); + let a1 = reg.register("raspberry-pi-pico", Some(0x2E8A), Some(0x000A), None, None); + let a2 = reg.register("raspberry-pi-pico", Some(0x2E8A), Some(0x000A), None, None); + let a3 = reg.register("arduino-uno", Some(0x2341), Some(0x0043), None, None); + + assert_eq!(a1, "pico0"); + assert_eq!(a2, "pico1"); + assert_eq!(a3, "arduino0"); + assert_eq!(reg.len(), 3); + } + + #[test] + fn registry_get_device_by_alias() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register( + "nucleo-f401re", + Some(0x0483), + Some(0x374B), + Some("/dev/ttyACM0".to_string()), + Some("ARM Cortex-M4".to_string()), + ); + + let device = reg.get_device(&alias).unwrap(); + assert_eq!(device.alias, "nucleo0"); + assert_eq!(device.board_name, "nucleo-f401re"); + assert_eq!(device.vid, Some(0x0483)); + assert_eq!(device.architecture.as_deref(), Some("ARM Cortex-M4")); + } + + #[test] + fn registry_unknown_alias_returns_none() { + let reg = DeviceRegistry::new(); + assert!(reg.get_device("nonexistent").is_none()); + assert!(reg.context("nonexistent").is_none()); + } + + #[test] + fn registry_context_none_without_transport() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register("pico", None, None, None, None); + // No transport attached → context returns None. + assert!(reg.context(&alias).is_none()); + } + + #[test] + fn registry_prompt_summary_empty() { + let reg = DeviceRegistry::new(); + assert_eq!(reg.prompt_summary(), NO_HW_DEVICES_SUMMARY); + } + + #[test] + fn registry_prompt_summary_with_devices() { + let mut reg = DeviceRegistry::new(); + reg.register( + "raspberry-pi-pico", + Some(0x2E8A), + None, + None, + Some("ARM Cortex-M0+".to_string()), + ); + let summary = reg.prompt_summary(); + assert!(summary.contains("pico0")); + assert!(summary.contains("raspberry-pi-pico")); + assert!(summary.contains("ARM Cortex-M0+")); + assert!(summary.contains("no transport")); + } + + #[test] + fn device_capabilities_default_all_false() { + let caps = DeviceCapabilities::default(); + assert!(!caps.gpio); + assert!(!caps.i2c); + assert!(!caps.spi); + assert!(!caps.swd); + assert!(!caps.uart); + assert!(!caps.adc); + assert!(!caps.pwm); + } + + #[test] + fn registry_default_is_empty() { + let reg = DeviceRegistry::default(); + assert!(reg.is_empty()); + assert_eq!(reg.len(), 0); + } + + #[test] + fn registry_aliases_returns_all() { + let mut reg = DeviceRegistry::new(); + reg.register("pico", None, None, None, None); + reg.register("arduino-uno", None, None, None, None); + let mut aliases = reg.aliases(); + aliases.sort(); + assert_eq!(aliases, vec!["arduino0", "pico0"]); + } + + // ── Phase 2 new tests ──────────────────────────────────────────────────── + + #[test] + fn device_kind_from_vid_known() { + assert_eq!(DeviceKind::from_vid(0x2e8a), Some(DeviceKind::Pico)); + assert_eq!(DeviceKind::from_vid(0x2341), Some(DeviceKind::Arduino)); + assert_eq!(DeviceKind::from_vid(0x10c4), Some(DeviceKind::Esp32)); + assert_eq!(DeviceKind::from_vid(0x0483), Some(DeviceKind::Nucleo)); + } + + #[test] + fn device_kind_from_vid_unknown() { + assert_eq!(DeviceKind::from_vid(0x0000), None); + assert_eq!(DeviceKind::from_vid(0xffff), None); + } + + #[test] + fn device_kind_display() { + assert_eq!(DeviceKind::Pico.to_string(), "pico"); + assert_eq!(DeviceKind::Arduino.to_string(), "arduino"); + assert_eq!(DeviceKind::Esp32.to_string(), "esp32"); + assert_eq!(DeviceKind::Nucleo.to_string(), "nucleo"); + assert_eq!(DeviceKind::Generic.to_string(), "generic"); + } + + #[test] + fn register_sets_kind_from_vid() { + let mut reg = DeviceRegistry::new(); + let a = reg.register("raspberry-pi-pico", Some(0x2e8a), Some(0x000a), None, None); + assert_eq!(reg.get(&a).unwrap().kind, DeviceKind::Pico); + + let b = reg.register("arduino-uno", Some(0x2341), Some(0x0043), None, None); + assert_eq!(reg.get(&b).unwrap().kind, DeviceKind::Arduino); + + let c = reg.register("unknown-device", None, None, None, None); + assert_eq!(reg.get(&c).unwrap().kind, DeviceKind::Generic); + } + + #[test] + fn device_port_returns_device_path() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register( + "raspberry-pi-pico", + Some(0x2e8a), + None, + Some("/dev/ttyACM0".to_string()), + None, + ); + let device = reg.get(&alias).unwrap(); + assert_eq!(device.port(), Some("/dev/ttyACM0")); + } + + #[test] + fn device_port_none_without_path() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register("pico", None, None, None, None); + assert!(reg.get(&alias).unwrap().port().is_none()); + } + + #[test] + fn registry_get_is_alias_for_get_device() { + let mut reg = DeviceRegistry::new(); + let alias = reg.register("raspberry-pi-pico", Some(0x2e8a), None, None, None); + let via_get = reg.get(&alias); + let via_get_device = reg.get_device(&alias); + assert!(via_get.is_some()); + assert!(via_get_device.is_some()); + assert_eq!(via_get.unwrap().alias, via_get_device.unwrap().alias); + } + + #[test] + fn registry_all_returns_every_device() { + let mut reg = DeviceRegistry::new(); + reg.register("raspberry-pi-pico", Some(0x2e8a), None, None, None); + reg.register("arduino-uno", Some(0x2341), None, None, None); + assert_eq!(reg.all().len(), 2); + } + + #[test] + fn registry_summary_one_liner_per_device() { + let mut reg = DeviceRegistry::new(); + reg.register( + "raspberry-pi-pico", + Some(0x2e8a), + None, + Some("/dev/ttyACM0".to_string()), + None, + ); + let s = reg.summary(); + assert!(s.contains("pico0")); + assert!(s.contains("raspberry-pi-pico")); + assert!(s.contains("/dev/ttyACM0")); + } + + #[test] + fn registry_summary_empty_when_no_devices() { + let reg = DeviceRegistry::new(); + assert_eq!(reg.summary(), ""); + } +} diff --git a/src/hardware/discover.rs b/src/hardware/discover.rs index 9f514da5b..1af74ca6f 100644 --- a/src/hardware/discover.rs +++ b/src/hardware/discover.rs @@ -1,10 +1,9 @@ -//! USB device discovery — enumerate devices and enrich with board registry. +//! USB and serial device discovery. //! -//! USB enumeration via `nusb` is only supported on Linux, macOS, and Windows. -//! On Android (Termux) and other unsupported platforms this module is excluded -//! from compilation; callers in `hardware/mod.rs` fall back to an empty result. - -#![cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] +//! - `list_usb_devices` — enumerate USB devices via `nusb` (cross-platform). +//! - `scan_serial_devices` — enumerate serial ports (`/dev/ttyACM*`, etc.), +//! read VID/PID from sysfs (Linux), and return `SerialDeviceInfo` records +//! ready for `DeviceRegistry` population. use super::registry; use anyhow::Result; @@ -49,3 +48,161 @@ pub fn list_usb_devices() -> Result> { Ok(devices) } + +// ── Serial port discovery ───────────────────────────────────────────────────── + +/// A serial device found during port scan, enriched with board registry data. +#[derive(Debug, Clone)] +pub struct SerialDeviceInfo { + /// Full port path (e.g. `"/dev/ttyACM0"`, `"/dev/tty.usbmodem14101"`). + pub port_path: String, + /// USB Vendor ID read from sysfs/IOKit. `0` if unknown. + pub vid: u16, + /// USB Product ID read from sysfs/IOKit. `0` if unknown. + pub pid: u16, + /// Board name from the registry, if VID/PID was recognised. + pub board_name: Option, + /// Architecture description from the registry. + pub architecture: Option, +} + +/// Scan for connected serial-port devices and return their metadata. +/// +/// On Linux: globs `/dev/ttyACM*` and `/dev/ttyUSB*`, reads VID/PID via sysfs. +/// On macOS: globs `/dev/tty.usbmodem*`, `/dev/cu.usbmodem*`, +/// `/dev/tty.usbserial*`, `/dev/cu.usbserial*` — VID/PID via nusb heuristic. +/// On other platforms or when the `hardware` feature is off: returns empty `Vec`. +/// +/// This function is **synchronous** — it only touches the filesystem (sysfs, +/// glob) and does no I/O to the device. The async ping handshake is done +/// separately in `DeviceRegistry::discover`. +#[cfg(feature = "hardware")] +pub fn scan_serial_devices() -> Vec { + #[cfg(target_os = "linux")] + { + scan_serial_devices_linux() + } + #[cfg(target_os = "macos")] + { + scan_serial_devices_macos() + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + Vec::new() + } +} + +// ── Linux: sysfs-based VID/PID correlation ─────────────────────────────────── + +#[cfg(all(feature = "hardware", target_os = "linux"))] +fn scan_serial_devices_linux() -> Vec { + let mut results = Vec::new(); + + for pattern in &["/dev/ttyACM*", "/dev/ttyUSB*"] { + let paths = match glob::glob(pattern) { + Ok(p) => p, + Err(_) => continue, + }; + + for path_result in paths.flatten() { + let port_path = path_result.to_string_lossy().to_string(); + let port_name = path_result + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + let (vid, pid) = vid_pid_from_sysfs(&port_name).unwrap_or((0, 0)); + let board = registry::lookup_board(vid, pid); + + results.push(SerialDeviceInfo { + port_path, + vid, + pid, + board_name: board.map(|b| b.name.to_string()), + architecture: board.and_then(|b| b.architecture.map(String::from)), + }); + } + } + + results +} + +/// Read VID and PID for a tty port from Linux sysfs. +/// +/// Follows the symlink chain: +/// `/sys/class/tty//device` → canonicalised USB interface directory +/// then climbs to parent (or grandparent) USB device to read `idVendor`/`idProduct`. +#[cfg(all(feature = "hardware", target_os = "linux"))] +fn vid_pid_from_sysfs(port_name: &str) -> Option<(u16, u16)> { + use std::path::Path; + + let device_link = format!("/sys/class/tty/{}/device", port_name); + // Resolve the symlink chain to a real absolute path. + let device_path = std::fs::canonicalize(device_link).ok()?; + + // ttyACM (CDC ACM): device_path = …/2-1:1.0 (interface) + // idVendor is at the USB device level, one directory up. + if let Some((v, p)) = try_read_vid_pid(device_path.parent()?) { + return Some((v, p)); + } + + // ttyUSB (USB-serial chips like CH340, FTDI): + // device_path = …/usb-serial/ttyUSB0 or …/2-1:1.0/ttyUSB0 + // May need grandparent to reach the USB device. + device_path + .parent() + .and_then(|p| p.parent()) + .and_then(try_read_vid_pid) +} + +/// Try to read `idVendor` and `idProduct` files from a directory. +#[cfg(all(feature = "hardware", target_os = "linux"))] +fn try_read_vid_pid(dir: &std::path::Path) -> Option<(u16, u16)> { + let vid = read_hex_u16(dir.join("idVendor"))?; + let pid = read_hex_u16(dir.join("idProduct"))?; + Some((vid, pid)) +} + +/// Read a hex-formatted u16 from a sysfs file (e.g. `"2e8a\n"` → `0x2E8A`). +#[cfg(all(feature = "hardware", target_os = "linux"))] +fn read_hex_u16(path: impl AsRef) -> Option { + let s = std::fs::read_to_string(path).ok()?; + u16::from_str_radix(s.trim(), 16).ok() +} + +// ── macOS: glob tty paths, no sysfs ────────────────────────────────────────── + +/// On macOS, enumerate common USB CDC and USB-serial tty paths. +/// VID/PID cannot be read from the path alone — they come back as 0/0. +/// Unknown-VID devices will be probed during `DeviceRegistry::discover`. +#[cfg(all(feature = "hardware", target_os = "macos"))] +fn scan_serial_devices_macos() -> Vec { + let mut results = Vec::new(); + + // cu.* variants are preferred on macOS (call-up; tty.* are call-in). + for pattern in &[ + "/dev/cu.usbmodem*", + "/dev/cu.usbserial*", + "/dev/tty.usbmodem*", + "/dev/tty.usbserial*", + ] { + let paths = match glob::glob(pattern) { + Ok(p) => p, + Err(_) => continue, + }; + + for path_result in paths.flatten() { + let port_path = path_result.to_string_lossy().to_string(); + // No sysfs on macOS — VID/PID unknown; will be resolved via ping. + results.push(SerialDeviceInfo { + port_path, + vid: 0, + pid: 0, + board_name: None, + architecture: None, + }); + } + } + + results +} diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index a1fa82314..b3a677659 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -2,7 +2,10 @@ //! //! See `docs/hardware-peripherals-design.md` for the full design. +pub mod device; +pub mod protocol; pub mod registry; +pub mod transport; #[cfg(all( feature = "hardware", @@ -16,11 +19,27 @@ pub mod discover; ))] pub mod introspect; +#[cfg(feature = "hardware")] +pub mod serial; + use crate::config::Config; use anyhow::Result; // Re-export config types so wizard can use `hardware::HardwareConfig` etc. pub use crate::config::{HardwareConfig, HardwareTransport}; +#[allow(unused_imports)] +pub use device::{ + Device, DeviceCapabilities, DeviceContext, DeviceKind, DeviceRegistry, DeviceRuntime, + NO_HW_DEVICES_SUMMARY, +}; +#[allow(unused_imports)] +pub use protocol::{ZcCommand, ZcResponse}; +#[allow(unused_imports)] +pub use transport::{Transport, TransportError, TransportKind}; + +#[cfg(feature = "hardware")] +#[allow(unused_imports)] +pub use serial::HardwareSerialTransport; /// A hardware device discovered during auto-scan. #[derive(Debug, Clone)] diff --git a/src/hardware/protocol.rs b/src/hardware/protocol.rs new file mode 100644 index 000000000..892ed3444 --- /dev/null +++ b/src/hardware/protocol.rs @@ -0,0 +1,148 @@ +//! ZeroClaw serial JSON protocol — the firmware contract. +//! +//! These types define the newline-delimited JSON wire format shared between +//! the ZeroClaw host and device firmware (Pico, Arduino, ESP32, Nucleo). +//! +//! Wire format: +//! Host → Device: `{"cmd":"gpio_write","params":{"pin":25,"value":1}}\n` +//! Device → Host: `{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n` +//! +//! Both sides MUST agree on these struct definitions. Any change here is a +//! breaking firmware contract change. + +use serde::{Deserialize, Serialize}; + +/// Host-to-device command. +/// +/// Serialized as one JSON line terminated by `\n`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ZcCommand { + /// Command name (e.g. `"gpio_read"`, `"ping"`, `"reboot_bootsel"`). + pub cmd: String, + /// Command parameters — schema depends on the command. + #[serde(default)] + pub params: serde_json::Value, +} + +impl ZcCommand { + /// Create a new command with the given name and parameters. + pub fn new(cmd: impl Into, params: serde_json::Value) -> Self { + Self { + cmd: cmd.into(), + params, + } + } + + /// Create a parameterless command (e.g. `ping`, `capabilities`). + pub fn simple(cmd: impl Into) -> Self { + Self { + cmd: cmd.into(), + params: serde_json::Value::Object(serde_json::Map::new()), + } + } +} + +/// Device-to-host response. +/// +/// Serialized as one JSON line terminated by `\n`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ZcResponse { + /// Whether the command succeeded. + pub ok: bool, + /// Response payload — schema depends on the command executed. + #[serde(default)] + pub data: serde_json::Value, + /// Human-readable error message when `ok` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ZcResponse { + /// Create a success response with data. + pub fn success(data: serde_json::Value) -> Self { + Self { + ok: true, + data, + error: None, + } + } + + /// Create an error response. + pub fn error(message: impl Into) -> Self { + Self { + ok: false, + data: serde_json::Value::Null, + error: Some(message.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn zc_command_serialization_roundtrip() { + let cmd = ZcCommand::new("gpio_write", json!({"pin": 25, "value": 1})); + let json = serde_json::to_string(&cmd).unwrap(); + let parsed: ZcCommand = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.cmd, "gpio_write"); + assert_eq!(parsed.params["pin"], 25); + assert_eq!(parsed.params["value"], 1); + } + + #[test] + fn zc_command_simple_has_empty_params() { + let cmd = ZcCommand::simple("ping"); + assert_eq!(cmd.cmd, "ping"); + assert!(cmd.params.is_object()); + } + + #[test] + fn zc_response_success_roundtrip() { + let resp = ZcResponse::success(json!({"value": 1})); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: ZcResponse = serde_json::from_str(&json).unwrap(); + assert!(parsed.ok); + assert_eq!(parsed.data["value"], 1); + assert!(parsed.error.is_none()); + } + + #[test] + fn zc_response_error_roundtrip() { + let resp = ZcResponse::error("pin not available"); + let json = serde_json::to_string(&resp).unwrap(); + let parsed: ZcResponse = serde_json::from_str(&json).unwrap(); + assert!(!parsed.ok); + assert_eq!(parsed.error.as_deref(), Some("pin not available")); + } + + #[test] + fn zc_command_wire_format_matches_spec() { + // Verify the exact JSON shape the firmware expects. + let cmd = ZcCommand::new("gpio_write", json!({"pin": 25, "value": 1})); + let v: serde_json::Value = serde_json::to_value(&cmd).unwrap(); + assert!(v.get("cmd").is_some()); + assert!(v.get("params").is_some()); + } + + #[test] + fn zc_response_from_firmware_json() { + // Simulate a raw firmware response line. + let raw = r#"{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}"#; + let resp: ZcResponse = serde_json::from_str(raw).unwrap(); + assert!(resp.ok); + assert_eq!(resp.data["state"], "HIGH"); + } + + #[test] + fn zc_response_missing_optional_fields() { + // Firmware may omit `data` and `error` on success. + let raw = r#"{"ok":true}"#; + let resp: ZcResponse = serde_json::from_str(raw).unwrap(); + assert!(resp.ok); + assert!(resp.data.is_null()); + assert!(resp.error.is_none()); + } +} diff --git a/src/hardware/registry.rs b/src/hardware/registry.rs index aac15f2bc..f82043f69 100644 --- a/src/hardware/registry.rs +++ b/src/hardware/registry.rs @@ -67,6 +67,31 @@ const KNOWN_BOARDS: &[BoardInfo] = &[ name: "esp32", architecture: Some("ESP32 (CH340)"), }, + // Raspberry Pi Pico (VID 0x2E8A = Raspberry Pi Foundation) + BoardInfo { + vid: 0x2e8a, + pid: 0x000a, + name: "raspberry-pi-pico", + architecture: Some("ARM Cortex-M0+ (RP2040)"), + }, + BoardInfo { + vid: 0x2e8a, + pid: 0x0005, + name: "raspberry-pi-pico", + architecture: Some("ARM Cortex-M0+ (RP2040)"), + }, + // Pico W (with CYW43 wireless) + // NOTE: PID 0xF00A is not in the official Raspberry Pi USB PID allocation. + // MicroPython on Pico W typically uses PID 0x0005 (CDC REPL). This entry + // is a placeholder for custom ZeroClaw firmware that sets PID 0xF00A. + // If using stock MicroPython, the Pico W will match the 0x0005 entry above. + // Reference: https://github.com/raspberrypi/usb-pid (official PID list). + BoardInfo { + vid: 0x2e8a, + pid: 0xf00a, + name: "raspberry-pi-pico-w", + architecture: Some("ARM Cortex-M0+ (RP2040 + CYW43)"), + }, ]; /// Look up a board by VID and PID. @@ -99,4 +124,18 @@ mod tests { fn known_boards_not_empty() { assert!(!known_boards().is_empty()); } + + #[test] + fn lookup_pico_standard() { + let b = lookup_board(0x2e8a, 0x000a).unwrap(); + assert_eq!(b.name, "raspberry-pi-pico"); + assert!(b.architecture.unwrap().contains("RP2040")); + } + + #[test] + fn lookup_pico_w() { + let b = lookup_board(0x2e8a, 0xf00a).unwrap(); + assert_eq!(b.name, "raspberry-pi-pico-w"); + assert!(b.architecture.unwrap().contains("CYW43")); + } } 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/transport.rs b/src/hardware/transport.rs new file mode 100644 index 000000000..6eaca2d24 --- /dev/null +++ b/src/hardware/transport.rs @@ -0,0 +1,112 @@ +//! Transport trait — decouples hardware tools from wire protocol. +//! +//! Implementations: +//! - `serial::HardwareSerialTransport` — lazy-open newline-delimited JSON over USB CDC (Phase 2) +//! - `SWDTransport` — memory read/write via probe-rs (Phase 7) +//! - `UF2Transport` — firmware flashing via UF2 mass storage (Phase 6) +//! - `NativeTransport` — direct Linux GPIO/I2C/SPI via rppal/sysfs (later) + +use super::protocol::{ZcCommand, ZcResponse}; +use async_trait::async_trait; +use thiserror::Error; + +/// Transport layer error. +#[derive(Debug, Error)] +pub enum TransportError { + /// Operation timed out. + #[error("transport timeout after {0}s")] + Timeout(u64), + + /// Transport is disconnected or device was removed. + #[error("transport disconnected")] + Disconnected, + + /// Protocol-level error (malformed JSON, id mismatch, etc.). + #[error("protocol error: {0}")] + Protocol(String), + + /// Underlying I/O error. + #[error("transport I/O error: {0}")] + Io(#[from] std::io::Error), + + /// Catch-all for transport-specific errors. + #[error("{0}")] + Other(String), +} + +/// Transport kind discriminator. +/// +/// Used for capability matching — some tools require a specific transport +/// (e.g. `pico_flash` requires UF2, `memory_read` prefers SWD). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TransportKind { + /// Newline-delimited JSON over USB CDC serial. + Serial, + /// SWD debug probe (probe-rs). + Swd, + /// UF2 mass storage firmware flashing. + Uf2, + /// Direct Linux GPIO/I2C/SPI (rppal, sysfs). + Native, +} + +impl std::fmt::Display for TransportKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Serial => write!(f, "serial"), + Self::Swd => write!(f, "swd"), + Self::Uf2 => write!(f, "uf2"), + Self::Native => write!(f, "native"), + } + } +} + +/// Transport trait — sends commands to a hardware device and receives responses. +/// +/// All implementations MUST use explicit `tokio::time::timeout` on I/O operations. +/// Callers should never assume success; always handle `TransportError`. +#[async_trait] +pub trait Transport: Send + Sync { + /// Send a command to the device and receive the response. + async fn send(&self, cmd: &ZcCommand) -> Result; + + /// What kind of transport this is. + fn kind(&self) -> TransportKind; + + /// Whether the transport is currently connected to a device. + fn is_connected(&self) -> bool; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transport_kind_display() { + assert_eq!(TransportKind::Serial.to_string(), "serial"); + assert_eq!(TransportKind::Swd.to_string(), "swd"); + assert_eq!(TransportKind::Uf2.to_string(), "uf2"); + assert_eq!(TransportKind::Native.to_string(), "native"); + } + + #[test] + fn transport_error_display() { + let err = TransportError::Timeout(5); + assert_eq!(err.to_string(), "transport timeout after 5s"); + + let err = TransportError::Disconnected; + assert_eq!(err.to_string(), "transport disconnected"); + + let err = TransportError::Protocol("bad json".into()); + assert_eq!(err.to_string(), "protocol error: bad json"); + + let err = TransportError::Other("custom".into()); + assert_eq!(err.to_string(), "custom"); + } + + #[test] + fn transport_kind_equality() { + assert_eq!(TransportKind::Serial, TransportKind::Serial); + assert_ne!(TransportKind::Serial, TransportKind::Swd); + } +} diff --git a/src/util.rs b/src/util.rs index 8e7cff751..872ffbc39 100644 --- a/src/util.rs +++ b/src/util.rs @@ -59,6 +59,58 @@ pub fn floor_utf8_char_boundary(s: &str, index: usize) -> usize { i } +/// Allowed serial device path prefixes shared across hardware transports. +pub const ALLOWED_SERIAL_PATH_PREFIXES: &[&str] = &[ + "/dev/ttyACM", + "/dev/ttyUSB", + "/dev/tty.usbmodem", + "/dev/cu.usbmodem", + "/dev/tty.usbserial", + "/dev/cu.usbserial", + "COM", +]; + +/// Validate serial device path against per-platform rules. +pub fn is_serial_path_allowed(path: &str) -> bool { + #[cfg(target_os = "linux")] + { + use std::sync::OnceLock; + if !std::path::Path::new(path).is_absolute() { + return false; + } + static PAT: OnceLock = OnceLock::new(); + let re = PAT.get_or_init(|| { + regex::Regex::new(r"^/dev/tty(ACM|USB|S|AMA|MFD)\d+$").expect("valid regex") + }); + return re.is_match(path); + } + + #[cfg(target_os = "macos")] + { + use std::sync::OnceLock; + if !std::path::Path::new(path).is_absolute() { + return false; + } + static PAT: OnceLock = OnceLock::new(); + let re = PAT.get_or_init(|| { + regex::Regex::new(r"^/dev/(tty|cu)\.(usbmodem|usbserial)[^\x00/]*$") + .expect("valid regex") + }); + return re.is_match(path); + } + + #[cfg(target_os = "windows")] + { + use std::sync::OnceLock; + static PAT: OnceLock = OnceLock::new(); + let re = PAT.get_or_init(|| regex::Regex::new(r"^COM\d{1,3}$").expect("valid regex")); + return re.is_match(path); + } + + #[allow(unreachable_code)] + false +} + /// Utility enum for handling optional values. pub enum MaybeSet { Set(T),