Skip to main content

zeroclaw_hardware/
device.rs

1//! Device types and registry — stable aliases for discovered hardware.
2//!
3//! The LLM always refers to devices by alias (`"pico0"`, `"arduino0"`), never
4//! by raw `/dev/` paths. The `DeviceRegistry` assigns these aliases at startup
5//! and provides lookup + context building for tool execution.
6
7use super::transport::Transport;
8use std::collections::HashMap;
9use std::sync::Arc;
10
11// ── DeviceRuntime ─────────────────────────────────────────────────────────────
12
13/// The software runtime / execution environment of a device.
14///
15/// Determines which host-side tooling is used for code deployment and execution.
16/// Currently only [`MicroPython`](DeviceRuntime::MicroPython) is implemented;
17/// other variants return a clear "not yet supported" error.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum DeviceRuntime {
20    /// MicroPython — uses `mpremote` for code read/write/exec.
21    MicroPython,
22    /// CircuitPython — `mpremote`-compatible (future).
23    CircuitPython,
24    /// Arduino — `arduino-cli` for sketch upload (future).
25    Arduino,
26    /// STM32 / probe-rs based flashing and debugging (future).
27    Nucleus,
28    /// Linux / Raspberry Pi — ssh/shell execution (future).
29    Linux,
30    /// Total Phase Aardvark I2C/SPI/GPIO USB adapter.
31    Aardvark,
32}
33
34impl DeviceRuntime {
35    /// Derive the default runtime from a [`DeviceKind`].
36    pub fn from_kind(kind: &DeviceKind) -> Self {
37        match kind {
38            DeviceKind::Pico | DeviceKind::Esp32 | DeviceKind::Generic => Self::MicroPython,
39            DeviceKind::Arduino => Self::Arduino,
40            DeviceKind::Nucleo => Self::Nucleus,
41            DeviceKind::Aardvark => Self::Aardvark,
42        }
43    }
44}
45
46impl std::fmt::Display for DeviceRuntime {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::MicroPython => write!(f, "MicroPython"),
50            Self::CircuitPython => write!(f, "CircuitPython"),
51            Self::Arduino => write!(f, "Arduino"),
52            Self::Nucleus => write!(f, "Nucleus"),
53            Self::Linux => write!(f, "Linux"),
54            Self::Aardvark => write!(f, "Aardvark"),
55        }
56    }
57}
58
59// ── DeviceKind ────────────────────────────────────────────────────────────────
60
61/// The category of a discovered hardware device.
62///
63/// Derived from USB Vendor ID or, for unknown VIDs, from a successful
64/// ping handshake (which yields `Generic`).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum DeviceKind {
67    /// Raspberry Pi Pico / Pico W (VID `0x2E8A`).
68    Pico,
69    /// Arduino Uno, Mega, etc. (VID `0x2341`).
70    Arduino,
71    /// ESP32 via CP2102 bridge (VID `0x10C4`).
72    Esp32,
73    /// STM32 Nucleo (VID `0x0483`).
74    Nucleo,
75    /// Unknown VID that passed the ZeroClaw firmware ping handshake.
76    Generic,
77    /// Total Phase Aardvark USB adapter (VID `0x2B76`).
78    Aardvark,
79}
80
81impl DeviceKind {
82    /// Derive the device kind from a USB Vendor ID.
83    /// Returns `None` if the VID is unknown (0 or unrecognised).
84    pub fn from_vid(vid: u16) -> Option<Self> {
85        match vid {
86            0x2e8a => Some(Self::Pico),
87            0x2341 => Some(Self::Arduino),
88            0x10c4 => Some(Self::Esp32),
89            0x0483 => Some(Self::Nucleo),
90            0x2b76 => Some(Self::Aardvark),
91            _ => None,
92        }
93    }
94}
95
96impl std::fmt::Display for DeviceKind {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        match self {
99            Self::Pico => write!(f, "pico"),
100            Self::Arduino => write!(f, "arduino"),
101            Self::Esp32 => write!(f, "esp32"),
102            Self::Nucleo => write!(f, "nucleo"),
103            Self::Generic => write!(f, "generic"),
104            Self::Aardvark => write!(f, "aardvark"),
105        }
106    }
107}
108
109/// Capability flags for a connected device.
110///
111/// Populated from device handshake or static board metadata.
112/// Tools can check capabilities before attempting unsupported operations.
113#[derive(Debug, Clone, Default)]
114#[allow(clippy::struct_excessive_bools)]
115pub struct DeviceCapabilities {
116    pub gpio: bool,
117    pub i2c: bool,
118    pub spi: bool,
119    pub swd: bool,
120    pub uart: bool,
121    pub adc: bool,
122    pub pwm: bool,
123}
124
125/// A discovered and registered hardware device.
126#[derive(Debug, Clone)]
127pub struct Device {
128    /// Stable session alias (e.g. `"pico0"`, `"arduino0"`, `"nucleo0"`).
129    pub alias: String,
130    /// Board name from registry (e.g. `"raspberry-pi-pico"`, `"arduino-uno"`).
131    pub board_name: String,
132    /// Device category derived from VID or ping handshake.
133    pub kind: DeviceKind,
134    /// Software runtime that determines how code is deployed/executed.
135    pub runtime: DeviceRuntime,
136    /// USB Vendor ID (if USB-connected).
137    pub vid: Option<u16>,
138    /// USB Product ID (if USB-connected).
139    pub pid: Option<u16>,
140    /// Raw device path (e.g. `"/dev/ttyACM0"`) — internal use only.
141    /// Tools MUST NOT use this directly; always go through Transport.
142    pub device_path: Option<String>,
143    /// Architecture description (e.g. `"ARM Cortex-M0+"`).
144    pub architecture: Option<String>,
145    /// Firmware identifier reported by device during ping handshake.
146    pub firmware: Option<String>,
147}
148
149impl Device {
150    /// Convenience accessor — same as `device_path` (matches the Phase 2 spec naming).
151    pub fn port(&self) -> Option<&str> {
152        self.device_path.as_deref()
153    }
154}
155
156/// Context passed to hardware tools during execution.
157///
158/// Provides the tool with access to the device identity, transport layer,
159/// and capability flags without the tool managing connections itself.
160pub struct DeviceContext {
161    /// The device this tool is operating on.
162    pub device: Arc<Device>,
163    /// Transport for sending commands to the device.
164    pub transport: Arc<dyn Transport>,
165    /// Device capabilities (gpio, i2c, spi, etc.).
166    pub capabilities: DeviceCapabilities,
167}
168
169/// A registered device entry with its transport and capabilities.
170struct RegisteredDevice {
171    device: Arc<Device>,
172    transport: Option<Arc<dyn Transport>>,
173    capabilities: DeviceCapabilities,
174}
175
176/// Summary string returned by [`DeviceRegistry::prompt_summary`] when no
177/// devices are registered.  Exported so callers can compare against it without
178/// duplicating the literal.
179pub const NO_HW_DEVICES_SUMMARY: &str = "No hardware devices connected.";
180
181/// Registry of discovered devices with stable session aliases.
182///
183/// - Scans at startup (via `hardware::discover`)
184/// - Assigns aliases: `pico0`, `pico1`, `arduino0`, `nucleo0`, `device0`, etc.
185/// - Provides alias-based lookup for tool dispatch
186/// - Generates prompt summaries for LLM context
187pub struct DeviceRegistry {
188    devices: HashMap<String, RegisteredDevice>,
189    alias_counters: HashMap<String, u32>,
190}
191
192impl DeviceRegistry {
193    /// Create an empty registry.
194    pub fn new() -> Self {
195        Self {
196            devices: HashMap::new(),
197            alias_counters: HashMap::new(),
198        }
199    }
200
201    /// Register a discovered device and assign a stable alias.
202    ///
203    /// Returns the assigned alias (e.g. `"pico0"`).
204    pub fn register(
205        &mut self,
206        board_name: &str,
207        vid: Option<u16>,
208        pid: Option<u16>,
209        device_path: Option<String>,
210        architecture: Option<String>,
211    ) -> String {
212        let prefix = alias_prefix(board_name);
213        let counter = self.alias_counters.entry(prefix.clone()).or_insert(0);
214        let alias = format!("{}{}", prefix, counter);
215        *counter += 1;
216
217        let kind = vid
218            .and_then(DeviceKind::from_vid)
219            .unwrap_or(DeviceKind::Generic);
220        let runtime = DeviceRuntime::from_kind(&kind);
221
222        let device = Arc::new(Device {
223            alias: alias.clone(),
224            board_name: board_name.to_string(),
225            kind,
226            runtime,
227            vid,
228            pid,
229            device_path,
230            architecture,
231            firmware: None,
232        });
233
234        self.devices.insert(
235            alias.clone(),
236            RegisteredDevice {
237                device,
238                transport: None,
239                capabilities: DeviceCapabilities::default(),
240            },
241        );
242
243        alias
244    }
245
246    /// Attach a transport and capabilities to a previously registered device.
247    ///
248    /// Returns `Err` when `alias` is not found in the registry (should not
249    /// happen in normal usage because callers pass aliases from `register`).
250    pub fn attach_transport(
251        &mut self,
252        alias: &str,
253        transport: Arc<dyn Transport>,
254        capabilities: DeviceCapabilities,
255    ) -> anyhow::Result<()> {
256        if let Some(entry) = self.devices.get_mut(alias) {
257            entry.transport = Some(transport);
258            entry.capabilities = capabilities;
259            Ok(())
260        } else {
261            ::zeroclaw_log::record!(
262                WARN,
263                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
264                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
265                    .with_attrs(::serde_json::json!({"device_alias": alias})),
266                "device registry attach refused: unknown alias"
267            );
268            Err(anyhow::Error::msg(format!("unknown device alias: {alias}")))
269        }
270    }
271
272    /// Look up a device by alias.
273    pub fn get_device(&self, alias: &str) -> Option<Arc<Device>> {
274        self.devices.get(alias).map(|e| e.device.clone())
275    }
276
277    /// Build a `DeviceContext` for a device by alias.
278    ///
279    /// Returns `None` if the alias is unknown or no transport is attached.
280    pub fn context(&self, alias: &str) -> Option<DeviceContext> {
281        self.devices.get(alias).and_then(|e| {
282            e.transport.as_ref().map(|t| DeviceContext {
283                device: e.device.clone(),
284                transport: t.clone(),
285                capabilities: e.capabilities.clone(),
286            })
287        })
288    }
289
290    /// List all registered device aliases.
291    pub fn aliases(&self) -> Vec<&str> {
292        self.devices.keys().map(|s| s.as_str()).collect()
293    }
294
295    /// Return a summary of connected devices for the LLM system prompt.
296    pub fn prompt_summary(&self) -> String {
297        if self.devices.is_empty() {
298            return NO_HW_DEVICES_SUMMARY.to_string();
299        }
300
301        let mut lines = vec!["Connected devices:".to_string()];
302        let mut sorted_aliases: Vec<&String> = self.devices.keys().collect();
303        sorted_aliases.sort();
304        for alias in sorted_aliases {
305            let entry = &self.devices[alias];
306            let status = entry
307                .transport
308                .as_ref()
309                .map(|t| {
310                    if t.is_connected() {
311                        "connected"
312                    } else {
313                        "disconnected"
314                    }
315                })
316                .unwrap_or("no transport");
317            let arch = entry
318                .device
319                .architecture
320                .as_deref()
321                .unwrap_or("unknown arch");
322            lines.push(format!(
323                "  {} — {} ({}) [{}]",
324                alias, entry.device.board_name, arch, status
325            ));
326        }
327        lines.join("\n")
328    }
329
330    /// Resolve a GPIO-capable device alias from tool arguments.
331    ///
332    /// If `args["device"]` is provided, uses that alias directly.
333    /// Otherwise, auto-selects the single GPIO-capable device, returning an
334    /// error description if zero or multiple GPIO devices are available.
335    ///
336    /// On success returns `(alias, DeviceContext)` — both are owned / Arc-based
337    /// so the caller can drop the registry lock before doing async I/O.
338    pub fn resolve_gpio_device(
339        &self,
340        args: &serde_json::Value,
341    ) -> Result<(String, DeviceContext), String> {
342        let device_alias: String = match args.get("device").and_then(|v| v.as_str()) {
343            Some(a) => a.to_string(),
344            None => {
345                let gpio_aliases: Vec<String> = self
346                    .aliases()
347                    .into_iter()
348                    .filter(|a| {
349                        self.context(a)
350                            .map(|c| c.capabilities.gpio)
351                            .unwrap_or(false)
352                    })
353                    .map(|a| a.to_string())
354                    .collect();
355                match gpio_aliases.as_slice() {
356                    [single] => single.clone(),
357                    [] => {
358                        return Err("no GPIO-capable device found; specify \"device\" parameter"
359                            .to_string());
360                    }
361                    _ => {
362                        return Err(format!(
363                            "multiple devices available ({}); specify \"device\" parameter",
364                            gpio_aliases.join(", ")
365                        ));
366                    }
367                }
368            }
369        };
370
371        let ctx = self.context(&device_alias).ok_or_else(|| {
372            format!(
373                "device '{}' not found or has no transport attached",
374                device_alias
375            )
376        })?;
377
378        // Verify the device advertises GPIO capability.
379        if !ctx.capabilities.gpio {
380            return Err(format!(
381                "device '{}' does not support GPIO; specify a GPIO-capable device",
382                device_alias
383            ));
384        }
385
386        Ok((device_alias, ctx))
387    }
388
389    /// Return `true` when at least one Aardvark adapter is registered.
390    pub fn has_aardvark(&self) -> bool {
391        self.devices
392            .values()
393            .any(|e| e.device.kind == DeviceKind::Aardvark)
394    }
395
396    /// Resolve an Aardvark device from tool arguments.
397    ///
398    /// If `args["device"]` is provided, uses that alias directly.
399    /// Otherwise auto-selects the single Aardvark device, returning an error
400    /// description if zero or multiple Aardvark devices are available.
401    ///
402    /// Returns `(alias, DeviceContext)` — both are owned/Arc-based so the
403    /// caller can drop the registry lock before doing async I/O.
404    pub fn resolve_aardvark_device(
405        &self,
406        args: &serde_json::Value,
407    ) -> Result<(String, DeviceContext), String> {
408        let device_alias: String = match args.get("device").and_then(|v| v.as_str()) {
409            Some(a) => a.to_string(),
410            None => {
411                let aardvark_aliases: Vec<String> = self
412                    .aliases()
413                    .into_iter()
414                    .filter(|a| {
415                        self.devices
416                            .get(*a)
417                            .map(|e| e.device.kind == DeviceKind::Aardvark)
418                            .unwrap_or(false)
419                    })
420                    .map(|a| a.to_string())
421                    .collect();
422                match aardvark_aliases.as_slice() {
423                    [single] => single.clone(),
424                    [] => {
425                        return Err("no Aardvark adapter found; is it plugged in?".to_string());
426                    }
427                    _ => {
428                        return Err(format!(
429                            "multiple Aardvark adapters available ({}); \
430                             specify \"device\" parameter",
431                            aardvark_aliases.join(", ")
432                        ));
433                    }
434                }
435            }
436        };
437
438        let ctx = self.context(&device_alias).ok_or_else(|| {
439            format!("device '{device_alias}' not found or has no transport attached")
440        })?;
441
442        Ok((device_alias, ctx))
443    }
444
445    /// Number of registered devices.
446    pub fn len(&self) -> usize {
447        self.devices.len()
448    }
449
450    /// Whether the registry is empty.
451    pub fn is_empty(&self) -> bool {
452        self.devices.is_empty()
453    }
454
455    /// Look up a device by alias (alias for `get_device` matching the Phase 2 spec).
456    pub fn get(&self, alias: &str) -> Option<Arc<Device>> {
457        self.get_device(alias)
458    }
459
460    /// Return all registered devices.
461    pub fn all(&self) -> Vec<Arc<Device>> {
462        self.devices.values().map(|e| e.device.clone()).collect()
463    }
464
465    /// One-line summary per device: `"pico0: raspberry-pi-pico /dev/ttyACM0"`.
466    ///
467    /// Suitable for CLI output and debug logging.
468    pub fn summary(&self) -> String {
469        if self.devices.is_empty() {
470            return String::new();
471        }
472        let mut lines: Vec<String> = self
473            .devices
474            .values()
475            .map(|e| {
476                let path = e.device.port().unwrap_or("(native)");
477                format!("{}: {} {}", e.device.alias, e.device.board_name, path)
478            })
479            .collect();
480        lines.sort(); // deterministic for tests
481        lines.join("\n")
482    }
483
484    /// Discover all connected serial devices and populate the registry.
485    ///
486    /// Steps:
487    /// 1. Call `discover::scan_serial_devices()` to enumerate port paths + VID/PID.
488    /// 2. For each device with a recognised VID: register and attach a transport.
489    /// 3. For unknown VID (`0`): attempt a 300 ms ping handshake; register only
490    ///    if the device responds with ZeroClaw firmware.
491    /// 4. Return the populated registry.
492    ///
493    /// Returns an empty registry when no devices are found or the `hardware`
494    /// feature is disabled.
495    #[cfg(feature = "hardware")]
496    pub async fn discover() -> Self {
497        use super::{
498            discover::scan_serial_devices,
499            serial::{DEFAULT_BAUD, HardwareSerialTransport},
500        };
501
502        let mut registry = Self::new();
503
504        for info in scan_serial_devices() {
505            let is_known_vid = info.vid != 0;
506
507            // For unknown VIDs, run the ping handshake before registering.
508            // This avoids registering random USB-serial adapters.
509            // If the probe succeeds we reuse the same transport instance below.
510            let probe_transport = if !is_known_vid {
511                let probe = HardwareSerialTransport::new(&info.port_path, DEFAULT_BAUD);
512                if !probe.ping_handshake().await {
513                    ::zeroclaw_log::record!(
514                        DEBUG,
515                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
516                            .with_attrs(::serde_json::json!({"port": info.port_path})),
517                        "skipping unknown device: no ZeroClaw firmware response"
518                    );
519                    continue;
520                }
521                Some(probe)
522            } else {
523                None
524            };
525
526            let board_name = info.board_name.as_deref().unwrap_or("unknown").to_string();
527
528            let alias = registry.register(
529                &board_name,
530                if info.vid != 0 { Some(info.vid) } else { None },
531                if info.pid != 0 { Some(info.pid) } else { None },
532                Some(info.port_path.clone()),
533                info.architecture,
534            );
535
536            // For unknown-VID devices that passed ping: mark as Generic.
537            // (register() will have already set kind = Generic for vid=None)
538
539            let transport: Arc<dyn super::transport::Transport> =
540                if let Some(probe) = probe_transport {
541                    Arc::new(probe)
542                } else {
543                    Arc::new(HardwareSerialTransport::new(&info.port_path, DEFAULT_BAUD))
544                };
545            let caps = DeviceCapabilities {
546                gpio: true, // assume GPIO; Phase 3 will populate via capabilities handshake
547                ..DeviceCapabilities::default()
548            };
549            registry
550                .attach_transport(&alias, transport, caps)
551                .unwrap_or_else(|e| {
552                    ::zeroclaw_log::record!(
553                        WARN,
554                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
555                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
556                            .with_attrs(
557                                ::serde_json::json!({"alias": alias, "err": e.to_string()})
558                            ),
559                        "attach_transport: unexpected unknown alias"
560                    )
561                });
562
563            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"alias": alias, "port": info.port_path, "vid": info.vid})), "device registered");
564        }
565
566        registry
567    }
568}
569
570impl DeviceRegistry {
571    /// Reconnect a device after reboot/reflash.
572    ///
573    /// Drops the old transport, creates a fresh [`HardwareSerialTransport`] for
574    /// the given (or existing) port path, runs the ping handshake to confirm
575    /// ZeroClaw firmware is alive, and re-attaches the transport.
576    ///
577    /// Pass `new_port` when the OS assigned a different path after reboot;
578    /// pass `None` to reuse the device's current path.
579    #[cfg(feature = "hardware")]
580    pub async fn reconnect(&mut self, alias: &str, new_port: Option<&str>) -> anyhow::Result<()> {
581        use super::serial::{DEFAULT_BAUD, HardwareSerialTransport};
582
583        let entry = self.devices.get_mut(alias).ok_or_else(|| {
584            ::zeroclaw_log::record!(
585                WARN,
586                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
587                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
588                    .with_attrs(::serde_json::json!({"device_alias": alias})),
589                "device registry reconnect refused: unknown alias"
590            );
591            anyhow::Error::msg(format!("unknown device alias: {alias}"))
592        })?;
593
594        // Determine the port path — prefer the caller's override.
595        let port_path = match new_port {
596            Some(p) => {
597                // Update the device record with the new path.
598                let mut updated = (*entry.device).clone();
599                updated.device_path = Some(p.to_string());
600                entry.device = Arc::new(updated);
601                p.to_string()
602            }
603            None => entry.device.device_path.clone().ok_or_else(|| {
604                ::zeroclaw_log::record!(
605                    WARN,
606                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
607                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
608                        .with_attrs(::serde_json::json!({"device_alias": alias})),
609                    "device registry reconnect refused: no recorded port path"
610                );
611                anyhow::Error::msg(format!("device {alias} has no port path"))
612            })?,
613        };
614
615        // Drop the stale transport.
616        entry.transport = None;
617
618        // Create a fresh transport and verify firmware is alive.
619        let transport = HardwareSerialTransport::new(&port_path, DEFAULT_BAUD);
620        if !transport.ping_handshake().await {
621            anyhow::bail!(
622                "ping handshake failed after reconnect on {port_path} — \
623                 firmware may not be running"
624            );
625        }
626
627        entry.transport = Some(Arc::new(transport) as Arc<dyn super::transport::Transport>);
628        entry.capabilities.gpio = true;
629
630        ::zeroclaw_log::record!(
631            INFO,
632            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
633                .with_attrs(::serde_json::json!({"alias": alias, "port": port_path})),
634            "device reconnected"
635        );
636        Ok(())
637    }
638}
639
640impl Default for DeviceRegistry {
641    fn default() -> Self {
642        Self::new()
643    }
644}
645
646/// Derive alias prefix from board name.
647fn alias_prefix(board_name: &str) -> String {
648    match board_name {
649        s if s.starts_with("raspberry-pi-pico") || s.starts_with("pico") => "pico".to_string(),
650        s if s.starts_with("arduino") => "arduino".to_string(),
651        s if s.starts_with("esp32") || s.starts_with("esp") => "esp".to_string(),
652        s if s.starts_with("nucleo") || s.starts_with("stm32") => "nucleo".to_string(),
653        s if s.starts_with("rpi") || s == "raspberry-pi" => "rpi".to_string(),
654        _ => "device".to_string(),
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn alias_prefix_pico_variants() {
664        assert_eq!(alias_prefix("raspberry-pi-pico"), "pico");
665        assert_eq!(alias_prefix("pico-w"), "pico");
666        assert_eq!(alias_prefix("pico"), "pico");
667    }
668
669    #[test]
670    fn alias_prefix_arduino() {
671        assert_eq!(alias_prefix("arduino-uno"), "arduino");
672        assert_eq!(alias_prefix("arduino-mega"), "arduino");
673    }
674
675    #[test]
676    fn alias_prefix_esp() {
677        assert_eq!(alias_prefix("esp32"), "esp");
678        assert_eq!(alias_prefix("esp32-s3"), "esp");
679    }
680
681    #[test]
682    fn alias_prefix_nucleo() {
683        assert_eq!(alias_prefix("nucleo-f401re"), "nucleo");
684        assert_eq!(alias_prefix("stm32-discovery"), "nucleo");
685    }
686
687    #[test]
688    fn alias_prefix_rpi() {
689        assert_eq!(alias_prefix("rpi-gpio"), "rpi");
690        assert_eq!(alias_prefix("raspberry-pi"), "rpi");
691    }
692
693    #[test]
694    fn alias_prefix_unknown() {
695        assert_eq!(alias_prefix("custom-board"), "device");
696    }
697
698    #[test]
699    fn registry_assigns_sequential_aliases() {
700        let mut reg = DeviceRegistry::new();
701        let a1 = reg.register("raspberry-pi-pico", Some(0x2E8A), Some(0x000A), None, None);
702        let a2 = reg.register("raspberry-pi-pico", Some(0x2E8A), Some(0x000A), None, None);
703        let a3 = reg.register("arduino-uno", Some(0x2341), Some(0x0043), None, None);
704
705        assert_eq!(a1, "pico0");
706        assert_eq!(a2, "pico1");
707        assert_eq!(a3, "arduino0");
708        assert_eq!(reg.len(), 3);
709    }
710
711    #[test]
712    fn registry_get_device_by_alias() {
713        let mut reg = DeviceRegistry::new();
714        let alias = reg.register(
715            "nucleo-f401re",
716            Some(0x0483),
717            Some(0x374B),
718            Some("/dev/ttyACM0".to_string()),
719            Some("ARM Cortex-M4".to_string()),
720        );
721
722        let device = reg.get_device(&alias).unwrap();
723        assert_eq!(device.alias, "nucleo0");
724        assert_eq!(device.board_name, "nucleo-f401re");
725        assert_eq!(device.vid, Some(0x0483));
726        assert_eq!(device.architecture.as_deref(), Some("ARM Cortex-M4"));
727    }
728
729    #[test]
730    fn registry_unknown_alias_returns_none() {
731        let reg = DeviceRegistry::new();
732        assert!(reg.get_device("nonexistent").is_none());
733        assert!(reg.context("nonexistent").is_none());
734    }
735
736    #[test]
737    fn registry_context_none_without_transport() {
738        let mut reg = DeviceRegistry::new();
739        let alias = reg.register("pico", None, None, None, None);
740        // No transport attached → context returns None.
741        assert!(reg.context(&alias).is_none());
742    }
743
744    #[test]
745    fn registry_prompt_summary_empty() {
746        let reg = DeviceRegistry::new();
747        assert_eq!(reg.prompt_summary(), NO_HW_DEVICES_SUMMARY);
748    }
749
750    #[test]
751    fn registry_prompt_summary_with_devices() {
752        let mut reg = DeviceRegistry::new();
753        reg.register(
754            "raspberry-pi-pico",
755            Some(0x2E8A),
756            None,
757            None,
758            Some("ARM Cortex-M0+".to_string()),
759        );
760        let summary = reg.prompt_summary();
761        assert!(summary.contains("pico0"));
762        assert!(summary.contains("raspberry-pi-pico"));
763        assert!(summary.contains("ARM Cortex-M0+"));
764        assert!(summary.contains("no transport"));
765    }
766
767    #[test]
768    fn device_capabilities_default_all_false() {
769        let caps = DeviceCapabilities::default();
770        assert!(!caps.gpio);
771        assert!(!caps.i2c);
772        assert!(!caps.spi);
773        assert!(!caps.swd);
774        assert!(!caps.uart);
775        assert!(!caps.adc);
776        assert!(!caps.pwm);
777    }
778
779    #[test]
780    fn registry_default_is_empty() {
781        let reg = DeviceRegistry::default();
782        assert!(reg.is_empty());
783        assert_eq!(reg.len(), 0);
784    }
785
786    #[test]
787    fn registry_aliases_returns_all() {
788        let mut reg = DeviceRegistry::new();
789        reg.register("pico", None, None, None, None);
790        reg.register("arduino-uno", None, None, None, None);
791        let mut aliases = reg.aliases();
792        aliases.sort_unstable();
793        assert_eq!(aliases, vec!["arduino0", "pico0"]);
794    }
795
796    // ── Phase 2 new tests ────────────────────────────────────────────────────
797
798    #[test]
799    fn device_kind_from_vid_known() {
800        assert_eq!(DeviceKind::from_vid(0x2e8a), Some(DeviceKind::Pico));
801        assert_eq!(DeviceKind::from_vid(0x2341), Some(DeviceKind::Arduino));
802        assert_eq!(DeviceKind::from_vid(0x10c4), Some(DeviceKind::Esp32));
803        assert_eq!(DeviceKind::from_vid(0x0483), Some(DeviceKind::Nucleo));
804    }
805
806    #[test]
807    fn device_kind_from_vid_unknown() {
808        assert_eq!(DeviceKind::from_vid(0x0000), None);
809        assert_eq!(DeviceKind::from_vid(0xffff), None);
810    }
811
812    #[test]
813    fn device_kind_display() {
814        assert_eq!(DeviceKind::Pico.to_string(), "pico");
815        assert_eq!(DeviceKind::Arduino.to_string(), "arduino");
816        assert_eq!(DeviceKind::Esp32.to_string(), "esp32");
817        assert_eq!(DeviceKind::Nucleo.to_string(), "nucleo");
818        assert_eq!(DeviceKind::Generic.to_string(), "generic");
819    }
820
821    #[test]
822    fn register_sets_kind_from_vid() {
823        let mut reg = DeviceRegistry::new();
824        let a = reg.register("raspberry-pi-pico", Some(0x2e8a), Some(0x000a), None, None);
825        assert_eq!(reg.get(&a).unwrap().kind, DeviceKind::Pico);
826
827        let b = reg.register("arduino-uno", Some(0x2341), Some(0x0043), None, None);
828        assert_eq!(reg.get(&b).unwrap().kind, DeviceKind::Arduino);
829
830        let c = reg.register("unknown-device", None, None, None, None);
831        assert_eq!(reg.get(&c).unwrap().kind, DeviceKind::Generic);
832    }
833
834    #[test]
835    fn device_port_returns_device_path() {
836        let mut reg = DeviceRegistry::new();
837        let alias = reg.register(
838            "raspberry-pi-pico",
839            Some(0x2e8a),
840            None,
841            Some("/dev/ttyACM0".to_string()),
842            None,
843        );
844        let device = reg.get(&alias).unwrap();
845        assert_eq!(device.port(), Some("/dev/ttyACM0"));
846    }
847
848    #[test]
849    fn device_port_none_without_path() {
850        let mut reg = DeviceRegistry::new();
851        let alias = reg.register("pico", None, None, None, None);
852        assert!(reg.get(&alias).unwrap().port().is_none());
853    }
854
855    #[test]
856    fn registry_get_is_alias_for_get_device() {
857        let mut reg = DeviceRegistry::new();
858        let alias = reg.register("raspberry-pi-pico", Some(0x2e8a), None, None, None);
859        let via_get = reg.get(&alias);
860        let via_get_device = reg.get_device(&alias);
861        assert!(via_get.is_some());
862        assert!(via_get_device.is_some());
863        assert_eq!(via_get.unwrap().alias, via_get_device.unwrap().alias);
864    }
865
866    #[test]
867    fn registry_all_returns_every_device() {
868        let mut reg = DeviceRegistry::new();
869        reg.register("raspberry-pi-pico", Some(0x2e8a), None, None, None);
870        reg.register("arduino-uno", Some(0x2341), None, None, None);
871        assert_eq!(reg.all().len(), 2);
872    }
873
874    #[test]
875    fn registry_summary_one_liner_per_device() {
876        let mut reg = DeviceRegistry::new();
877        reg.register(
878            "raspberry-pi-pico",
879            Some(0x2e8a),
880            None,
881            Some("/dev/ttyACM0".to_string()),
882            None,
883        );
884        let s = reg.summary();
885        assert!(s.contains("pico0"));
886        assert!(s.contains("raspberry-pi-pico"));
887        assert!(s.contains("/dev/ttyACM0"));
888    }
889
890    #[test]
891    fn registry_summary_empty_when_no_devices() {
892        let reg = DeviceRegistry::new();
893        assert_eq!(reg.summary(), "");
894    }
895}