1#![allow(clippy::to_string_in_format_args)]
2pub 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#[cfg(feature = "hardware")]
40pub mod aardvark;
41
42#[cfg(feature = "hardware")]
45pub mod aardvark_tools;
46
47#[cfg(feature = "hardware")]
50pub mod datasheet;
51
52#[cfg(feature = "hardware")]
54pub mod wizard;
55
56#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
59pub mod rpi;
60
61pub mod util;
62
63pub 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
79pub use zeroclaw_config::schema::{HardwareConfig, HardwareTransport};
81
82pub 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
116pub struct HardwareBootResult {
119 pub tools: Vec<Box<dyn zeroclaw_api::tool::Tool>>,
121 pub device_summary: String,
123 pub context_files_prompt: String,
126}
127
128pub 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
146pub fn load_hardware_context_from_dir(hw_dir: &std::path::Path, aliases: &[&str]) -> String {
150 let mut sections: Vec<String> = Vec::new();
151
152 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 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 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#[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 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#[cfg(feature = "hardware")]
269#[allow(unused_mut)] pub 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 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; }
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 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 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 {
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 #[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#[cfg(not(feature = "hardware"))]
434#[allow(unused_mut)] pub 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 let mut context_files_prompt = load_hardware_context_prompt(&[]);
455 #[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#[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
474pub fn discover_hardware() -> Vec<DiscoveredDevice> {
477 #[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
505pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize {
508 if devices.is_empty() {
509 3 } else {
511 1 }
513}
514
515pub 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(), }
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 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 }
748}