Skip to main content

zeroclaw_hardware/
lib.rs

1#![allow(clippy::to_string_in_format_args)]
2//! Hardware discovery — USB device enumeration and introspection.
3//!
4//! See `docs/hardware-peripherals-design.md` for the full design.
5
6pub mod catalog;
7pub mod device;
8pub mod gpio;
9pub mod peripherals;
10pub mod protocol;
11pub mod registry;
12pub mod transport;
13
14#[cfg(all(
15    feature = "hardware",
16    any(target_os = "linux", target_os = "macos", target_os = "windows")
17))]
18pub mod discover;
19
20#[cfg(all(
21    feature = "hardware",
22    any(target_os = "linux", target_os = "macos", target_os = "windows")
23))]
24pub mod introspect;
25
26#[cfg(feature = "hardware")]
27pub mod serial;
28
29#[cfg(feature = "hardware")]
30pub mod uf2;
31
32#[cfg(feature = "hardware")]
33pub mod pico_flash;
34
35#[cfg(feature = "hardware")]
36pub mod pico_code;
37
38/// Aardvark USB adapter transport (I2C / SPI / GPIO via aardvark-sys).
39#[cfg(feature = "hardware")]
40pub mod aardvark;
41
42/// Tools backed by the Aardvark transport (i2c_scan, i2c_read, i2c_write,
43/// spi_transfer, gpio_aardvark).
44#[cfg(feature = "hardware")]
45pub mod aardvark_tools;
46
47/// Datasheet management — search, download, and manage device datasheets.
48/// Used by DatasheetTool when an Aardvark is connected.
49#[cfg(feature = "hardware")]
50pub mod datasheet;
51
52/// Interactive hardware onboarding wizard UI.
53#[cfg(feature = "hardware")]
54pub mod wizard;
55
56/// Raspberry Pi self-discovery and native GPIO tools.
57/// Only compiled on Linux with the `peripheral-rpi` feature.
58#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
59pub mod rpi;
60
61pub mod util;
62
63// ── Phase 4: ToolRegistry + plugin system ─────────────────────────────────────
64pub mod loader;
65pub mod manifest;
66pub mod subprocess;
67pub mod tool_registry;
68
69#[cfg(feature = "hardware")]
70#[allow(unused_imports)]
71pub use aardvark::AardvarkTransport;
72
73use crate::device::DeviceRegistry;
74#[cfg(feature = "hardware")]
75use anyhow::Result;
76#[allow(unused_imports)]
77pub use tool_registry::{ToolError, ToolRegistry};
78
79// Re-export config types so wizard can use `hardware::HardwareConfig` etc.
80pub use zeroclaw_config::schema::{HardwareConfig, HardwareTransport};
81
82// ── Phase 5: boot() — hardware tool integration into agent loop ───────────────
83
84/// Merge hardware tools from a [`HardwareBootResult`] into an existing tool
85/// registry, deduplicating by name.
86///
87/// Returns a tuple of `(device_summary, added_tool_names)`.
88pub fn merge_hardware_tools(
89    tools: &mut Vec<Box<dyn zeroclaw_api::tool::Tool>>,
90    hw_boot: HardwareBootResult,
91) -> (String, Vec<String>) {
92    let device_summary = hw_boot.device_summary.clone();
93    let mut added_tool_names: Vec<String> = Vec::new();
94    if !hw_boot.tools.is_empty() {
95        let existing: std::collections::HashSet<String> =
96            tools.iter().map(|t| t.name().to_string()).collect();
97        let new_hw_tools: Vec<Box<dyn zeroclaw_api::tool::Tool>> = hw_boot
98            .tools
99            .into_iter()
100            .filter(|t| !existing.contains(t.name()))
101            .collect();
102        if !new_hw_tools.is_empty() {
103            added_tool_names = new_hw_tools.iter().map(|t| t.name().to_string()).collect();
104            ::zeroclaw_log::record!(
105                INFO,
106                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
107                    .with_attrs(::serde_json::json!({"count": new_hw_tools.len()})),
108                "Hardware registry tools added"
109            );
110            tools.extend(new_hw_tools);
111        }
112    }
113    (device_summary, added_tool_names)
114}
115
116/// Result of [`boot`]: tools to merge into the agent + device summary for the
117/// system prompt.
118pub struct HardwareBootResult {
119    /// Tools to extend into the agent's `tools_registry`.
120    pub tools: Vec<Box<dyn zeroclaw_api::tool::Tool>>,
121    /// Human-readable device summary for the LLM system prompt.
122    pub device_summary: String,
123    /// Content of `~/.zeroclaw/hardware/` context files (HARDWARE.md, device
124    /// profiles, and skills) for injection into the system prompt.
125    pub context_files_prompt: String,
126}
127
128/// Load hardware context files from `~/.zeroclaw/hardware/` and return them
129/// concatenated as a single markdown string ready for system-prompt injection.
130///
131/// Reads (if they exist):
132/// 1. `~/.zeroclaw/hardware/HARDWARE.md`
133/// 2. `~/.zeroclaw/hardware/devices/<alias>.md` for each discovered alias
134/// 3. All `~/.zeroclaw/hardware/skills/*.md` files (sorted by name)
135///
136/// Missing files are silently skipped. Returns an empty string when no files
137/// are found.
138pub fn load_hardware_context_prompt(aliases: &[&str]) -> String {
139    let home = match directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) {
140        Some(h) => h,
141        None => return String::new(),
142    };
143    load_hardware_context_from_dir(&home.join(".zeroclaw").join("hardware"), aliases)
144}
145
146/// Inner helper that reads hardware context from an explicit base directory.
147/// Separated from [`load_hardware_context_prompt`] to allow unit-testing with
148/// a temporary directory.
149pub fn load_hardware_context_from_dir(hw_dir: &std::path::Path, aliases: &[&str]) -> String {
150    let mut sections: Vec<String> = Vec::new();
151
152    // 1. Global HARDWARE.md
153    let global = hw_dir.join("HARDWARE.md");
154    if let Ok(content) = std::fs::read_to_string(&global)
155        && !content.trim().is_empty()
156    {
157        sections.push(content.trim().to_string());
158    }
159
160    // 2. Per-device profile
161    let devices_dir = hw_dir.join("devices");
162    for alias in aliases {
163        let path = devices_dir.join(format!("{alias}.md"));
164        ::zeroclaw_log::record!(
165            INFO,
166            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
167            &format!("loading device file: {:?}", path)
168        );
169        if let Ok(content) = std::fs::read_to_string(&path)
170            && !content.trim().is_empty()
171        {
172            sections.push(content.trim().to_string());
173        }
174    }
175
176    // 3. Skills directory (*.md files, sorted)
177    let skills_dir = hw_dir.join("skills");
178    if let Ok(entries) = std::fs::read_dir(&skills_dir) {
179        let mut skill_paths: Vec<std::path::PathBuf> = entries
180            .filter_map(|e| e.ok())
181            .map(|e| e.path())
182            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
183            .collect();
184        skill_paths.sort();
185        for path in skill_paths {
186            if let Ok(content) = std::fs::read_to_string(&path)
187                && !content.trim().is_empty()
188            {
189                sections.push(content.trim().to_string());
190            }
191        }
192    }
193
194    if sections.is_empty() {
195        return String::new();
196    }
197    sections.join("\n\n")
198}
199
200/// Inject RPi self-discovery tools and system prompt context into the boot result.
201///
202/// Called from both `boot()` variants when the `peripheral-rpi` feature is active
203/// and the binary is running on Linux. If `/proc/device-tree/model` (or
204/// `/proc/cpuinfo`) identifies a Raspberry Pi, the four built-in GPIO/info
205/// tools are added to `tools` and the board description is appended to
206/// `context_files_prompt` so the LLM knows it is running on the device.
207#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
208fn inject_rpi_context(
209    tools: &mut Vec<Box<dyn zeroclaw_api::tool::Tool>>,
210    context_files_prompt: &mut String,
211) {
212    if let Some(ctx) = rpi::RpiSystemContext::discover() {
213        ::zeroclaw_log::record!(
214            INFO,
215            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
216                ::serde_json::json!({"board": ctx.model.display_name(), "ip": ctx.ip_address})
217            ),
218            "RPi self-discovery complete"
219        );
220        if let Some(led) = ctx.model.onboard_led_gpio() {
221            ::zeroclaw_log::record!(
222                INFO,
223                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
224                    .with_attrs(::serde_json::json!({"gpio": led})),
225                "Onboard ACT LED"
226            );
227        }
228        println!("[registry] rpi0 ready \u{2192} /dev/gpiomem");
229        if ctx.gpio_available {
230            tools.push(Box::new(rpi::GpioRpiWriteTool));
231            tools.push(Box::new(rpi::GpioRpiReadTool));
232            tools.push(Box::new(rpi::GpioRpiBlinkTool));
233            println!("[registry] loaded built-in: gpio_rpi_write");
234            println!("[registry] loaded built-in: gpio_rpi_read");
235            println!("[registry] loaded built-in: gpio_rpi_blink");
236        }
237        tools.push(Box::new(rpi::RpiSystemInfoTool));
238        println!("[registry] loaded built-in: rpi_system_info");
239        ctx.write_hardware_context_file();
240        // Load the device profile (rpi0.md) that was just written so its full
241        // GPIO reference and tool-usage rules appear in the system prompt.
242        let device_ctx = load_hardware_context_prompt(&["rpi0"]);
243        if !device_ctx.is_empty() {
244            if !context_files_prompt.is_empty() {
245                context_files_prompt.push_str("\n\n");
246            }
247            context_files_prompt.push_str("## Connected Hardware Devices\n\n");
248            context_files_prompt.push_str(&device_ctx);
249        }
250        let rpi_prompt = ctx.to_system_prompt();
251        if !context_files_prompt.is_empty() {
252            context_files_prompt.push_str("\n\n");
253        }
254        context_files_prompt.push_str(&rpi_prompt);
255    }
256}
257
258/// Boot the hardware subsystem: discover devices + load tool registry.
259///
260/// With the `hardware` feature: enumerates USB-serial devices, then
261/// pre-registers any config-specified serial boards not already found by
262/// discovery. [`HardwareSerialTransport`] opens the port lazily per-send,
263/// so this succeeds even when the port doesn't exist at startup.
264///
265/// Without the feature: loads plugin tools from `~/.zeroclaw/tools/` only,
266/// with an empty device registry (GPIO tools will report "no device found"
267/// if called, which is correct).
268#[cfg(feature = "hardware")]
269#[allow(unused_mut)] // tools and context_files_prompt are mutated on Linux+peripheral-rpi
270pub async fn boot(
271    peripherals: &zeroclaw_config::schema::PeripheralsConfig,
272) -> anyhow::Result<HardwareBootResult> {
273    use self::serial::HardwareSerialTransport;
274    use device::DeviceCapabilities;
275
276    let mut registry_inner = DeviceRegistry::discover().await;
277
278    // Pre-register config-specified serial boards not already found by USB
279    // discovery. Transport opens lazily, so the port need not exist at boot.
280    if peripherals.enabled {
281        let mut discovered_paths: std::collections::HashSet<String> = registry_inner
282            .all()
283            .iter()
284            .filter_map(|d| d.device_path.clone())
285            .collect();
286
287        for board in &peripherals.boards {
288            if board.transport != "serial" {
289                continue;
290            }
291            let path = match &board.path {
292                Some(p) if !p.is_empty() => p.clone(),
293                _ => continue,
294            };
295            if discovered_paths.contains(&path) {
296                continue; // already registered by USB discovery or a previous config entry
297            }
298            let alias = registry_inner.register(&board.board, None, None, Some(path.clone()), None);
299            let transport = std::sync::Arc::new(HardwareSerialTransport::new(&path, board.baud))
300                as std::sync::Arc<dyn transport::Transport>;
301            let caps = DeviceCapabilities {
302                gpio: true,
303                ..DeviceCapabilities::default()
304            };
305            registry_inner
306                .attach_transport(&alias, transport, caps)
307                .unwrap_or_else(|e| {
308                    ::zeroclaw_log::record!(
309                        WARN,
310                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
311                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
312                            .with_attrs(
313                                ::serde_json::json!({"alias": alias, "err": e.to_string()})
314                            ),
315                        "attach_transport: unexpected unknown alias"
316                    )
317                });
318            // Mark path as registered so duplicate config entries are skipped.
319            discovered_paths.insert(path.clone());
320            ::zeroclaw_log::record!(
321                INFO,
322                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
323                    .with_attrs(
324                        ::serde_json::json!({"board": board.board, "path": path, "alias": alias})
325                    ),
326                "pre-registered config board with lazy serial transport"
327            );
328        }
329    }
330
331    // BOOTSEL auto-detect: warn the user if a Pico is in BOOTSEL mode at startup.
332    if uf2::find_rpi_rp2_mount().is_some() {
333        ::zeroclaw_log::record!(
334            INFO,
335            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
336            "Pico detected in BOOTSEL mode (RPI-RP2 drive found)"
337        );
338        ::zeroclaw_log::record!(
339            INFO,
340            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
341            "Say \"flash my pico\" to install ZeroClaw firmware automatically"
342        );
343    }
344
345    // Aardvark discovery: scan for Total Phase Aardvark USB adapters and
346    // register each one with AardvarkTransport + full I2C/SPI/GPIO capabilities.
347    {
348        use aardvark::AardvarkTransport;
349        use device::DeviceCapabilities;
350
351        let aardvark_ports = aardvark_sys::AardvarkHandle::find_devices();
352        for (i, &port) in aardvark_ports.iter().enumerate() {
353            let alias = registry_inner.register(
354                "aardvark",
355                Some(0x2b76),
356                None,
357                None,
358                Some("Total Phase Aardvark".to_string()),
359            );
360            let transport = std::sync::Arc::new(AardvarkTransport::new(i32::from(port), 100))
361                as std::sync::Arc<dyn transport::Transport>;
362            let caps = DeviceCapabilities {
363                gpio: true,
364                i2c: true,
365                spi: true,
366                ..DeviceCapabilities::default()
367            };
368            registry_inner
369                .attach_transport(&alias, transport, caps)
370                .unwrap_or_else(|e| {
371                    ::zeroclaw_log::record!(
372                        WARN,
373                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
374                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
375                            .with_attrs(
376                                ::serde_json::json!({"alias": alias, "err": e.to_string()})
377                            ),
378                        "aardvark attach_transport failed"
379                    )
380                });
381            ::zeroclaw_log::record!(
382                INFO,
383                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
384                    .with_attrs(::serde_json::json!({"alias": alias, "port_index": i})),
385                "aardvark adapter registered"
386            );
387            println!("[registry] {alias} ready \u{2192} Total Phase port {i}");
388        }
389    }
390
391    let devices = std::sync::Arc::new(tokio::sync::RwLock::new(registry_inner));
392    let registry = ToolRegistry::load(devices.clone()).await?;
393    let device_summary = {
394        let reg = devices.read().await;
395        reg.prompt_summary()
396    };
397    let mut tools = registry.into_tools();
398    if !tools.is_empty() {
399        ::zeroclaw_log::record!(
400            INFO,
401            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
402                .with_attrs(::serde_json::json!({"count": tools.len()})),
403            "Hardware registry tools loaded"
404        );
405    }
406    let alias_strings: Vec<String> = {
407        let reg = devices.read().await;
408        reg.aliases()
409            .into_iter()
410            .map(|s: &str| s.to_string())
411            .collect()
412    };
413    let alias_refs: Vec<&str> = alias_strings.iter().map(|s: &String| s.as_str()).collect();
414    let mut context_files_prompt = load_hardware_context_prompt(&alias_refs);
415    if !context_files_prompt.is_empty() {
416        ::zeroclaw_log::record!(
417            INFO,
418            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
419            "Hardware context files loaded"
420        );
421    }
422    // RPi self-discovery: detect board model and inject GPIO tools + prompt context.
423    #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
424    inject_rpi_context(&mut tools, &mut context_files_prompt);
425    Ok(HardwareBootResult {
426        tools,
427        device_summary,
428        context_files_prompt,
429    })
430}
431
432/// Fallback when the `hardware` feature is disabled — plugins only.
433#[cfg(not(feature = "hardware"))]
434#[allow(unused_mut)] // tools and context_files_prompt are mutated on Linux+peripheral-rpi
435pub async fn boot(
436    _peripherals: &zeroclaw_config::schema::PeripheralsConfig,
437) -> anyhow::Result<HardwareBootResult> {
438    let devices = std::sync::Arc::new(tokio::sync::RwLock::new(DeviceRegistry::new()));
439    let registry = ToolRegistry::load(devices.clone()).await?;
440    let device_summary = {
441        let reg = devices.read().await;
442        reg.prompt_summary()
443    };
444    let mut tools = registry.into_tools();
445    if !tools.is_empty() {
446        ::zeroclaw_log::record!(
447            INFO,
448            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
449                .with_attrs(::serde_json::json!({"count": tools.len()})),
450            "Hardware registry tools loaded (plugins only)"
451        );
452    }
453    // No discovered devices in no-hardware fallback; still load global files.
454    let mut context_files_prompt = load_hardware_context_prompt(&[]);
455    // RPi self-discovery: detect board model and inject GPIO tools + prompt context.
456    #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
457    inject_rpi_context(&mut tools, &mut context_files_prompt);
458    Ok(HardwareBootResult {
459        tools,
460        device_summary,
461        context_files_prompt,
462    })
463}
464
465/// A hardware device discovered during auto-scan.
466#[derive(Debug, Clone)]
467pub struct DiscoveredDevice {
468    pub name: String,
469    pub detail: Option<String>,
470    pub device_path: Option<String>,
471    pub transport: HardwareTransport,
472}
473
474/// Auto-discover connected hardware devices.
475/// Returns an empty vec on platforms without hardware support.
476pub fn discover_hardware() -> Vec<DiscoveredDevice> {
477    // USB/serial discovery is behind the "hardware" feature gate and only
478    // available on platforms where nusb supports device enumeration.
479    #[cfg(all(
480        feature = "hardware",
481        any(target_os = "linux", target_os = "macos", target_os = "windows")
482    ))]
483    {
484        if let Ok(devices) = discover::list_usb_devices() {
485            return devices
486                .into_iter()
487                .map(|d| DiscoveredDevice {
488                    name: d
489                        .board_name
490                        .unwrap_or_else(|| format!("{:04x}:{:04x}", d.vid, d.pid)),
491                    detail: d.product_string,
492                    device_path: None,
493                    transport: if d.architecture.as_deref() == Some("native") {
494                        HardwareTransport::Native
495                    } else {
496                        HardwareTransport::Serial
497                    },
498                })
499                .collect();
500        }
501    }
502    Vec::new()
503}
504
505/// Return the recommended default wizard choice index based on discovered devices.
506/// 0 = Native, 1 = Tethered/Serial, 2 = Debug Probe, 3 = Software Only
507pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize {
508    if devices.is_empty() {
509        3 // software only
510    } else {
511        1 // tethered (most common for detected USB devices)
512    }
513}
514
515/// Build a `HardwareConfig` from the wizard menu choice (0–3) and discovered devices.
516pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig {
517    match choice {
518        0 => HardwareConfig {
519            enabled: true,
520            transport: HardwareTransport::Native,
521            ..HardwareConfig::default()
522        },
523        1 => {
524            let serial_port = devices
525                .iter()
526                .find(|d| d.transport == HardwareTransport::Serial)
527                .and_then(|d| d.device_path.clone());
528            HardwareConfig {
529                enabled: true,
530                transport: HardwareTransport::Serial,
531                serial_port,
532                ..HardwareConfig::default()
533            }
534        }
535        2 => HardwareConfig {
536            enabled: true,
537            transport: HardwareTransport::Probe,
538            ..HardwareConfig::default()
539        },
540        _ => HardwareConfig::default(), // software only
541    }
542}
543#[cfg(feature = "hardware")]
544pub fn run_discover() -> Result<()> {
545    let devices = discover::list_usb_devices()?;
546
547    if devices.is_empty() {
548        println!("No USB devices found.");
549        println!();
550        println!("Connect a board (e.g. Nucleo-F401RE) via USB and try again.");
551        return Ok(());
552    }
553
554    println!("USB devices:");
555    println!();
556    for d in &devices {
557        let board = d.board_name.as_deref().unwrap_or("(unknown)");
558        let arch = d.architecture.as_deref().unwrap_or("—");
559        let product = d.product_string.as_deref().unwrap_or("—");
560        println!(
561            "  {:04x}:{:04x}  {}  {}  {}",
562            d.vid, d.pid, board, arch, product
563        );
564    }
565    println!();
566    println!("Known boards: nucleo-f401re, nucleo-f411re, arduino-uno, arduino-mega, cp2102");
567
568    Ok(())
569}
570
571#[cfg(all(
572    feature = "hardware",
573    any(target_os = "linux", target_os = "macos", target_os = "windows")
574))]
575#[cfg(feature = "hardware")]
576pub fn run_introspect(path: &str) -> Result<()> {
577    let result = introspect::introspect_device(path)?;
578
579    println!("Device at {}:", result.path);
580    println!();
581    if let (Some(vid), Some(pid)) = (result.vid, result.pid) {
582        println!("  VID:PID     {:04x}:{:04x}", vid, pid);
583    } else {
584        println!("  VID:PID     (could not correlate with USB device)");
585    }
586    if let Some(name) = &result.board_name {
587        println!("  Board       {}", name);
588    }
589    if let Some(arch) = &result.architecture {
590        println!("  Architecture {}", arch);
591    }
592    println!("  Memory map  {}", result.memory_map_note);
593
594    Ok(())
595}
596
597#[cfg(all(
598    feature = "hardware",
599    any(target_os = "linux", target_os = "macos", target_os = "windows")
600))]
601#[cfg(feature = "hardware")]
602pub fn run_info(chip: &str) -> Result<()> {
603    #[cfg(feature = "probe")]
604    {
605        match info_via_probe(chip) {
606            Ok(()) => Ok(()),
607            Err(e) => {
608                println!("probe-rs attach failed: {}", e);
609                println!();
610                println!(
611                    "Ensure Nucleo is connected via USB. The ST-Link is built into the board."
612                );
613                println!("No firmware needs to be flashed — probe-rs reads chip info over SWD.");
614                Err(e)
615            }
616        }
617    }
618
619    #[cfg(not(feature = "probe"))]
620    {
621        println!("Chip info via USB requires the 'probe' feature.");
622        println!();
623        println!("Build with: cargo build --features hardware,probe");
624        println!();
625        println!("Then run: zeroclaw hardware info --chip {}", chip);
626        println!();
627        println!("This uses probe-rs to attach to the Nucleo's ST-Link over USB");
628        println!("and read chip info (memory map, etc.) — no firmware on target needed.");
629        Ok(())
630    }
631}
632
633#[cfg(all(
634    feature = "hardware",
635    feature = "probe",
636    any(target_os = "linux", target_os = "macos", target_os = "windows")
637))]
638fn info_via_probe(chip: &str) -> anyhow::Result<()> {
639    use probe_rs::config::MemoryRegion;
640    use probe_rs::{Session, SessionConfig};
641
642    println!("Connecting to {} via USB (ST-Link)...", chip);
643    let session = Session::auto_attach(chip, SessionConfig::default()).map_err(|e| {
644        ::zeroclaw_log::record!(
645            WARN,
646            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
647                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
648                .with_attrs(::serde_json::json!({
649                    "chip": chip,
650                    "error": format!("{}", e),
651                })),
652            "probe-rs auto_attach failed (info CLI path)"
653        );
654        anyhow::Error::msg(e.to_string())
655    })?;
656
657    let target = session.target();
658    println!();
659    println!("Chip: {}", target.name);
660    println!("Architecture: {:?}", session.architecture());
661    println!();
662    println!("Memory map:");
663    for region in target.memory_map.iter() {
664        match region {
665            MemoryRegion::Ram(ram) => {
666                let start = ram.range.start;
667                let end = ram.range.end;
668                let size_kb = (end - start) / 1024;
669                println!("  RAM: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb);
670            }
671            MemoryRegion::Nvm(flash) => {
672                let start = flash.range.start;
673                let end = flash.range.end;
674                let size_kb = (end - start) / 1024;
675                println!("  Flash: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb);
676            }
677            _ => {}
678        }
679    }
680    println!();
681    println!("Info read via USB (SWD) — no firmware on target needed.");
682    Ok(())
683}
684
685#[cfg(test)]
686mod tests {
687    use super::load_hardware_context_from_dir;
688    use std::fs;
689
690    fn write(path: &std::path::Path, content: &str) {
691        if let Some(parent) = path.parent() {
692            fs::create_dir_all(parent).unwrap();
693        }
694        fs::write(path, content).unwrap();
695    }
696
697    #[test]
698    fn empty_dir_returns_empty_string() {
699        let tmp = tempfile::tempdir().unwrap();
700        assert_eq!(load_hardware_context_from_dir(tmp.path(), &[]), "");
701    }
702
703    #[test]
704    fn hardware_md_only_returns_its_content() {
705        let tmp = tempfile::tempdir().unwrap();
706        write(&tmp.path().join("HARDWARE.md"), "# Global HW\npin 25 = LED");
707        let result = load_hardware_context_from_dir(tmp.path(), &[]);
708        assert!(result.contains("pin 25 = LED"), "got: {result}");
709    }
710
711    #[test]
712    fn device_profile_loaded_for_matching_alias() {
713        let tmp = tempfile::tempdir().unwrap();
714        write(
715            &tmp.path().join("devices").join("pico0.md"),
716            "# pico0\nPort: /dev/cu.usbmodem1101",
717        );
718        let result = load_hardware_context_from_dir(tmp.path(), &["pico0"]);
719        assert!(result.contains("/dev/cu.usbmodem1101"), "got: {result}");
720    }
721
722    #[test]
723    fn device_profile_skipped_for_non_matching_alias() {
724        let tmp = tempfile::tempdir().unwrap();
725        write(
726            &tmp.path().join("devices").join("pico0.md"),
727            "# pico0\nPort: /dev/cu.usbmodem1101",
728        );
729        // No alias provided — device profile must not appear
730        let result = load_hardware_context_from_dir(tmp.path(), &[]);
731        assert!(!result.contains("pico0"), "got: {result}");
732    }
733
734    #[test]
735    fn skills_loaded_and_sorted() {
736        let tmp = tempfile::tempdir().unwrap();
737        write(
738            &tmp.path().join("skills").join("blink.md"),
739            "# Skill: Blink\nuse device_exec",
740        );
741        write(
742            &tmp.path().join("skills").join("gpio.md"),
743            "# Skill: GPIO\ngpio_write",
744        );
745        load_hardware_context_from_dir(tmp.path(), &[]);
746        // blink.md sorts before gpio.md
747    }
748}