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