1#![allow(clippy::to_string_in_format_args)]
2pub 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#[cfg(feature = "hardware")]
39pub mod aardvark;
40
41#[cfg(feature = "hardware")]
44pub mod aardvark_tools;
45
46#[cfg(feature = "hardware")]
49pub mod datasheet;
50
51#[cfg(feature = "hardware")]
53pub mod wizard;
54
55#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
58pub mod rpi;
59
60pub mod util;
61
62pub 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
78pub use zeroclaw_config::schema::{HardwareConfig, HardwareTransport};
80
81pub 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
115pub struct HardwareBootResult {
118 pub tools: Vec<Box<dyn zeroclaw_api::tool::Tool>>,
120 pub device_summary: String,
122 pub context_files_prompt: String,
125}
126
127pub 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
145pub fn load_hardware_context_from_dir(hw_dir: &std::path::Path, aliases: &[&str]) -> String {
149 let mut sections: Vec<String> = Vec::new();
150
151 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 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 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#[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 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#[cfg(feature = "hardware")]
268#[allow(unused_mut)] pub 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 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; }
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 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 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 {
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 #[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#[cfg(not(feature = "hardware"))]
433#[allow(unused_mut)] pub 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 let mut context_files_prompt = load_hardware_context_prompt(&[]);
454 #[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#[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
473pub fn discover_hardware() -> Vec<DiscoveredDevice> {
476 #[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
504pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize {
507 if devices.is_empty() {
508 3 } else {
510 1 }
512}
513
514pub 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(), }
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 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 }
747}