//! 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(), ""); } }