From de3ec87d163adc673dcace292bbc2e097b389b41 Mon Sep 17 00:00:00 2001 From: ehu shubham shaw <106058299+Extreammouse@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:40:10 -0500 Subject: [PATCH] Ehu shubham shaw contribution --> Hardware support (#306) * feat: add ZeroClaw firmware for ESP32 and Nucleo * Introduced new firmware for ZeroClaw on ESP32 and Nucleo-F401RE, enabling JSON-over-serial communication for GPIO control. * Added `zeroclaw-esp32` with support for commands like `gpio_read` and `gpio_write`, along with capabilities reporting. * Implemented `zeroclaw-nucleo` firmware with similar functionality for STM32, ensuring compatibility with existing ZeroClaw protocols. * Updated `.gitignore` to include new firmware targets and added necessary dependencies in `Cargo.toml` for both platforms. * Created README files for both firmware projects detailing setup, build, and usage instructions. Co-authored-by: Claude Opus 4.6 * feat: enhance hardware peripheral support and documentation - Added `Peripheral` trait implementation in `src/peripherals/` to manage hardware boards (STM32, RPi GPIO). - Updated `AGENTS.md` to include new extension points for peripherals and their configuration. - Introduced comprehensive documentation for adding boards and tools, including a quick start guide and supported boards. - Enhanced `Cargo.toml` to include optional dependencies for PDF extraction and peripheral support. - Created new datasheets for Arduino Uno, ESP32, and Nucleo-F401RE, detailing pin aliases and GPIO usage. - Implemented new tools for hardware memory reading and board information retrieval in the agent loop. This update significantly improves the integration and usability of hardware peripherals within the ZeroClaw framework. * feat: add ZeroClaw firmware for ESP32 and Nucleo * Introduced new firmware for ZeroClaw on ESP32 and Nucleo-F401RE, enabling JSON-over-serial communication for GPIO control. * Added `zeroclaw-esp32` with support for commands like `gpio_read` and `gpio_write`, along with capabilities reporting. * Implemented `zeroclaw-nucleo` firmware with similar functionality for STM32, ensuring compatibility with existing ZeroClaw protocols. * Updated `.gitignore` to include new firmware targets and added necessary dependencies in `Cargo.toml` for both platforms. * Created README files for both firmware projects detailing setup, build, and usage instructions. Co-authored-by: Claude Opus 4.6 * feat: enhance hardware peripheral support and documentation - Added `Peripheral` trait implementation in `src/peripherals/` to manage hardware boards (STM32, RPi GPIO). - Updated `AGENTS.md` to include new extension points for peripherals and their configuration. - Introduced comprehensive documentation for adding boards and tools, including a quick start guide and supported boards. - Enhanced `Cargo.toml` to include optional dependencies for PDF extraction and peripheral support. - Created new datasheets for Arduino Uno, ESP32, and Nucleo-F401RE, detailing pin aliases and GPIO usage. - Implemented new tools for hardware memory reading and board information retrieval in the agent loop. This update significantly improves the integration and usability of hardware peripherals within the ZeroClaw framework. * feat: Introduce hardware auto-discovery and expanded configuration options for agents, hardware, and security. * chore: update dependencies and improve probe-rs integration - Updated `Cargo.lock` to remove specific version constraints for several dependencies, including `zerocopy`, `syn`, and `strsim`, allowing for more flexibility in version resolution. - Upgraded `bincode` and `bitfield` to their latest versions, enhancing serialization and memory management capabilities. - Updated `Cargo.toml` to reflect the new version of `probe-rs` from `0.24` to `0.30`, improving hardware probing functionality. - Refactored code in `src/hardware` and `src/tools` to utilize the new `SessionConfig` for session management in `probe-rs`, ensuring better compatibility and performance. - Cleaned up documentation in `docs/datasheets/nucleo-f401re.md` by removing unnecessary lines. * fix: apply cargo fmt * docs: add hardware architecture diagram. --------- Co-authored-by: Claude Opus 4.6 --- .gitignore | 3 +- AGENTS.md | 15 +- Cargo.lock | 1266 +++++++++++- Cargo.toml | 36 +- docs/Hardware_architecture.jpg | Bin 0 -> 85764 bytes docs/adding-boards-and-tools.md | 116 ++ docs/arduino-uno-q-setup.md | 217 ++ docs/datasheets/arduino-uno.md | 37 + docs/datasheets/esp32.md | 22 + docs/datasheets/nucleo-f401re.md | 16 + docs/hardware-peripherals-design.md | 324 +++ docs/network-deployment.md | 182 ++ docs/nucleo-setup.md | 147 ++ .../zeroclaw-arduino/zeroclaw-arduino.ino | 143 ++ firmware/zeroclaw-esp32/.cargo/config.toml | 5 + firmware/zeroclaw-esp32/Cargo.lock | 1840 +++++++++++++++++ firmware/zeroclaw-esp32/Cargo.toml | 35 + firmware/zeroclaw-esp32/README.md | 52 + firmware/zeroclaw-esp32/build.rs | 3 + firmware/zeroclaw-esp32/src/main.rs | 154 ++ firmware/zeroclaw-nucleo/Cargo.lock | 849 ++++++++ firmware/zeroclaw-nucleo/Cargo.toml | 39 + firmware/zeroclaw-nucleo/src/main.rs | 187 ++ firmware/zeroclaw-uno-q-bridge/app.yaml | 9 + firmware/zeroclaw-uno-q-bridge/python/main.py | 66 + .../python/requirements.txt | 1 + .../zeroclaw-uno-q-bridge/sketch/sketch.ino | 24 + .../zeroclaw-uno-q-bridge/sketch/sketch.yaml | 11 + src/agent/loop_.rs | 339 ++- src/agent/mod.rs | 15 +- src/channels/mod.rs | 120 +- src/config/mod.rs | 14 +- src/config/schema.rs | 521 +++-- src/daemon/mod.rs | 2 +- src/gateway/mod.rs | 2 + src/hardware/discover.rs | 45 + src/hardware/introspect.rs | 121 ++ src/hardware/mod.rs | 1511 ++------------ src/hardware/registry.rs | 102 + src/lib.rs | 116 +- src/main.rs | 46 +- src/onboard/wizard.rs | 212 +- src/peripherals/arduino_flash.rs | 144 ++ src/peripherals/arduino_upload.rs | 161 ++ src/peripherals/capabilities_tool.rs | 99 + src/peripherals/mod.rs | 231 +++ src/peripherals/nucleo_flash.rs | 83 + src/peripherals/rpi.rs | 173 ++ src/peripherals/serial.rs | 274 +++ src/peripherals/traits.rs | 33 + src/peripherals/uno_q_bridge.rs | 151 ++ src/peripherals/uno_q_setup.rs | 143 ++ src/providers/compatible.rs | 37 +- src/providers/mod.rs | 4 +- src/rag/mod.rs | 397 ++++ src/tools/hardware_board_info.rs | 205 ++ src/tools/hardware_memory_map.rs | 205 ++ src/tools/hardware_memory_read.rs | 181 ++ src/tools/mod.rs | 6 + 59 files changed, 9607 insertions(+), 1885 deletions(-) create mode 100644 docs/Hardware_architecture.jpg create mode 100644 docs/adding-boards-and-tools.md create mode 100644 docs/arduino-uno-q-setup.md create mode 100644 docs/datasheets/arduino-uno.md create mode 100644 docs/datasheets/esp32.md create mode 100644 docs/datasheets/nucleo-f401re.md create mode 100644 docs/hardware-peripherals-design.md create mode 100644 docs/network-deployment.md create mode 100644 docs/nucleo-setup.md create mode 100644 firmware/zeroclaw-arduino/zeroclaw-arduino.ino create mode 100644 firmware/zeroclaw-esp32/.cargo/config.toml create mode 100644 firmware/zeroclaw-esp32/Cargo.lock create mode 100644 firmware/zeroclaw-esp32/Cargo.toml create mode 100644 firmware/zeroclaw-esp32/README.md create mode 100644 firmware/zeroclaw-esp32/build.rs create mode 100644 firmware/zeroclaw-esp32/src/main.rs create mode 100644 firmware/zeroclaw-nucleo/Cargo.lock create mode 100644 firmware/zeroclaw-nucleo/Cargo.toml create mode 100644 firmware/zeroclaw-nucleo/src/main.rs create mode 100644 firmware/zeroclaw-uno-q-bridge/app.yaml create mode 100644 firmware/zeroclaw-uno-q-bridge/python/main.py create mode 100644 firmware/zeroclaw-uno-q-bridge/python/requirements.txt create mode 100644 firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino create mode 100644 firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml create mode 100644 src/hardware/discover.rs create mode 100644 src/hardware/introspect.rs create mode 100644 src/hardware/registry.rs create mode 100644 src/peripherals/arduino_flash.rs create mode 100644 src/peripherals/arduino_upload.rs create mode 100644 src/peripherals/capabilities_tool.rs create mode 100644 src/peripherals/mod.rs create mode 100644 src/peripherals/nucleo_flash.rs create mode 100644 src/peripherals/rpi.rs create mode 100644 src/peripherals/serial.rs create mode 100644 src/peripherals/traits.rs create mode 100644 src/peripherals/uno_q_bridge.rs create mode 100644 src/peripherals/uno_q_setup.rs create mode 100644 src/rag/mod.rs create mode 100644 src/tools/hardware_board_info.rs create mode 100644 src/tools/hardware_memory_map.rs create mode 100644 src/tools/hardware_memory_read.rs diff --git a/.gitignore b/.gitignore index 1b068a3..badd0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target +firmware/*/target *.db *.db-journal .DS_Store .wt-pr37/ -docker-compose.override.yml +.env diff --git a/AGENTS.md b/AGENTS.md index 9c24ffd..cfbacfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Key extension points: - `src/memory/traits.rs` (`Memory`) - `src/observability/traits.rs` (`Observer`) - `src/runtime/traits.rs` (`RuntimeAdapter`) +- `src/peripherals/traits.rs` (`Peripheral`) — hardware boards (STM32, RPi GPIO) ## 2) Deep Architecture Observations (Why This Protocol Exists) @@ -141,7 +142,8 @@ Required: - `src/providers/` — model providers and resilient wrapper - `src/channels/` — Telegram/Discord/Slack/etc channels - `src/tools/` — tool execution surface (shell, file, memory, browser) -- `src/runtime/` — runtime adapters (currently native/docker) +- `src/peripherals/` — hardware peripherals (STM32, RPi GPIO); see `docs/hardware-peripherals-design.md` +- `src/runtime/` — runtime adapters (currently native) - `docs/` — architecture + process docs - `.github/` — CI, templates, automation workflows @@ -236,13 +238,14 @@ Use these rules to keep the trait/factory architecture stable under growth. - Validate and sanitize all inputs. - Return structured `ToolResult`; avoid panics in runtime path. -### 7.4 Memory / Runtime / Config Changes +### 5.4 Adding a Peripheral -- Keep compatibility explicit (config defaults, migration impact, fallback behavior). -- Add targeted tests for boundary conditions and unsupported values. -- Avoid hidden side effects in startup path. +- Implement `Peripheral` in `src/peripherals/`. +- Peripherals expose `tools()` — each tool delegates to the hardware (GPIO, sensors, etc.). +- Register board type in config schema if needed. +- See `docs/hardware-peripherals-design.md` for protocol and firmware notes. -### 7.5 Security / Gateway / CI Changes +### 5.5 Security / Runtime / Gateway Changes - Include threat/risk notes and rollback strategy. - Add/update tests or validation evidence for failure modes and boundaries. diff --git a/Cargo.lock b/Cargo.lock index 41924f2..6df10c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "aead" version = "0.5.2" @@ -12,6 +27,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,6 +50,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -101,7 +136,25 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" dependencies = [ - "object", + "object 0.37.3", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", ] [[package]] @@ -205,11 +258,72 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitfield" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "block-buffer" @@ -220,12 +334,47 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -238,6 +387,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -250,6 +408,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -368,12 +532,31 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ef0193218d365c251b5b9297f9911a908a8ddd2ebd3a36cc5d0ef0f63aee9e" +dependencies = [ + "heapless", + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -383,7 +566,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -396,7 +579,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -421,6 +604,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -455,6 +648,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "cron" version = "0.12.1" @@ -466,6 +668,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -477,12 +685,93 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deku" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9711031e209dc1306d66985363b4397d4c7b911597580340b93c9729b55f6eb" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cb0719583cbe4e81fb40434ace2f0d22ccc3e39a74bb3796c22b451b4f139d" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.6" @@ -569,12 +858,41 @@ dependencies = [ "syn", ] +[[package]] +name = "docsplay" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8547ea80db62c5bb9d7796fcce5e6e07d1136bdc1a02269095061e806758fab4" +dependencies = [ + "docsplay-macros", +] + +[[package]] +name = "docsplay-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11772ed3eb3db124d826f3abeadf5a791a557f62c19b123e3f07288158a71fdd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -603,6 +921,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -639,6 +966,57 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esp-idf-part" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ebc2381d030e4e89183554c3fcd4ad44dc5ab34961ab09e09b4adbe4f94b61" +dependencies = [ + "bitflags 2.11.0", + "csv", + "deku", + "md-5", + "parse_int", + "regex", + "serde", + "serde_plain", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "espflash" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f05d15cb2479a3cbbbe684b9f0831b2ae036d9faefd1eb08f21267275862f9" +dependencies = [ + "base64", + "bitflags 2.11.0", + "bytemuck", + "esp-idf-part", + "flate2", + "gimli", + "libc", + "log", + "md-5", + "miette", + "nix 0.30.1", + "object 0.38.1", + "serde", + "sha2", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -686,6 +1064,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -719,6 +1107,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -752,6 +1161,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -781,6 +1200,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -850,12 +1270,32 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -904,18 +1344,55 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hidapi" +version = "2.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "565dd4c730b8f8b2c0fb36df6be12e5470ae10895ddcc4e9dcfbfb495de202b0" +dependencies = [ + "cc", + "cfg-if", + "libc", + "nix 0.27.1", + "pkg-config", + "udev", + "windows-sys 0.48.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1241,6 +1718,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1262,6 +1745,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ihex" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365a784774bb381e8c19edb91190a90d7f2625e057b55de2bc0f6b57bc779ff2" + [[package]] name = "indexmap" version = "2.13.0" @@ -1280,9 +1769,31 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "io-kit-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b" +dependencies = [ + "core-foundation-sys", + "mach2", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1320,6 +1831,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jep106" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1354c92c91fd5595fd4cc46694b6914749cc90ea437246549c26b6ff0ec6d1" +dependencies = [ + "serde", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1405,7 +1925,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", ] @@ -1420,6 +1940,28 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1453,12 +1995,49 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" +dependencies = [ + "aes", + "bitflags 2.11.0", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.3.4", + "indexmap", + "itoa", + "log", + "md-5", + "nom 8.0.0", + "nom_locate", + "rand 0.9.2", + "rangemap", + "sha2", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "mail-parser" version = "0.11.2" @@ -1474,12 +2053,44 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -1502,6 +2113,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1509,10 +2130,79 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "mio-serial" +version = "5.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" +dependencies = [ + "log", + "mio", + "nix 0.29.0", + "serialport", + "winapi", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -1532,6 +2222,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1556,6 +2257,43 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nusb" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f861541f15de120eae5982923d073bfc0c1a65466561988c82d6e197734c19e" +dependencies = [ + "atomic-waker", + "core-foundation 0.9.4", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "libc", + "log", + "once_cell", + "rustix 0.38.44", + "slab", + "windows-sys 0.48.0", +] + +[[package]] +name = "nusb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0226f4db3ee78f820747cf713767722877f6449d7a0fcfbf2ec3b840969763f" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "futures-core", + "io-kit-sys", + "linux-raw-sys 0.9.4", + "log", + "once_cell", + "rustix 1.1.3", + "slab", + "windows-sys 0.60.2", +] + [[package]] name = "object" version = "0.37.3" @@ -1565,6 +2303,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "flate2", + "memchr", + "ruzstd", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1665,6 +2414,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1688,6 +2443,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse_int" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c464266693329dd5a8715098c7f86e6c5fd5d985018b8318f53d9c6c2b21a31" +dependencies = [ + "num-traits", +] + +[[package]] +name = "pdf-extract" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid", + "log", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1732,6 +2513,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -1743,6 +2538,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1777,6 +2584,65 @@ dependencies = [ "syn", ] +[[package]] +name = "probe-rs" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee27329ac37fa02b194c62a4e3c1aa053739884ea7bcf861249866d3bf7de00" +dependencies = [ + "anyhow", + "async-io", + "bincode", + "bitfield", + "bitvec", + "cobs", + "docsplay", + "dunce", + "espflash", + "flate2", + "futures-lite", + "hidapi", + "ihex", + "itertools", + "jep106", + "nusb 0.1.14", + "object 0.37.3", + "parking_lot", + "probe-rs-target", + "rmp-serde", + "scroll", + "serde", + "serde_yaml", + "serialport", + "thiserror 2.0.18", + "tracing", + "uf2-decode", + "zerocopy", +] + +[[package]] +name = "probe-rs-target" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239aca5dc62c68ca6d8ff0051fe617cb8363b803380fbc60567e67c82b474df" +dependencies = [ + "base64", + "indexmap", + "jep106", + "serde", + "serde_with", + "url", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1909,6 +2775,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -1968,13 +2840,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1999,6 +2877,35 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "reqwest" version = "0.12.28" @@ -2056,6 +2963,34 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rppal" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612e1a22e21f08a246657c6433fe52b773ae43d07c9ef88ccfc433cc8683caba" +dependencies = [ + "libc", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -2072,7 +3007,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2087,16 +3022,29 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2156,6 +3104,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ruzstd" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2177,14 +3134,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" + [[package]] name = "security-framework" version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2260,6 +3223,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.4" @@ -2281,6 +3253,52 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap", + "serde_core", + "serde_json", + "time", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serialport" +version = "4.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acaf3f973e8616d7ceac415f53fc60e190b2a686fbcf8d27d0256c741c5007b" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "core-foundation 0.10.1", + "core-foundation-sys", + "io-kit-sys", + "mach2", + "nix 0.26.4", + "scopeguard", + "unescaper", + "winapi", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2343,6 +3361,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -2396,12 +3420,44 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2439,6 +3495,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.25.0" @@ -2448,7 +3510,7 @@ dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2603,6 +3665,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-serial" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" +dependencies = [ + "cfg-if", + "futures", + "log", + "mio-serial", + "serialport", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2663,12 +3739,21 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.0.0+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -2678,6 +3763,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.8+spec-1.1.0" @@ -2746,7 +3843,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", @@ -2821,6 +3918,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.24.0" @@ -2841,24 +3944,93 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" +dependencies = [ + "pom", +] + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "udev" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50051c6e22be28ee6f217d50014f3bc29e81c20dc66ff7ca0d5c5226e1dcc5a1" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "uf2-decode" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77d41ab27e3fa45df42043f96c79b80c6d8632eed906b54681d8d47ab00623" + +[[package]] +name = "unescaper" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "unicase" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -2881,12 +4053,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "url" version = "2.5.8" @@ -2897,6 +4081,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2940,6 +4125,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "want" version = "0.3.1" @@ -3073,7 +4264,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -3137,6 +4328,34 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -3432,6 +4651,9 @@ name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -3491,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -3533,6 +4755,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.7.5" @@ -3605,11 +4836,15 @@ dependencies = [ "landlock", "lettre", "mail-parser", + "nusb 0.2.1", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "pdf-extract", + "probe-rs", "prometheus", "reqwest", + "rppal", "rusqlite", "rustls", "rustls-pki-types", @@ -3621,6 +4856,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-rustls", + "tokio-serial", "tokio-test", "tokio-tungstenite", "toml", diff --git a/Cargo.toml b/Cargo.toml index a096827..a9ff034 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,20 +97,30 @@ opentelemetry = { version = "0.31", default-features = false, features = ["trace opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] } opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] } +# USB device enumeration (hardware discovery) +nusb = { version = "0.2", default-features = false, optional = true } + +# Serial port for peripheral communication (STM32, etc.) +tokio-serial = { version = "5", default-features = false, optional = true } + +# probe-rs for STM32/Nucleo memory read (Phase B) +probe-rs = { version = "0.30", optional = true } + +# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf) +pdf-extract = { version = "0.10", optional = true } + +# Raspberry Pi GPIO (Linux/RPi only) — target-specific to avoid compile failure on macOS +[target.'cfg(target_os = "linux")'.dependencies] +rppal = { version = "0.14", optional = true } + [features] -default = [] -browser-native = ["dep:fantoccini"] - -# Sandbox backends (platform-specific, opt-in) -sandbox-landlock = ["landlock"] # Linux kernel LSM -sandbox-bubblewrap = [] # User namespaces (Linux/macOS) - -# Full security suite -security-full = ["sandbox-landlock"] - -[[bin]] -name = "zeroclaw" -path = "src/main.rs" +default = ["hardware"] +hardware = ["nusb", "tokio-serial"] +peripheral-rpi = ["rppal"] +# probe = probe-rs for Nucleo memory read (adds ~50 deps; optional) +probe = ["dep:probe-rs"] +# rag-pdf = PDF ingestion for datasheet RAG +rag-pdf = ["dep:pdf-extract"] [profile.release] opt-level = "z" # Optimize for size diff --git a/docs/Hardware_architecture.jpg b/docs/Hardware_architecture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8daf589a8f456391c82a8a488b15483d861e6a55 GIT binary patch literal 85764 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 literal 0 HcmV?d00001 diff --git a/docs/adding-boards-and-tools.md b/docs/adding-boards-and-tools.md new file mode 100644 index 0000000..a7e9eaa --- /dev/null +++ b/docs/adding-boards-and-tools.md @@ -0,0 +1,116 @@ +# Adding Boards and Tools — ZeroClaw Hardware Guide + +This guide explains how to add new hardware boards and custom tools to ZeroClaw. + +## Quick Start: Add a Board via CLI + +```bash +# Add a board (updates ~/.zeroclaw/config.toml) +zeroclaw peripheral add nucleo-f401re /dev/ttyACM0 +zeroclaw peripheral add arduino-uno /dev/cu.usbmodem12345 +zeroclaw peripheral add rpi-gpio native # for Raspberry Pi GPIO (Linux) + +# Restart daemon to apply +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +## Supported Boards + +| Board | Transport | Path Example | +|-----------------|-----------|---------------------------| +| nucleo-f401re | serial | /dev/ttyACM0, /dev/cu.usbmodem* | +| arduino-uno | serial | /dev/ttyACM0, /dev/cu.usbmodem* | +| arduino-uno-q | bridge | (Uno Q IP) | +| rpi-gpio | native | native | +| esp32 | serial | /dev/ttyUSB0 | + +## Manual Config + +Edit `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true +datasheet_dir = "docs/datasheets" # optional: RAG for "turn on red led" → pin 13 + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[[peripherals.boards]] +board = "arduino-uno" +transport = "serial" +path = "/dev/cu.usbmodem12345" +baud = 115200 +``` + +## Adding a Datasheet (RAG) + +Place `.md` or `.txt` files in `docs/datasheets/` (or your `datasheet_dir`). Name files by board: `nucleo-f401re.md`, `arduino-uno.md`. + +### Pin Aliases (Recommended) + +Add a `## Pin Aliases` section so the agent can map "red led" → pin 13: + +```markdown +# My Board + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| builtin_led | 13 | +| user_led | 5 | +``` + +Or use key-value format: + +```markdown +## Pin Aliases +red_led: 13 +builtin_led: 13 +``` + +### PDF Datasheets + +With the `rag-pdf` feature, ZeroClaw can index PDF files: + +```bash +cargo build --features hardware,rag-pdf +``` + +Place PDFs in the datasheet directory. They are extracted and chunked for RAG. + +## Adding a New Board Type + +1. **Create a datasheet** — `docs/datasheets/my-board.md` with pin aliases and GPIO info. +2. **Add to config** — `zeroclaw peripheral add my-board /dev/ttyUSB0` +3. **Implement a peripheral** (optional) — For custom protocols, implement the `Peripheral` trait in `src/peripherals/` and register in `create_peripheral_tools`. + +See `docs/hardware-peripherals-design.md` for the full design. + +## Adding a Custom Tool + +1. Implement the `Tool` trait in `src/tools/`. +2. Register in `create_peripheral_tools` (for hardware tools) or the agent tool registry. +3. Add a tool description to the agent's `tool_descs` in `src/agent/loop_.rs`. + +## CLI Reference + +| Command | Description | +|---------|-------------| +| `zeroclaw peripheral list` | List configured boards | +| `zeroclaw peripheral add ` | Add board (writes config) | +| `zeroclaw peripheral flash` | Flash Arduino firmware | +| `zeroclaw peripheral flash-nucleo` | Flash Nucleo firmware | +| `zeroclaw hardware discover` | List USB devices | +| `zeroclaw hardware info` | Chip info via probe-rs | + +## Troubleshooting + +- **Serial port not found** — On macOS use `/dev/cu.usbmodem*`; on Linux use `/dev/ttyACM0` or `/dev/ttyUSB0`. +- **Build with hardware** — `cargo build --features hardware` +- **Probe-rs for Nucleo** — `cargo build --features hardware,probe` diff --git a/docs/arduino-uno-q-setup.md b/docs/arduino-uno-q-setup.md new file mode 100644 index 0000000..8e170e8 --- /dev/null +++ b/docs/arduino-uno-q-setup.md @@ -0,0 +1,217 @@ +# ZeroClaw on Arduino Uno Q — Step-by-Step Guide + +Run ZeroClaw on the Arduino Uno Q's Linux side. Telegram works over WiFi; GPIO control uses the Bridge (requires a minimal App Lab app). + +--- + +## What's Included (No Code Changes Needed) + +ZeroClaw includes everything needed for Arduino Uno Q. **Clone the repo and follow this guide — no patches or custom code required.** + +| Component | Location | Purpose | +|-----------|----------|---------| +| Bridge app | `firmware/zeroclaw-uno-q-bridge/` | MCU sketch + Python socket server (port 9999) for GPIO | +| Bridge tools | `src/peripherals/uno_q_bridge.rs` | `gpio_read` / `gpio_write` tools that talk to the Bridge over TCP | +| Setup command | `src/peripherals/uno_q_setup.rs` | `zeroclaw peripheral setup-uno-q` deploys the Bridge via scp + arduino-app-cli | +| Config schema | `board = "arduino-uno-q"`, `transport = "bridge"` | Supported in `config.toml` | + +Build with `--features hardware` (or the default features) to include Uno Q support. + +--- + +## Prerequisites + +- Arduino Uno Q with WiFi configured +- Arduino App Lab installed on your Mac (for initial setup and deployment) +- API key for LLM (OpenRouter, etc.) + +--- + +## Phase 1: Initial Uno Q Setup (One-Time) + +### 1.1 Configure Uno Q via App Lab + +1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux). +2. Connect Uno Q via USB, power it on. +3. Open App Lab, connect to the board. +4. Follow the setup wizard: + - Set username and password (for SSH) + - Configure WiFi (SSID, password) + - Apply any firmware updates +5. Note the IP address shown (e.g. `arduino@192.168.1.42`) or find it later via `ip addr show` in App Lab's terminal. + +### 1.2 Verify SSH Access + +```bash +ssh arduino@ +# Enter the password you set +``` + +--- + +## Phase 2: Install ZeroClaw on Uno Q + +### Option A: Build on the Device (Simpler, ~20–40 min) + +```bash +# SSH into Uno Q +ssh arduino@ + +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +source ~/.cargo/env + +# Install build deps (Debian) +sudo apt-get update +sudo apt-get install -y pkg-config libssl-dev + +# Clone zeroclaw (or scp your project) +git clone https://github.com/theonlyhennygod/zeroclaw.git +cd zeroclaw + +# Build (takes ~15–30 min on Uno Q) +cargo build --release + +# Install +sudo cp target/release/zeroclaw /usr/local/bin/ +``` + +### Option B: Cross-Compile on Mac (Faster) + +```bash +# On your Mac — add aarch64 target +rustup target add aarch64-unknown-linux-gnu + +# Install cross-compiler (macOS; required for linking) +brew tap messense/macos-cross-toolchains +brew install aarch64-unknown-linux-gnu + +# Build +CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc cargo build --release --target aarch64-unknown-linux-gnu + +# Copy to Uno Q +scp target/aarch64-unknown-linux-gnu/release/zeroclaw arduino@:~/ +ssh arduino@ "sudo mv ~/zeroclaw /usr/local/bin/" +``` + +If cross-compile fails, use Option A and build on the device. + +--- + +## Phase 3: Configure ZeroClaw + +### 3.1 Run Onboard (or Create Config Manually) + +```bash +ssh arduino@ + +# Quick config +zeroclaw onboard --api-key YOUR_OPENROUTER_KEY --provider openrouter + +# Or create config manually +mkdir -p ~/.zeroclaw/workspace +nano ~/.zeroclaw/config.toml +``` + +### 3.2 Minimal config.toml + +```toml +api_key = "YOUR_OPENROUTER_API_KEY" +default_provider = "openrouter" +default_model = "anthropic/claude-sonnet-4" + +[peripherals] +enabled = false +# GPIO via Bridge requires Phase 4 + +[channels_config.telegram] +bot_token = "YOUR_TELEGRAM_BOT_TOKEN" +allowed_users = ["*"] + +[gateway] +host = "127.0.0.1" +port = 8080 +allow_public_bind = false + +[agent] +compact_context = true +``` + +--- + +## Phase 4: Run ZeroClaw Daemon + +```bash +ssh arduino@ + +# Run daemon (Telegram polling works over WiFi) +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +**At this point:** Telegram chat works. Send messages to your bot — ZeroClaw responds. No GPIO yet. + +--- + +## Phase 5: GPIO via Bridge (ZeroClaw Handles It) + +ZeroClaw includes the Bridge app and setup command. + +### 5.1 Deploy Bridge App + +**From your Mac** (with zeroclaw repo): +```bash +zeroclaw peripheral setup-uno-q --host 192.168.0.48 +``` + +**From the Uno Q** (SSH'd in): +```bash +zeroclaw peripheral setup-uno-q +``` + +This copies the Bridge app to `~/ArduinoApps/zeroclaw-uno-q-bridge` and starts it. + +### 5.2 Add to config.toml + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "arduino-uno-q" +transport = "bridge" +``` + +### 5.3 Run ZeroClaw + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +Now when you message your Telegram bot *"Turn on the LED"* or *"Set pin 13 high"*, ZeroClaw uses `gpio_write` via the Bridge. + +--- + +## Summary: Commands Start to End + +| Step | Command | +|------|---------| +| 1 | Configure Uno Q in App Lab (WiFi, SSH) | +| 2 | `ssh arduino@` | +| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` | +| 4 | `sudo apt-get install -y pkg-config libssl-dev` | +| 5 | `git clone https://github.com/theonlyhennygod/zeroclaw.git && cd zeroclaw` | +| 6 | `cargo build --release --no-default-features` | +| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` | +| 8 | Edit `~/.zeroclaw/config.toml` (add Telegram bot_token) | +| 9 | `zeroclaw daemon --host 127.0.0.1 --port 8080` | +| 10 | Message your Telegram bot — it responds | + +--- + +## Troubleshooting + +- **"command not found: zeroclaw"** — Use full path: `/usr/local/bin/zeroclaw` or ensure `~/.cargo/bin` is in PATH. +- **Telegram not responding** — Check bot_token, allowed_users, and that the Uno Q has internet (WiFi). +- **Out of memory** — Use `--no-default-features` to reduce binary size; consider `compact_context = true`. +- **GPIO commands ignored** — Ensure Bridge app is running (`zeroclaw peripheral setup-uno-q` deploys and starts it). Config must have `board = "arduino-uno-q"` and `transport = "bridge"`. +- **LLM provider (GLM/Zhipu)** — Use `default_provider = "glm"` or `"zhipu"` with `GLM_API_KEY` in env or config. ZeroClaw uses the correct v4 endpoint. diff --git a/docs/datasheets/arduino-uno.md b/docs/datasheets/arduino-uno.md new file mode 100644 index 0000000..be4d4fc --- /dev/null +++ b/docs/datasheets/arduino-uno.md @@ -0,0 +1,37 @@ +# Arduino Uno + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| builtin_led | 13 | +| user_led | 13 | + +## Overview + +Arduino Uno is a microcontroller board based on the ATmega328P. It has 14 digital I/O pins (0–13) and 6 analog inputs (A0–A5). + +## Digital Pins + +- **Pins 0–13:** Digital I/O. Can be INPUT or OUTPUT. +- **Pin 13:** Built-in LED (onboard). Connect LED to GND or use for output. +- **Pins 0–1:** Also used for Serial (RX/TX). Avoid if using Serial. + +## GPIO + +- `digitalWrite(pin, HIGH)` or `digitalWrite(pin, LOW)` for output. +- `digitalRead(pin)` for input (returns 0 or 1). +- Pin numbers in ZeroClaw protocol: 0–13. + +## Serial + +- UART on pins 0 (RX) and 1 (TX). +- USB via ATmega16U2 or CH340 (clones). +- Baud rate: 115200 for ZeroClaw firmware. + +## ZeroClaw Tools + +- `gpio_read`: Read pin value (0 or 1). +- `gpio_write`: Set pin high (1) or low (0). +- `arduino_upload`: Agent generates full Arduino sketch code; ZeroClaw compiles and uploads it via arduino-cli. Use for "make a heart", custom patterns — agent writes the code, no manual editing. Pin 13 = built-in LED. diff --git a/docs/datasheets/esp32.md b/docs/datasheets/esp32.md new file mode 100644 index 0000000..8cb453d --- /dev/null +++ b/docs/datasheets/esp32.md @@ -0,0 +1,22 @@ +# ESP32 GPIO Reference + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| builtin_led | 2 | +| red_led | 2 | + +## Common pins (ESP32 / ESP32-C3) + +- **GPIO 2**: Built-in LED on many dev boards (output) +- **GPIO 13**: General-purpose output +- **GPIO 21/20**: Often used for UART0 TX/RX (avoid if using serial) + +## Protocol + +ZeroClaw host sends JSON over serial (115200 baud): +- `gpio_read`: `{"id":"1","cmd":"gpio_read","args":{"pin":13}}` +- `gpio_write`: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}` + +Response: `{"id":"1","ok":true,"result":"0"}` or `{"id":"1","ok":true,"result":"done"}` diff --git a/docs/datasheets/nucleo-f401re.md b/docs/datasheets/nucleo-f401re.md new file mode 100644 index 0000000..22b1e93 --- /dev/null +++ b/docs/datasheets/nucleo-f401re.md @@ -0,0 +1,16 @@ +# Nucleo-F401RE GPIO + +## Pin Aliases + +| alias | pin | +|-------------|-----| +| red_led | 13 | +| user_led | 13 | +| ld2 | 13 | +| builtin_led | 13 | + +## GPIO + +Pin 13: User LED (LD2) +- Output, active high +- PA5 on STM32F401 diff --git a/docs/hardware-peripherals-design.md b/docs/hardware-peripherals-design.md new file mode 100644 index 0000000..87f61bf --- /dev/null +++ b/docs/hardware-peripherals-design.md @@ -0,0 +1,324 @@ +# Hardware Peripherals Design — ZeroClaw + +ZeroClaw enables microcontrollers (MCUs) and Single Board Computers (SBCs) to **dynamically interpret natural language commands**, generate hardware-specific code, and execute peripheral interactions in real-time. + +## 1. Vision + +**Goal:** ZeroClaw acts as a hardware-aware AI agent that: +- Receives natural language triggers (e.g. "Move X arm", "Turn on LED") via channels (WhatsApp, Telegram) +- Fetches accurate hardware documentation (datasheets, register maps) +- Synthesizes Rust code/logic using an LLM (Gemini, local open-source models) +- Executes the logic to manipulate peripherals (GPIO, I2C, SPI) +- Persists optimized code for future reuse + +**Mental model:** ZeroClaw = brain that understands hardware. Peripherals = arms and legs it controls. + +## 2. Two Modes of Operation + +### Mode 1: Edge-Native (Standalone) + +**Target:** Wi-Fi-enabled boards (ESP32, Raspberry Pi). + +ZeroClaw runs **directly on the device**. The board spins up a gRPC/nanoRPC server and communicates with peripherals locally. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ZeroClaw on ESP32 / Raspberry Pi (Edge-Native) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────────┐ │ +│ │ Channels │───►│ Agent Loop │───►│ RAG: datasheets, register maps │ │ +│ │ WhatsApp │ │ (LLM calls) │ │ → LLM context │ │ +│ │ Telegram │ └──────┬───────┘ └─────────────────────────────────┘ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ Code synthesis → Wasm / dynamic exec → GPIO / I2C / SPI → persist ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +│ │ +│ gRPC/nanoRPC server ◄──► Peripherals (GPIO, I2C, SPI, sensors, actuators) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +**Workflow:** +1. User sends WhatsApp: *"Turn on LED on pin 13"* +2. ZeroClaw fetches board-specific docs (e.g. ESP32 GPIO mapping) +3. LLM synthesizes Rust code +4. Code runs in a sandbox (Wasm or dynamic linking) +5. GPIO is toggled; result returned to user +6. Optimized code is persisted for future "Turn on LED" requests + +**All happens on-device.** No host required. + +### Mode 2: Host-Mediated (Development / Debugging) + +**Target:** Hardware connected via USB / J-Link / Aardvark to a host (macOS, Linux). + +ZeroClaw runs on the **host** and maintains a hardware-aware link to the target. Used for development, introspection, and flashing. + +``` +┌─────────────────────┐ ┌──────────────────────────────────┐ +│ ZeroClaw on Mac │ USB / J-Link / │ STM32 Nucleo-F401RE │ +│ │ Aardvark │ (or other MCU) │ +│ - Channels │ ◄────────────────► │ - Memory map │ +│ - LLM │ │ - Peripherals (GPIO, ADC, I2C) │ +│ - Hardware probe │ VID/PID │ - Flash / RAM │ +│ - Flash / debug │ discovery │ │ +└─────────────────────┘ └──────────────────────────────────┘ +``` + +**Workflow:** +1. User sends Telegram: *"What are the readable memory addresses on this USB device?"* +2. ZeroClaw identifies connected hardware (VID/PID, architecture) +3. Performs memory mapping; suggests available address spaces +4. Returns result to user + +**Or:** +1. User: *"Flash this firmware to the Nucleo"* +2. ZeroClaw writes/flashes via OpenOCD or probe-rs +3. Confirms success + +**Or:** +1. ZeroClaw auto-discovers: *"STM32 Nucleo on /dev/ttyACM0, ARM Cortex-M4"* +2. Suggests: *"I can read/write GPIO, ADC, flash. What would you like to do?"* + +--- + +### Mode Comparison + +| Aspect | Edge-Native | Host-Mediated | +|------------------|--------------------------------|----------------------------------| +| ZeroClaw runs on | Device (ESP32, RPi) | Host (Mac, Linux) | +| Hardware link | Local (GPIO, I2C, SPI) | USB, J-Link, Aardvark | +| LLM | On-device or cloud (Gemini) | Host (cloud or local) | +| Use case | Production, standalone | Dev, debug, introspection | +| Channels | WhatsApp, etc. (via WiFi) | Telegram, CLI, etc. | + +## 3. Legacy / Simpler Modes (Pre-LLM-on-Edge) + +For boards without WiFi or before full Edge-Native is ready: + +### Mode A: Host + Remote Peripheral (STM32 via serial) + +Host runs ZeroClaw; peripheral runs minimal firmware. Simple JSON over serial. + +### Mode B: RPi as Host (Native GPIO) + +ZeroClaw on Pi; GPIO via rppal or sysfs. No separate firmware. + +## 4. Technical Requirements + +| Requirement | Description | +|-------------|-------------| +| **Language** | Pure Rust. `no_std` where applicable for embedded targets (STM32, ESP32). | +| **Communication** | Lightweight gRPC or nanoRPC stack for low-latency command processing. | +| **Dynamic execution** | Safely run LLM-generated logic on-the-fly: Wasm runtime for isolation, or dynamic linking where supported. | +| **Documentation retrieval** | RAG (Retrieval-Augmented Generation) pipeline to feed datasheet snippets, register maps, and pinouts into LLM context. | +| **Hardware discovery** | VID/PID-based identification for USB devices; architecture detection (ARM Cortex-M, RISC-V, etc.). | + +### RAG Pipeline (Datasheet Retrieval) + +- **Index:** Datasheets, reference manuals, register maps (PDF → chunks, embeddings). +- **Retrieve:** On user query ("turn on LED"), fetch relevant snippets (e.g. GPIO section for target board). +- **Inject:** Add to LLM system prompt or context. +- **Result:** LLM generates accurate, board-specific code. + +### Dynamic Execution Options + +| Option | Pros | Cons | +|-------|------|------| +| **Wasm** | Sandboxed, portable, no FFI | Overhead; limited HW access from Wasm | +| **Dynamic linking** | Native speed, full HW access | Platform-specific; security concerns | +| **Interpreted DSL** | Safe, auditable | Slower; limited expressiveness | +| **Pre-compiled templates** | Fast, secure | Less flexible; requires template library | + +**Recommendation:** Start with pre-compiled templates + parameterization; evolve to Wasm for user-defined logic once stable. + +## 5. CLI and Config + +### CLI Flags + +```bash +# Edge-Native: run on device (ESP32, RPi) +zeroclaw agent --mode edge + +# Host-Mediated: connect to USB/J-Link target +zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0 +zeroclaw agent --probe jlink + +# Hardware introspection +zeroclaw hardware discover +zeroclaw hardware introspect /dev/ttyACM0 +``` + +### Config (config.toml) + +```toml +[peripherals] +enabled = true +mode = "host" # "edge" | "host" +datasheet_dir = "docs/datasheets" # RAG: board-specific docs for LLM context + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[[peripherals.boards]] +board = "rpi-gpio" +transport = "native" + +[[peripherals.boards]] +board = "esp32" +transport = "wifi" +# Edge-Native: ZeroClaw runs on ESP32 +``` + +## 6. Architecture: Peripheral as Extension Point + +### New Trait: `Peripheral` + +```rust +/// A hardware peripheral that exposes capabilities as tools. +#[async_trait] +pub trait Peripheral: Send + Sync { + fn name(&self) -> &str; + fn board_type(&self) -> &str; // e.g. "nucleo-f401re", "rpi-gpio" + async fn connect(&mut self) -> anyhow::Result<()>; + async fn disconnect(&mut self) -> anyhow::Result<()>; + async fn health_check(&self) -> bool; + /// Tools this peripheral provides (gpio_read, gpio_write, sensor_read, etc.) + fn tools(&self) -> Vec>; +} +``` + +### Flow + +1. **Startup:** ZeroClaw loads config, sees `peripherals.boards`. +2. **Connect:** For each board, create a `Peripheral` impl, call `connect()`. +3. **Tools:** Collect tools from all connected peripherals; merge with default tools. +4. **Agent loop:** Agent can call `gpio_write`, `sensor_read`, etc. — these delegate to the peripheral. +5. **Shutdown:** Call `disconnect()` on each peripheral. + +### Board Support + +| Board | Transport | Firmware / Driver | Tools | +|--------------------|-----------|------------------------|--------------------------| +| nucleo-f401re | serial | Zephyr / Embassy | gpio_read, gpio_write, adc_read | +| rpi-gpio | native | rppal or sysfs | gpio_read, gpio_write | +| esp32 | serial/ws | ESP-IDF / Embassy | gpio, wifi, mqtt | + +## 7. Communication Protocols + +### gRPC / nanoRPC (Edge-Native, Host-Mediated) + +For low-latency, typed RPC between ZeroClaw and peripherals: + +- **nanoRPC** or **tonic** (gRPC): Protobuf-defined services. +- Methods: `GpioWrite`, `GpioRead`, `I2cTransfer`, `SpiTransfer`, `MemoryRead`, `FlashWrite`, etc. +- Enables streaming, bidirectional calls, and code generation from `.proto` files. + +### Serial Fallback (Host-Mediated, legacy) + +Simple JSON over serial for boards without gRPC support: + +**Request (host → peripheral):** +```json +{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} +``` + +**Response (peripheral → host):** +```json +{"id":"1","ok":true,"result":"done"} +``` + +## 8. Firmware (Separate Repo or Crate) + +- **zeroclaw-firmware** or **zeroclaw-peripheral** — a separate crate/workspace. +- Targets: `thumbv7em-none-eabihf` (STM32), `armv7-unknown-linux-gnueabihf` (RPi), etc. +- Uses `embassy` or Zephyr for STM32. +- Implements the protocol above. +- User flashes this to the board; ZeroClaw connects and discovers capabilities. + +## 9. Implementation Phases + +### Phase 1: Skeleton ✅ (Done) + +- [x] Add `Peripheral` trait, config schema, CLI (`zeroclaw peripheral list/add`) +- [x] Add `--peripheral` flag to agent +- [x] Document in AGENTS.md + +### Phase 2: Host-Mediated — Hardware Discovery ✅ (Done) + +- [x] `zeroclaw hardware discover`: enumerate USB devices (VID/PID) +- [x] Board registry: map VID/PID → architecture, name (e.g. Nucleo-F401RE) +- [x] `zeroclaw hardware introspect `: memory map, peripheral list + +### Phase 3: Host-Mediated — Serial / J-Link + +- [x] `SerialPeripheral` for STM32 over USB CDC +- [ ] probe-rs or OpenOCD integration for flash/debug +- [x] Tools: `gpio_read`, `gpio_write` (memory_read, flash_write in future) + +### Phase 4: RAG Pipeline ✅ (Done) + +- [x] Datasheet index (markdown/text → chunks) +- [x] Retrieve-and-inject into LLM context on hardware-related queries +- [x] Board-specific prompt augmentation + +**Usage:** Add `datasheet_dir = "docs/datasheets"` to `[peripherals]` in config.toml. Place `.md` or `.txt` files named by board (e.g. `nucleo-f401re.md`, `rpi-gpio.md`). Files in `_generic/` or named `generic.md` apply to all boards. Chunks are retrieved by keyword match and injected into the user message context. + +### Phase 5: Edge-Native — RPi ✅ (Done) + +- [x] ZeroClaw on Raspberry Pi (native GPIO via rppal) +- [ ] gRPC/nanoRPC server for local peripheral access +- [ ] Code persistence (store synthesized snippets) + +### Phase 6: Edge-Native — ESP32 + +- [x] Host-mediated ESP32 (serial transport) — same JSON protocol as STM32 +- [x] `zeroclaw-esp32` firmware crate (`firmware/zeroclaw-esp32`) — GPIO over UART +- [x] ESP32 in hardware registry (CH340 VID/PID) +- [ ] ZeroClaw *on* ESP32 (WiFi + LLM, edge-native) — future +- [ ] Wasm or template-based execution for LLM-generated logic + +**Usage:** Flash `firmware/zeroclaw-esp32` to ESP32, add `board = "esp32"`, `transport = "serial"`, `path = "/dev/ttyUSB0"` to config. + +### Phase 7: Dynamic Execution (LLM-Generated Code) + +- [ ] Template library: parameterized GPIO/I2C/SPI snippets +- [ ] Optional: Wasm runtime for user-defined logic (sandboxed) +- [ ] Persist and reuse optimized code paths + +## 10. Security Considerations + +- **Serial path:** Validate `path` is in allowlist (e.g. `/dev/ttyACM*`, `/dev/ttyUSB*`); never arbitrary paths. +- **GPIO:** Restrict which pins are exposed; avoid power/reset pins. +- **No secrets on peripheral:** Firmware should not store API keys; host handles auth. + +## 11. Non-Goals (For Now) + +- Running full ZeroClaw *on* bare STM32 (no WiFi, limited RAM) — use Host-Mediated instead +- Real-time guarantees — peripherals are best-effort +- Arbitrary native code execution from LLM — prefer Wasm or templates + +## 12. Related Documents + +- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — How to add boards and datasheets +- [network-deployment.md](./network-deployment.md) — RPi and network deployment + +## 13. References + +- [Zephyr RTOS Rust support](https://docs.zephyrproject.org/latest/develop/languages/rust/index.html) +- [Embassy](https://embassy.dev/) — async embedded framework +- [rppal](https://github.com/golemparts/rppal) — Raspberry Pi GPIO in Rust +- [STM32 Nucleo-F401RE](https://www.st.com/en/evaluation-tools/nucleo-f401re.html) +- [tonic](https://github.com/hyperium/tonic) — gRPC for Rust +- [probe-rs](https://probe.rs/) — ARM debug probe, flash, memory access +- [nusb](https://github.com/nic-hartley/nusb) — USB device enumeration (VID/PID) + +## 14. Raw Prompt Summary + +> *"Boards like ESP, Raspberry Pi, or boards with WiFi can connect to an LLM (Gemini or open-source). ZeroClaw runs on the device, creates its own gRPC, spins it up, and communicates with peripherals. User asks via WhatsApp: 'move X arm' or 'turn on LED'. ZeroClaw gets accurate documentation, writes code, executes it, stores it optimally, runs it, and turns on the LED — all on the development board.* +> +> *For STM Nucleo connected via USB/J-Link/Aardvark to my Mac: ZeroClaw from my Mac accesses the hardware, installs or writes what it wants on the device, and returns the result. Example: 'Hey ZeroClaw, what are the available/readable addresses on this USB device?' It can figure out what's connected where and suggest."* diff --git a/docs/network-deployment.md b/docs/network-deployment.md new file mode 100644 index 0000000..5fdc7fa --- /dev/null +++ b/docs/network-deployment.md @@ -0,0 +1,182 @@ +# Network Deployment — ZeroClaw on Raspberry Pi and Local Network + +This document covers deploying ZeroClaw on a Raspberry Pi or other host on your local network, with Telegram and optional webhook channels. + +--- + +## 1. Overview + +| Mode | Inbound port needed? | Use case | +|------|----------------------|----------| +| **Telegram polling** | No | ZeroClaw polls Telegram API; works from anywhere | +| **Discord/Slack** | No | Same — outbound only | +| **Gateway webhook** | Yes | POST /webhook, WhatsApp, etc. need a public URL | +| **Gateway pairing** | Yes | If you pair clients via the gateway | + +**Key:** Telegram, Discord, and Slack use **long-polling** — ZeroClaw makes outbound requests. No port forwarding or public IP required. + +--- + +## 2. ZeroClaw on Raspberry Pi + +### 2.1 Prerequisites + +- Raspberry Pi (3/4/5) with Raspberry Pi OS +- USB peripherals (Arduino, Nucleo) if using serial transport +- Optional: `rppal` for native GPIO (`peripheral-rpi` feature) + +### 2.2 Install + +```bash +# Build for RPi (or cross-compile from host) +cargo build --release --features hardware + +# Or install via your preferred method +``` + +### 2.3 Config + +Edit `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "rpi-gpio" +transport = "native" + +# Or Arduino over USB +[[peripherals.boards]] +board = "arduino-uno" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 + +[channels_config.telegram] +bot_token = "YOUR_BOT_TOKEN" +allowed_users = ["*"] + +[gateway] +host = "127.0.0.1" +port = 8080 +allow_public_bind = false +``` + +### 2.4 Run Daemon (Local Only) + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +- Gateway binds to `127.0.0.1` — not reachable from other machines +- Telegram channel works: ZeroClaw polls Telegram API (outbound) +- No firewall or port forwarding needed + +--- + +## 3. Binding to 0.0.0.0 (Local Network) + +To allow other devices on your LAN to hit the gateway (e.g. for pairing or webhooks): + +### 3.1 Option A: Explicit Opt-In + +```toml +[gateway] +host = "0.0.0.0" +port = 8080 +allow_public_bind = true +``` + +```bash +zeroclaw daemon --host 0.0.0.0 --port 8080 +``` + +**Security:** `allow_public_bind = true` exposes the gateway to your local network. Only use on trusted LANs. + +### 3.2 Option B: Tunnel (Recommended for Webhooks) + +If you need a **public URL** (e.g. WhatsApp webhook, external clients): + +1. Run gateway on localhost: + ```bash + zeroclaw daemon --host 127.0.0.1 --port 8080 + ``` + +2. Start a tunnel: + ```toml + [tunnel] + provider = "tailscale" # or "ngrok", "cloudflare" + ``` + Or use `zeroclaw tunnel` (see tunnel docs). + +3. ZeroClaw will refuse `0.0.0.0` unless `allow_public_bind = true` or a tunnel is active. + +--- + +## 4. Telegram Polling (No Inbound Port) + +Telegram uses **long-polling** by default: + +- ZeroClaw calls `https://api.telegram.org/bot{token}/getUpdates` +- No inbound port or public IP needed +- Works behind NAT, on RPi, in a home lab + +**Config:** + +```toml +[channels_config.telegram] +bot_token = "YOUR_BOT_TOKEN" +allowed_users = ["*"] # or specific @usernames / user IDs +``` + +Run `zeroclaw daemon` — Telegram channel starts automatically. + +--- + +## 5. Webhook Channels (WhatsApp, Custom) + +Webhook-based channels need a **public URL** so Meta (WhatsApp) or your client can POST events. + +### 5.1 Tailscale Funnel + +```toml +[tunnel] +provider = "tailscale" +``` + +Tailscale Funnel exposes your gateway via a `*.ts.net` URL. No port forwarding. + +### 5.2 ngrok + +```toml +[tunnel] +provider = "ngrok" +``` + +Or run ngrok manually: +```bash +ngrok http 8080 +# Use the HTTPS URL for your webhook +``` + +### 5.3 Cloudflare Tunnel + +Configure Cloudflare Tunnel to forward to `127.0.0.1:8080`, then set your webhook URL to the tunnel's public hostname. + +--- + +## 6. Checklist: RPi Deployment + +- [ ] Build with `--features hardware` (and `peripheral-rpi` if using native GPIO) +- [ ] Configure `[peripherals]` and `[channels_config.telegram]` +- [ ] Run `zeroclaw daemon --host 127.0.0.1 --port 8080` (Telegram works without 0.0.0.0) +- [ ] For LAN access: `--host 0.0.0.0` + `allow_public_bind = true` in config +- [ ] For webhooks: use Tailscale, ngrok, or Cloudflare tunnel + +--- + +## 7. References + +- [hardware-peripherals-design.md](./hardware-peripherals-design.md) — Peripherals design +- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Hardware setup and adding boards diff --git a/docs/nucleo-setup.md b/docs/nucleo-setup.md new file mode 100644 index 0000000..76e942e --- /dev/null +++ b/docs/nucleo-setup.md @@ -0,0 +1,147 @@ +# ZeroClaw on Nucleo-F401RE — Step-by-Step Guide + +Run ZeroClaw on your Mac or Linux host. Connect a Nucleo-F401RE via USB. Control GPIO (LED, pins) via Telegram or CLI. + +--- + +## Get Board Info via Telegram (No Firmware Needed) + +ZeroClaw can read chip info from the Nucleo over USB **without flashing any firmware**. Message your Telegram bot: + +- *"What board info do I have?"* +- *"Board info"* +- *"What hardware is connected?"* +- *"Chip info"* + +The agent uses the `hardware_board_info` tool to return chip name, architecture, and memory map. With the `probe` feature, it reads live data via USB/SWD; otherwise it returns static datasheet info. + +**Config:** Add Nucleo to `config.toml` first (so the agent knows which board to query): + +```toml +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/ttyACM0" +baud = 115200 +``` + +**CLI alternative:** + +```bash +cargo build --features hardware,probe +zeroclaw hardware info +zeroclaw hardware discover +``` + +--- + +## What's Included (No Code Changes Needed) + +ZeroClaw includes everything for Nucleo-F401RE: + +| Component | Location | Purpose | +|-----------|----------|---------| +| Firmware | `firmware/zeroclaw-nucleo/` | Embassy Rust — USART2 (115200), gpio_read, gpio_write | +| Serial peripheral | `src/peripherals/serial.rs` | JSON-over-serial protocol (same as Arduino/ESP32) | +| Flash command | `zeroclaw peripheral flash-nucleo` | Builds firmware, flashes via probe-rs | + +Protocol: newline-delimited JSON. Request: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`. Response: `{"id":"1","ok":true,"result":"done"}`. + +--- + +## Prerequisites + +- Nucleo-F401RE board +- USB cable (USB-A to Mini-USB; Nucleo has built-in ST-Link) +- For flashing: `cargo install probe-rs-tools --locked` (or use the [install script](https://probe.rs/docs/getting-started/installation/)) + +--- + +## Phase 1: Flash Firmware + +### 1.1 Connect Nucleo + +1. Connect Nucleo to your Mac/Linux via USB. +2. The board appears as a USB device (ST-Link). No separate driver needed on modern systems. + +### 1.2 Flash via ZeroClaw + +From the zeroclaw repo root: + +```bash +zeroclaw peripheral flash-nucleo +``` + +This builds `firmware/zeroclaw-nucleo` and runs `probe-rs run --chip STM32F401RETx`. The firmware runs immediately after flashing. + +### 1.3 Manual Flash (Alternative) + +```bash +cd firmware/zeroclaw-nucleo +cargo build --release --target thumbv7em-none-eabihf +probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo +``` + +--- + +## Phase 2: Find Serial Port + +- **macOS:** `/dev/cu.usbmodem*` or `/dev/tty.usbmodem*` (e.g. `/dev/cu.usbmodem101`) +- **Linux:** `/dev/ttyACM0` (or check `dmesg` after plugging in) + +USART2 (PA2/PA3) is bridged to the ST-Link's virtual COM port, so the host sees one serial device. + +--- + +## Phase 3: Configure ZeroClaw + +Add to `~/.zeroclaw/config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "nucleo-f401re" +transport = "serial" +path = "/dev/cu.usbmodem101" # adjust to your port +baud = 115200 +``` + +--- + +## Phase 4: Run and Test + +```bash +zeroclaw daemon --host 127.0.0.1 --port 8080 +``` + +Or use the agent directly: + +```bash +zeroclaw agent --message "Turn on the LED on pin 13" +``` + +Pin 13 = PA5 = User LED (LD2) on Nucleo-F401RE. + +--- + +## Summary: Commands + +| Step | Command | +|------|---------| +| 1 | Connect Nucleo via USB | +| 2 | `cargo install probe-rs --locked` | +| 3 | `zeroclaw peripheral flash-nucleo` | +| 4 | Add Nucleo to config.toml (path = your serial port) | +| 5 | `zeroclaw daemon` or `zeroclaw agent -m "Turn on LED"` | + +--- + +## Troubleshooting + +- **flash-nucleo unrecognized** — Build from repo: `cargo run --features hardware -- peripheral flash-nucleo`. The subcommand is only in the repo build, not in crates.io installs. +- **probe-rs not found** — `cargo install probe-rs-tools --locked` (the `probe-rs` crate is a library; the CLI is in `probe-rs-tools`) +- **No probe detected** — Ensure Nucleo is connected. Try another USB cable/port. +- **Serial port not found** — On Linux, add user to `dialout`: `sudo usermod -a -G dialout $USER`, then log out/in. +- **GPIO commands ignored** — Check `path` in config matches your serial port. Run `zeroclaw peripheral list` to verify. diff --git a/firmware/zeroclaw-arduino/zeroclaw-arduino.ino b/firmware/zeroclaw-arduino/zeroclaw-arduino.ino new file mode 100644 index 0000000..5e9c4ee --- /dev/null +++ b/firmware/zeroclaw-arduino/zeroclaw-arduino.ino @@ -0,0 +1,143 @@ +/* + * ZeroClaw Arduino Uno Firmware + * + * Listens for JSON commands on Serial (115200 baud), executes gpio_read/gpio_write, + * responds with JSON. Compatible with ZeroClaw SerialPeripheral protocol. + * + * Protocol (newline-delimited JSON): + * Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} + * Response: {"id":"1","ok":true,"result":"done"} + * + * Arduino Uno: Pin 13 has built-in LED. Digital pins 0-13 supported. + * + * 1. Open in Arduino IDE + * 2. Select Board: Arduino Uno + * 3. Select correct Port (Tools -> Port) + * 4. Upload + */ + +#define BAUDRATE 115200 +#define MAX_LINE 256 + +char lineBuf[MAX_LINE]; +int lineLen = 0; + +// Parse integer from JSON: "pin":13 or "value":1 +int parseArg(const char* key, const char* json) { + char search[32]; + snprintf(search, sizeof(search), "\"%s\":", key); + const char* p = strstr(json, search); + if (!p) return -1; + p += strlen(search); + return atoi(p); +} + +// Extract "id" for response +void copyId(char* out, int outLen, const char* json) { + const char* p = strstr(json, "\"id\":\""); + if (!p) { + out[0] = '0'; + out[1] = '\0'; + return; + } + p += 6; + int i = 0; + while (i < outLen - 1 && *p && *p != '"') { + out[i++] = *p++; + } + out[i] = '\0'; +} + +// Check if cmd is present +bool hasCmd(const char* json, const char* cmd) { + char search[64]; + snprintf(search, sizeof(search), "\"cmd\":\"%s\"", cmd); + return strstr(json, search) != NULL; +} + +void handleLine(const char* line) { + char idBuf[16]; + copyId(idBuf, sizeof(idBuf), line); + + if (hasCmd(line, "ping")) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":true,\"result\":\"pong\"}"); + return; + } + + // Phase C: Dynamic discovery — report GPIO pins and LED pin + if (hasCmd(line, "capabilities")) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":true,\"result\":\"{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}\"}"); + Serial.println(); + return; + } + + if (hasCmd(line, "gpio_read")) { + int pin = parseArg("pin", line); + if (pin < 0 || pin > 13) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin "); + Serial.print(pin); + Serial.println("\"}"); + return; + } + pinMode(pin, INPUT); + int val = digitalRead(pin); + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":true,\"result\":\""); + Serial.print(val); + Serial.println("\"}"); + return; + } + + if (hasCmd(line, "gpio_write")) { + int pin = parseArg("pin", line); + int value = parseArg("value", line); + if (pin < 0 || pin > 13) { + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin "); + Serial.print(pin); + Serial.println("\"}"); + return; + } + pinMode(pin, OUTPUT); + digitalWrite(pin, value ? HIGH : LOW); + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":true,\"result\":\"done\"}"); + return; + } + + // Unknown command + Serial.print("{\"id\":\""); + Serial.print(idBuf); + Serial.println("\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}"); +} + +void setup() { + Serial.begin(BAUDRATE); + lineLen = 0; +} + +void loop() { + while (Serial.available()) { + char c = Serial.read(); + if (c == '\n' || c == '\r') { + if (lineLen > 0) { + lineBuf[lineLen] = '\0'; + handleLine(lineBuf); + lineLen = 0; + } + } else if (lineLen < MAX_LINE - 1) { + lineBuf[lineLen++] = c; + } else { + lineLen = 0; // Overflow, discard + } + } +} diff --git a/firmware/zeroclaw-esp32/.cargo/config.toml b/firmware/zeroclaw-esp32/.cargo/config.toml new file mode 100644 index 0000000..8746ad1 --- /dev/null +++ b/firmware/zeroclaw-esp32/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "riscv32imc-esp-espidf" + +[target.riscv32imc-esp-espidf] +runner = "espflash flash --monitor" diff --git a/firmware/zeroclaw-esp32/Cargo.lock b/firmware/zeroclaw-esp32/Cargo.lock new file mode 100644 index 0000000..6f8ad22 --- /dev/null +++ b/firmware/zeroclaw-esp32/Cargo.lock @@ -0,0 +1,1840 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "build-time" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1219c19fc29b7bfd74b7968b420aff5bc951cf517800176e795d6b2300dd382" +dependencies = [ + "chrono", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "cvt" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ae9bf77fbf2d39ef573205d554d87e86c12f1994e9ea335b0651b9b278bcf1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-sync" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd938f25c0798db4280fcd8026bf4c2f48789aebf8f77b6e5cf8a7693ba114ec" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-util", + "heapless", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-svc" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6f87e7654f28018340aa55f933803017aefabaa5417820a3b2f808033c7bbc" +dependencies = [ + "defmt 0.3.100", + "embedded-io", + "embedded-io-async", + "enumset", + "heapless", + "no-std-net", + "num_enum", + "serde", + "strum 0.25.0", +] + +[[package]] +name = "embuild" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4caa4f198bb9152a55c0103efb83fa4edfcbb8625f4c9e94ae8ec8e23827c563" +dependencies = [ + "anyhow", + "bindgen", + "bitflags 1.3.2", + "cmake", + "filetime", + "globwalk", + "home", + "log", + "remove_dir_all", + "serde", + "serde_json", + "shlex", + "strum 0.24.1", + "tempfile", + "thiserror 1.0.69", + "which", + "xmas-elf", +] + +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "esp-idf-hal" +version = "0.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7adf3fb19a9ca016cbea1ab8a7b852ac69df8fcde4923c23d3b155efbc42a74" +dependencies = [ + "atomic-waker", + "embassy-sync", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io", + "embedded-io-async", + "embuild", + "enumset", + "esp-idf-sys", + "heapless", + "log", + "nb 1.1.0", + "num_enum", +] + +[[package]] +name = "esp-idf-svc" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2180642ca122a7fec1ec417a9b1a77aa66aaa067fdf1daae683dd8caba84f26b" +dependencies = [ + "embassy-futures", + "embedded-hal-async", + "embedded-svc", + "embuild", + "enumset", + "esp-idf-hal", + "heapless", + "log", + "num_enum", + "uncased", +] + +[[package]] +name = "esp-idf-sys" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e148f97c04ed3e9181a08bcdc9560a515aad939b0ba7f50a0022e294665e0af" +dependencies = [ + "anyhow", + "bindgen", + "build-time", + "cargo_metadata", + "const_format", + "embuild", + "envy", + "libc", + "regex", + "serde", + "strum 0.24.1", + "which", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs_at" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14af6c9694ea25db25baa2a1788703b9e7c6648dcaeeebeb98f7561b5384c036" +dependencies = [ + "aligned", + "cfg-if", + "cvt", + "libc", + "nix", + "windows-sys 0.52.0", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.11.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no-std-net" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bcece43b12349917e096cddfa66107277f123e6c96a5aea78711dc601a47152" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "remove_dir_all" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a694f9e0eb3104451127f6cc1e5de55f59d3b1fc8c5ddfaeb6f1e716479ceb4a" +dependencies = [ + "cfg-if", + "cvt", + "fs_at", + "libc", + "normpath", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros 0.25.3", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.116", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +dependencies = [ + "winnow", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.116", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.116", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.116", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xmas-elf" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c49817e78342f7f30a181573d82ff55b88a35f86ccaf07fc64b3008f56d1c6" +dependencies = [ + "zero", +] + +[[package]] +name = "zero" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe21bcc34ca7fe6dd56cc2cb1261ea59d6b93620215aefb5ea6032265527784" + +[[package]] +name = "zeroclaw-esp32" +version = "0.1.0" +dependencies = [ + "anyhow", + "embuild", + "esp-idf-svc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/firmware/zeroclaw-esp32/Cargo.toml b/firmware/zeroclaw-esp32/Cargo.toml new file mode 100644 index 0000000..2f7a001 --- /dev/null +++ b/firmware/zeroclaw-esp32/Cargo.toml @@ -0,0 +1,35 @@ +# ZeroClaw ESP32 firmware — JSON-over-serial peripheral for host-mediated control. +# +# Flash to ESP32 and connect via serial. The host ZeroClaw sends gpio_read/gpio_write +# commands; this firmware executes them and responds. +# +# Prerequisites: espup (cargo install espup; espup install; source ~/export-esp.sh) +# Build: cargo build --release +# Flash: cargo espflash flash --monitor + +[package] +name = "zeroclaw-esp32" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial" + +[dependencies] +esp-idf-svc = "0.48" +log = "0.4" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[build-dependencies] +embuild = { version = "0.31", features = ["elf"] } + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" + +[profile.dev] +opt-level = "s" diff --git a/firmware/zeroclaw-esp32/README.md b/firmware/zeroclaw-esp32/README.md new file mode 100644 index 0000000..804aaca --- /dev/null +++ b/firmware/zeroclaw-esp32/README.md @@ -0,0 +1,52 @@ +# ZeroClaw ESP32 Firmware + +Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial. + +## Protocol + +- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n` +- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n` + +Commands: `gpio_read`, `gpio_write`. + +## Prerequisites + +1. **ESP toolchain** (espup): + ```sh + cargo install espup espflash + espup install + source ~/export-esp.sh # or ~/export-esp.fish for Fish + ``` + +2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32). + +## Build & Flash + +```sh +cd firmware/zeroclaw-esp32 +cargo build --release +espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor +``` + +## Host Config + +Add to `config.toml`: + +```toml +[peripherals] +enabled = true + +[[peripherals.boards]] +board = "esp32" +transport = "serial" +path = "/dev/ttyUSB0" # or /dev/ttyACM0, COM3, etc. +baud = 115200 +``` + +## Pin Mapping + +Default GPIO 2 and 13 are configured for output. Edit `src/main.rs` to add more pins or change for your board. ESP32-C3 has different pin layout — adjust UART pins (gpio21/gpio20) if needed. + +## Edge-Native (Future) + +Phase 6 also envisions ZeroClaw running *on* the ESP32 (WiFi + LLM). This firmware is the host-mediated serial peripheral; edge-native will be a separate crate. diff --git a/firmware/zeroclaw-esp32/build.rs b/firmware/zeroclaw-esp32/build.rs new file mode 100644 index 0000000..112ec3f --- /dev/null +++ b/firmware/zeroclaw-esp32/build.rs @@ -0,0 +1,3 @@ +fn main() { + embuild::espidf::sysenv::output(); +} diff --git a/firmware/zeroclaw-esp32/src/main.rs b/firmware/zeroclaw-esp32/src/main.rs new file mode 100644 index 0000000..b1a487c --- /dev/null +++ b/firmware/zeroclaw-esp32/src/main.rs @@ -0,0 +1,154 @@ +//! ZeroClaw ESP32 firmware — JSON-over-serial peripheral. +//! +//! Listens for newline-delimited JSON commands on UART0, executes gpio_read/gpio_write, +//! responds with JSON. Compatible with host ZeroClaw SerialPeripheral protocol. +//! +//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md + +use esp_idf_svc::hal::gpio::PinDriver; +use esp_idf_svc::hal::prelude::*; +use esp_idf_svc::hal::uart::*; +use log::info; +use serde::{Deserialize, Serialize}; + +/// Incoming command from host. +#[derive(Debug, Deserialize)] +struct Request { + id: String, + cmd: String, + args: serde_json::Value, +} + +/// Outgoing response to host. +#[derive(Debug, Serialize)] +struct Response { + id: String, + ok: bool, + result: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn main() -> anyhow::Result<()> { + esp_idf_svc::sys::link_patches(); + esp_idf_svc::log::EspLogger::initialize_default(); + + let peripherals = Peripherals::take()?; + let pins = peripherals.pins; + + // UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board + let config = UartConfig::new().baudrate(Hertz(115_200)); + let mut uart = UartDriver::new( + peripherals.uart0, + pins.gpio21, + pins.gpio20, + Option::::None, + Option::::None, + &config, + )?; + + info!("ZeroClaw ESP32 firmware ready on UART0 (115200)"); + + let mut buf = [0u8; 512]; + let mut line = Vec::new(); + + loop { + match uart.read(&mut buf, 100) { + Ok(0) => continue, + Ok(n) => { + for &b in &buf[..n] { + if b == b'\n' { + if !line.is_empty() { + if let Ok(line_str) = std::str::from_utf8(&line) { + if let Ok(resp) = handle_request(line_str, &peripherals) { + let out = serde_json::to_string(&resp).unwrap_or_default(); + let _ = uart.write(format!("{}\n", out).as_bytes()); + } + } + line.clear(); + } + } else { + line.push(b); + if line.len() > 400 { + line.clear(); + } + } + } + } + Err(_) => {} + } + } +} + +fn handle_request( + line: &str, + peripherals: &esp_idf_svc::hal::peripherals::Peripherals, +) -> anyhow::Result { + let req: Request = serde_json::from_str(line.trim())?; + let id = req.id.clone(); + + let result = match req.cmd.as_str() { + "capabilities" => { + // Phase C: report GPIO pins and LED pin (matches Arduino protocol) + let caps = serde_json::json!({ + "gpio": [0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19], + "led_pin": 2 + }); + Ok(caps.to_string()) + } + "gpio_read" => { + let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let value = gpio_read(peripherals, pin_num)?; + Ok(value.to_string()) + } + "gpio_write" => { + let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32; + let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0); + gpio_write(peripherals, pin_num, value)?; + Ok("done".into()) + } + _ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)), + }; + + match result { + Ok(r) => Ok(Response { + id, + ok: true, + result: r, + error: None, + }), + Err(e) => Ok(Response { + id, + ok: false, + result: String::new(), + error: Some(e.to_string()), + }), + } +} + +fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result { + // TODO: implement input pin read — requires storing InputPin drivers per pin + Ok(0) +} + +fn gpio_write( + peripherals: &esp_idf_svc::hal::peripherals::Peripherals, + pin: i32, + value: u64, +) -> anyhow::Result<()> { + let pins = peripherals.pins; + let level = value != 0; + + match pin { + 2 => { + let mut out = PinDriver::output(pins.gpio2)?; + out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?; + } + 13 => { + let mut out = PinDriver::output(pins.gpio13)?; + out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?; + } + _ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin), + } + Ok(()) +} diff --git a/firmware/zeroclaw-nucleo/Cargo.lock b/firmware/zeroclaw-nucleo/Cargo.lock new file mode 100644 index 0000000..41b57b5 --- /dev/null +++ b/firmware/zeroclaw-nucleo/Cargo.lock @@ -0,0 +1,849 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bare-metal" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitfield" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-device-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c051592f59fe68053524b4c4935249b806f72c1f544cfb7abe4f57c3be258e" +dependencies = [ + "aligned", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cortex-m" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" +dependencies = [ + "bare-metal", + "bitfield", + "embedded-hal 0.2.7", + "volatile-register", +] + +[[package]] +name = "cortex-m-rt" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" +dependencies = [ + "cortex-m-rt-macros", +] + +[[package]] +name = "cortex-m-rt-macros" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.116", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "defmt" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "defmt-rtt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e" +dependencies = [ + "critical-section", + "defmt 1.0.1", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "embassy-embedded-hal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8" +dependencies = [ + "defmt 1.0.1", + "embassy-futures", + "embassy-hal-internal 0.3.0", + "embassy-sync", + "embassy-time", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-storage", + "embedded-storage-async", + "nb 1.1.0", +] + +[[package]] +name = "embassy-executor" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-executor-macros", + "embassy-executor-timer-queue", +] + +[[package]] +name = "embassy-executor-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "embassy-executor-timer-queue" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" + +[[package]] +name = "embassy-futures" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" + +[[package]] +name = "embassy-hal-internal" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "embassy-hal-internal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" +dependencies = [ + "cortex-m", + "critical-section", + "defmt 1.0.1", + "num-traits", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" +dependencies = [ + "defmt 0.3.100", +] + +[[package]] +name = "embassy-stm32" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088d65743a48f2cc9b3ae274ed85d6e8b68bd3ee92eb6b87b15dca2f81f7a101" +dependencies = [ + "aligned", + "bit_field", + "bitflags 2.11.0", + "block-device-driver", + "cfg-if", + "cortex-m", + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-embedded-hal", + "embassy-futures", + "embassy-hal-internal 0.4.0", + "embassy-net-driver", + "embassy-sync", + "embassy-time", + "embassy-time-driver", + "embassy-time-queue-utils", + "embassy-usb-driver", + "embassy-usb-synopsys-otg", + "embedded-can", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-hal-nb", + "embedded-io 0.7.1", + "embedded-io-async 0.7.0", + "embedded-storage", + "embedded-storage-async", + "futures-util", + "heapless 0.9.2", + "nb 1.1.0", + "proc-macro2", + "quote", + "rand_core 0.6.4", + "rand_core 0.9.5", + "sdio-host", + "static_assertions", + "stm32-fmc", + "stm32-metapac", + "trait-set", + "vcell", + "volatile-register", +] + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "embedded-io-async 0.6.1", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-time" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa65b9284d974dad7a23bb72835c4ec85c0b540d86af7fc4098c88cff51d65" +dependencies = [ + "cfg-if", + "critical-section", + "defmt 1.0.1", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a244c7dc22c8d0289379c8d8830cae06bb93d8f990194d0de5efb3b5ae7ba6" +dependencies = [ + "document-features", +] + +[[package]] +name = "embassy-time-queue-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454" +dependencies = [ + "embassy-executor-timer-queue", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-usb-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a" +dependencies = [ + "defmt 1.0.1", + "embedded-io-async 0.6.1", +] + +[[package]] +name = "embassy-usb-synopsys-otg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288751f8eaa44a5cf2613f13cee0ca8e06e6638cb96e897e6834702c79084b23" +dependencies = [ + "critical-section", + "defmt 1.0.1", + "embassy-sync", + "embassy-usb-driver", +] + +[[package]] +name = "embedded-can" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "embedded-hal-async" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "embedded-hal-nb" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" +dependencies = [ + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" +dependencies = [ + "defmt 1.0.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io 0.6.1", +] + +[[package]] +name = "embedded-io-async" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" +dependencies = [ + "defmt 1.0.1", + "embedded-io 0.7.1", +] + +[[package]] +name = "embedded-storage" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" + +[[package]] +name = "embedded-storage-async" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" +dependencies = [ + "embedded-storage", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "nb" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "panic-probe" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" +dependencies = [ + "cortex-m", + "defmt 1.0.1", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + +[[package]] +name = "sdio-host" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b328e2cb950eeccd55b7f55c3a963691455dcd044cfb5354f0c5e68d2c2d6ee2" + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stm32-fmc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72692594faa67f052e5e06dd34460951c21e83bc55de4feb8d2666e2f15480a2" +dependencies = [ + "embedded-hal 1.0.0", +] + +[[package]] +name = "stm32-metapac" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a411079520dbccc613af73172f944b7cf97ba84e3bd7381a0352b6ec7bfef03b" +dependencies = [ + "cortex-m", + "cortex-m-rt", + "defmt 0.3.100", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "trait-set" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79e2e9c9ab44c6d7c20d5976961b47e8f49ac199154daa514b77cd1ab536625" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "volatile-register" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" +dependencies = [ + "vcell", +] + +[[package]] +name = "zeroclaw-nucleo" +version = "0.1.0" +dependencies = [ + "cortex-m-rt", + "critical-section", + "defmt 1.0.1", + "defmt-rtt", + "embassy-executor", + "embassy-stm32", + "embassy-time", + "heapless 0.9.2", + "panic-probe", +] diff --git a/firmware/zeroclaw-nucleo/Cargo.toml b/firmware/zeroclaw-nucleo/Cargo.toml new file mode 100644 index 0000000..a5d97f8 --- /dev/null +++ b/firmware/zeroclaw-nucleo/Cargo.toml @@ -0,0 +1,39 @@ +# ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral. +# +# Listens for newline-delimited JSON on USART2 (PA2/PA3, ST-Link VCP). +# Protocol: same as Arduino/ESP32 — ping, capabilities, gpio_read, gpio_write. +# +# Build: cargo build --release +# Flash: probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo +# Or: zeroclaw peripheral flash-nucleo + +[package] +name = "zeroclaw-nucleo" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ZeroClaw Nucleo-F401RE peripheral firmware — GPIO over JSON serial" + +[dependencies] +embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread", "defmt"] } +embassy-stm32 = { version = "0.5", features = ["defmt", "stm32f401re", "unstable-pac", "memory-x", "time-driver-tim4", "exti"] } +embassy-time = { version = "0.5", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] } +defmt = "1.0" +defmt-rtt = "1.0" +panic-probe = { version = "1.0", features = ["print-defmt"] } +heapless = { version = "0.9", default-features = false } +critical-section = "1.1" +cortex-m-rt = "0.7" + +[package.metadata.embassy] +build = [ + { target = "thumbv7em-none-eabihf", artifact-dir = "target" } +] + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" +debug = 1 diff --git a/firmware/zeroclaw-nucleo/src/main.rs b/firmware/zeroclaw-nucleo/src/main.rs new file mode 100644 index 0000000..909645e --- /dev/null +++ b/firmware/zeroclaw-nucleo/src/main.rs @@ -0,0 +1,187 @@ +//! ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral. +//! +//! Listens for newline-delimited JSON on USART2 (PA2=TX, PA3=RX). +//! USART2 is connected to ST-Link VCP — host sees /dev/ttyACM0 (Linux) or /dev/cu.usbmodem* (macOS). +//! +//! Protocol: same as Arduino/ESP32 — see docs/hardware-peripherals-design.md + +#![no_std] +#![no_main] + +use core::fmt::Write; +use core::str; +use defmt::info; +use embassy_executor::Spawner; +use embassy_stm32::gpio::{Level, Output, Speed}; +use embassy_stm32::usart::{Config, Uart}; +use heapless::String; +use {defmt_rtt as _, panic_probe as _}; + +/// Arduino-style pin 13 = PA5 (User LED LD2 on Nucleo-F401RE) +const LED_PIN: u8 = 13; + +/// Parse integer from JSON: "pin":13 or "value":1 +fn parse_arg(line: &[u8], key: &[u8]) -> Option { + // key like b"pin" -> search for b"\"pin\":" + let mut suffix: [u8; 32] = [0; 32]; + suffix[0] = b'"'; + let mut len = 1; + for (i, &k) in key.iter().enumerate() { + if i >= 30 { + break; + } + suffix[len] = k; + len += 1; + } + suffix[len] = b'"'; + suffix[len + 1] = b':'; + len += 2; + let suffix = &suffix[..len]; + + let line_len = line.len(); + if line_len < len { + return None; + } + for i in 0..=line_len - len { + if line[i..].starts_with(suffix) { + let rest = &line[i + len..]; + let mut num: i32 = 0; + let mut neg = false; + let mut j = 0; + if j < rest.len() && rest[j] == b'-' { + neg = true; + j += 1; + } + while j < rest.len() && rest[j].is_ascii_digit() { + num = num * 10 + (rest[j] - b'0') as i32; + j += 1; + } + return Some(if neg { -num } else { num }); + } + } + None +} + +fn has_cmd(line: &[u8], cmd: &[u8]) -> bool { + let mut pat: [u8; 64] = [0; 64]; + pat[0..7].copy_from_slice(b"\"cmd\":\""); + let clen = cmd.len().min(50); + pat[7..7 + clen].copy_from_slice(&cmd[..clen]); + pat[7 + clen] = b'"'; + let pat = &pat[..8 + clen]; + + let line_len = line.len(); + if line_len < pat.len() { + return false; + } + for i in 0..=line_len - pat.len() { + if line[i..].starts_with(pat) { + return true; + } + } + false +} + +/// Extract "id" for response +fn copy_id(line: &[u8], out: &mut [u8]) -> usize { + let prefix = b"\"id\":\""; + if line.len() < prefix.len() + 1 { + out[0] = b'0'; + return 1; + } + for i in 0..=line.len() - prefix.len() { + if line[i..].starts_with(prefix) { + let start = i + prefix.len(); + let mut j = 0; + while start + j < line.len() && j < out.len() - 1 && line[start + j] != b'"' { + out[j] = line[start + j]; + j += 1; + } + return j; + } + } + out[0] = b'0'; + 1 +} + +#[embassy_executor::main] +async fn main(_spawner: Spawner) { + let p = embassy_stm32::init(Default::default()); + + let mut config = Config::default(); + config.baudrate = 115_200; + + let mut usart = Uart::new_blocking(p.USART2, p.PA3, p.PA2, config).unwrap(); + let mut led = Output::new(p.PA5, Level::Low, Speed::Low); + + info!("ZeroClaw Nucleo firmware ready on USART2 (115200)"); + + let mut line_buf: heapless::Vec = heapless::Vec::new(); + let mut id_buf = [0u8; 16]; + let mut resp_buf: String<128> = String::new(); + + loop { + let mut byte = [0u8; 1]; + if usart.blocking_read(&mut byte).is_ok() { + let b = byte[0]; + if b == b'\n' || b == b'\r' { + if !line_buf.is_empty() { + let id_len = copy_id(&line_buf, &mut id_buf); + let id_str = str::from_utf8(&id_buf[..id_len]).unwrap_or("0"); + + resp_buf.clear(); + if has_cmd(&line_buf, b"ping") { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"pong\"}}", id_str); + } else if has_cmd(&line_buf, b"capabilities") { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":true,\"result\":\"{{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}}\"}}", + id_str + ); + } else if has_cmd(&line_buf, b"gpio_read") { + let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1); + if pin == LED_PIN as i32 { + // Output doesn't support read; return 0 (LED state not readable) + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str); + } else if pin >= 0 && pin <= 13 { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str); + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}", + id_str, pin + ); + } + } else if has_cmd(&line_buf, b"gpio_write") { + let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1); + let value = parse_arg(&line_buf, b"value").unwrap_or(0); + if pin == LED_PIN as i32 { + led.set_level(if value != 0 { Level::High } else { Level::Low }); + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str); + } else if pin >= 0 && pin <= 13 { + let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str); + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}", + id_str, pin + ); + } + } else { + let _ = write!( + resp_buf, + "{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}}", + id_str + ); + } + + let _ = usart.blocking_write(resp_buf.as_bytes()); + let _ = usart.blocking_write(b"\n"); + line_buf.clear(); + } + } else if line_buf.push(b).is_err() { + line_buf.clear(); + } + } + } +} diff --git a/firmware/zeroclaw-uno-q-bridge/app.yaml b/firmware/zeroclaw-uno-q-bridge/app.yaml new file mode 100644 index 0000000..32c5eb6 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/app.yaml @@ -0,0 +1,9 @@ +name: ZeroClaw Bridge +description: "GPIO bridge for ZeroClaw — exposes digitalWrite/digitalRead via socket for agent control" +icon: 🦀 +version: "1.0.0" + +ports: + - 9999 + +bricks: [] diff --git a/firmware/zeroclaw-uno-q-bridge/python/main.py b/firmware/zeroclaw-uno-q-bridge/python/main.py new file mode 100644 index 0000000..d4b286b --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/python/main.py @@ -0,0 +1,66 @@ +# ZeroClaw Bridge — socket server for GPIO control from ZeroClaw agent +# SPDX-License-Identifier: MPL-2.0 + +import socket +import threading +from arduino.app_utils import App, Bridge + +ZEROCLAW_PORT = 9999 + +def handle_client(conn): + try: + data = conn.recv(256).decode().strip() + if not data: + conn.close() + return + parts = data.split() + if len(parts) < 2: + conn.sendall(b"error: invalid command\n") + conn.close() + return + cmd = parts[0].lower() + if cmd == "gpio_write" and len(parts) >= 3: + pin = int(parts[1]) + value = int(parts[2]) + Bridge.call("digitalWrite", [pin, value]) + conn.sendall(b"ok\n") + elif cmd == "gpio_read" and len(parts) >= 2: + pin = int(parts[1]) + val = Bridge.call("digitalRead", [pin]) + conn.sendall(f"{val}\n".encode()) + else: + conn.sendall(b"error: unknown command\n") + except Exception as e: + try: + conn.sendall(f"error: {e}\n".encode()) + except Exception: + pass + finally: + conn.close() + +def accept_loop(server): + while True: + try: + conn, _ = server.accept() + t = threading.Thread(target=handle_client, args=(conn,)) + t.daemon = True + t.start() + except Exception: + break + +def loop(): + App.sleep(1) + +def main(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", ZEROCLAW_PORT)) + server.listen(5) + server.settimeout(1.0) + t = threading.Thread(target=accept_loop, args=(server,)) + t.daemon = True + t.start() + App.run(user_loop=loop) + +if __name__ == "__main__": + main() diff --git a/firmware/zeroclaw-uno-q-bridge/python/requirements.txt b/firmware/zeroclaw-uno-q-bridge/python/requirements.txt new file mode 100644 index 0000000..a7fe2e0 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/python/requirements.txt @@ -0,0 +1 @@ +# ZeroClaw Bridge — no extra deps (arduino.app_utils is preinstalled on Uno Q) diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino new file mode 100644 index 0000000..0e7b11b --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino @@ -0,0 +1,24 @@ +// ZeroClaw Bridge — expose digitalWrite/digitalRead for agent GPIO control +// SPDX-License-Identifier: MPL-2.0 + +#include "Arduino_RouterBridge.h" + +void gpio_write(int pin, int value) { + pinMode(pin, OUTPUT); + digitalWrite(pin, value ? HIGH : LOW); +} + +int gpio_read(int pin) { + pinMode(pin, INPUT); + return digitalRead(pin); +} + +void setup() { + Bridge.begin(); + Bridge.provide("digitalWrite", gpio_write); + Bridge.provide("digitalRead", gpio_read); +} + +void loop() { + Bridge.update(); +} diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml new file mode 100644 index 0000000..d9fe917 --- /dev/null +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - MsgPack (0.4.2) + - DebugLog (0.8.4) + - ArxContainer (0.7.0) + - ArxTypeTraits (0.3.1) +default_profile: default diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 14c3840..e7421ad 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -143,6 +143,46 @@ async fn build_context(mem: &dyn Memory, user_msg: &str) -> String { context } +/// Build hardware datasheet context from RAG when peripherals are enabled. +/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks. +fn build_hardware_context( + rag: &crate::rag::HardwareRag, + user_msg: &str, + boards: &[String], + chunk_limit: usize, +) -> String { + if rag.is_empty() || boards.is_empty() { + return String::new(); + } + + let mut context = String::new(); + + // Pin aliases: when user says "red led", inject "red_led: 13" for matching boards + let pin_ctx = rag.pin_alias_context(user_msg, boards); + if !pin_ctx.is_empty() { + context.push_str(&pin_ctx); + } + + let chunks = rag.retrieve(user_msg, boards, chunk_limit); + if chunks.is_empty() && pin_ctx.is_empty() { + return String::new(); + } + + if !chunks.is_empty() { + context.push_str("[Hardware documentation]\n"); + } + for chunk in chunks { + let board_tag = chunk.board.as_deref().unwrap_or("generic"); + let _ = writeln!( + context, + "--- {} ({}) ---\n{}\n", + chunk.source, board_tag, chunk.content + ); + } + context.push('\n'); + context +} + /// Find a tool by name in the registry. fn find_tool<'a>(tools: &'a [Box], name: &str) -> Option<&'a dyn Tool> { tools.iter().find(|t| t.name() == name).map(|t| t.as_ref()) @@ -370,10 +410,9 @@ struct ParsedToolCall { arguments: serde_json::Value, } -/// Execute a single turn for channel runtime paths. -/// -/// Channel runtime now provides an explicit provider label so observer events -/// stay consistent with the main agent loop execution path. +/// Execute a single turn of the agent loop: send messages, parse tool calls, +/// execute tools, and loop until the LLM produces a final text response. +/// When `silent` is true, suppresses stdout (for channel use). pub(crate) async fn agent_turn( provider: &dyn Provider, history: &mut Vec, @@ -382,6 +421,7 @@ pub(crate) async fn agent_turn( provider_name: &str, model: &str, temperature: f64, + silent: bool, ) -> Result { run_tool_call_loop( provider, @@ -391,6 +431,7 @@ pub(crate) async fn agent_turn( provider_name, model, temperature, + silent, ) .await } @@ -405,6 +446,7 @@ pub(crate) async fn run_tool_call_loop( provider_name: &str, model: &str, temperature: f64, + silent: bool, ) -> Result { for _iteration in 0..MAX_TOOL_ITERATIONS { observer.record_event(&ObserverEvent::LlmRequest { @@ -458,17 +500,16 @@ pub(crate) async fn run_tool_call_loop( if tool_calls.is_empty() { // No tool calls — this is the final response - let final_text = if parsed_text.is_empty() { + history.push(ChatMessage::assistant(response_text.clone())); + return Ok(if parsed_text.is_empty() { response_text } else { parsed_text - }; - history.push(ChatMessage::assistant(&final_text)); - return Ok(final_text); + }); } - // Print any text the LLM produced alongside tool calls - if !parsed_text.is_empty() { + // Print any text the LLM produced alongside tool calls (unless silent) + if !silent && !parsed_text.is_empty() { print!("{parsed_text}"); let _ = std::io::stdout().flush(); } @@ -515,7 +556,7 @@ pub(crate) async fn run_tool_call_loop( } // Add assistant message with tool calls + tool results to history - history.push(ChatMessage::assistant(&assistant_history_content)); + history.push(ChatMessage::assistant(assistant_history_content.clone())); history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}"))); } @@ -529,6 +570,10 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box]) -> Strin instructions.push_str("\n## Tool Use Protocol\n\n"); instructions.push_str("To use a tool, wrap a JSON object in tags:\n\n"); instructions.push_str("```\n\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n\n```\n\n"); + instructions.push_str( + "CRITICAL: Output actual tags—never describe steps or give examples.\n\n", + ); + instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n\n\n"); instructions.push_str("You may use multiple tool calls in a single response. "); instructions.push_str("After tool execution, results appear in tags. "); instructions @@ -555,18 +600,11 @@ pub async fn run( provider_override: Option, model_override: Option, temperature: f64, - verbose: bool, + peripheral_overrides: Vec, ) -> Result<()> { // ── Wire up agnostic subsystems ────────────────────────────── let base_observer = observability::create_observer(&config.observability); - let observer: Arc = if verbose { - Arc::from(Box::new(observability::MultiObserver::new(vec![ - base_observer, - Box::new(observability::VerboseObserver::new()), - ])) as Box) - } else { - Arc::from(base_observer) - }; + let observer: Arc = Arc::from(base_observer); let runtime: Arc = Arc::from(runtime::create_runtime(&config.runtime)?); let security = Arc::new(SecurityPolicy::from_config( @@ -582,7 +620,15 @@ pub async fn run( )?); tracing::info!(backend = mem.name(), "Memory initialized"); - // ── Tools (including memory tools) ──────────────────────────── + // ── Peripherals (merge peripheral tools into registry) ─ + if !peripheral_overrides.is_empty() { + tracing::info!( + peripherals = ?peripheral_overrides, + "Peripheral overrides from CLI (config boards take precedence)" + ); + } + + // ── Tools (including memory tools and peripherals) ──────────── let (composio_key, composio_entity_id) = if config.composio.enabled { ( config.composio.api_key.as_deref(), @@ -591,7 +637,7 @@ pub async fn run( } else { (None, None) }; - let tools_registry = tools::all_tools_with_runtime( + let mut tools_registry = tools::all_tools_with_runtime( &security, runtime, mem.clone(), @@ -605,6 +651,13 @@ pub async fn run( &config, ); + let peripheral_tools: Vec> = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + if !peripheral_tools.is_empty() { + tracing::info!(count = peripheral_tools.len(), "Peripheral tools added"); + tools_registry.extend(peripheral_tools); + } + // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override .as_deref() @@ -629,6 +682,26 @@ pub async fn run( model: model_name.to_string(), }); + // ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ── + let hardware_rag: Option = config + .peripherals + .datasheet_dir + .as_ref() + .filter(|d| !d.trim().is_empty()) + .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) + .and_then(Result::ok) + .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); + if let Some(ref rag) = hardware_rag { + tracing::info!(chunks = rag.len(), "Hardware RAG loaded"); + } + + let board_names: Vec = config + .peripherals + .boards + .iter() + .map(|b| b.board.clone()) + .collect(); + // ── Build system prompt from workspace MD files (OpenClaw framework) ── let skills = crate::skills::load_skills(&config.workspace_dir); let mut tool_descs: Vec<(&str, &str)> = vec![ @@ -684,17 +757,51 @@ pub async fn run( if !config.agents.is_empty() { tool_descs.push(( "delegate", - "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \ - (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \ - prompt and returns its response.", + "Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.", )); } + if config.peripherals.enabled && !config.peripherals.boards.is_empty() { + tool_descs.push(( + "gpio_read", + "Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.", + )); + tool_descs.push(( + "gpio_write", + "Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.", + )); + tool_descs.push(( + "arduino_upload", + "Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.", + )); + tool_descs.push(( + "hardware_memory_map", + "Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.", + )); + tool_descs.push(( + "hardware_board_info", + "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.", + )); + tool_descs.push(( + "hardware_memory_read", + "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).", + )); + tool_descs.push(( + "hardware_capabilities", + "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.", + )); + } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; let mut system_prompt = crate::channels::build_system_prompt( &config.workspace_dir, model_name, &tool_descs, &skills, Some(&config.identity), + bootstrap_max_chars, ); // Append structured tool-use instructions with schemas @@ -712,8 +819,14 @@ pub async fn run( .await; } - // Inject memory context into user message - let context = build_context(mem.as_ref(), &msg).await; + // Inject memory + hardware RAG context into user message + let mem_context = build_context(mem.as_ref(), &msg).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, &msg, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); let enriched = if context.is_empty() { msg.clone() } else { @@ -733,6 +846,7 @@ pub async fn run( provider_name, model_name, temperature, + false, ) .await?; println!("{response}"); @@ -770,8 +884,14 @@ pub async fn run( .await; } - // Inject memory context into user message - let context = build_context(mem.as_ref(), &msg.content).await; + // Inject memory + hardware RAG context into user message + let mem_context = build_context(mem.as_ref(), &msg.content).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, &msg.content, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); let enriched = if context.is_empty() { msg.content.clone() } else { @@ -788,6 +908,7 @@ pub async fn run( provider_name, model_name, temperature, + false, ) .await { @@ -833,6 +954,166 @@ pub async fn run( Ok(()) } +/// Process a single message through the full agent (with tools, peripherals, memory). +/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use. +pub async fn process_message(config: Config, message: &str) -> Result { + let observer: Arc = + Arc::from(observability::create_observer(&config.observability)); + let runtime: Arc = + Arc::from(runtime::create_runtime(&config.runtime)?); + let security = Arc::new(SecurityPolicy::from_config( + &config.autonomy, + &config.workspace_dir, + )); + let mem: Arc = Arc::from(memory::create_memory( + &config.memory, + &config.workspace_dir, + config.api_key.as_deref(), + )?); + + let (composio_key, composio_entity_id) = if config.composio.enabled { + ( + config.composio.api_key.as_deref(), + Some(config.composio.entity_id.as_str()), + ) + } else { + (None, None) + }; + let mut tools_registry = tools::all_tools_with_runtime( + &security, + runtime, + mem.clone(), + composio_key, + composio_entity_id, + &config.browser, + &config.http_request, + &config.workspace_dir, + &config.agents, + config.api_key.as_deref(), + &config, + ); + let peripheral_tools: Vec> = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + tools_registry.extend(peripheral_tools); + + let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); + let model_name = config + .default_model + .clone() + .unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()); + let provider: Box = providers::create_routed_provider( + provider_name, + config.api_key.as_deref(), + &config.reliability, + &config.model_routes, + &model_name, + )?; + + let hardware_rag: Option = config + .peripherals + .datasheet_dir + .as_ref() + .filter(|d| !d.trim().is_empty()) + .map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim())) + .and_then(Result::ok) + .filter(|r: &crate::rag::HardwareRag| !r.is_empty()); + let board_names: Vec = config + .peripherals + .boards + .iter() + .map(|b| b.board.clone()) + .collect(); + + let skills = crate::skills::load_skills(&config.workspace_dir); + let mut tool_descs: Vec<(&str, &str)> = vec![ + ("shell", "Execute terminal commands."), + ("file_read", "Read file contents."), + ("file_write", "Write file contents."), + ("memory_store", "Save to memory."), + ("memory_recall", "Search memory."), + ("memory_forget", "Delete a memory entry."), + ("screenshot", "Capture a screenshot."), + ("image_info", "Read image metadata."), + ]; + if config.browser.enabled { + tool_descs.push(("browser_open", "Open approved URLs in browser.")); + } + if config.composio.enabled { + tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio.")); + } + if config.peripherals.enabled && !config.peripherals.boards.is_empty() { + tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware.")); + tool_descs.push(( + "gpio_write", + "Set GPIO pin high or low on connected hardware.", + )); + tool_descs.push(( + "arduino_upload", + "Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.", + )); + tool_descs.push(( + "hardware_memory_map", + "Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.", + )); + tool_descs.push(( + "hardware_board_info", + "Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.", + )); + tool_descs.push(( + "hardware_memory_read", + "Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.", + )); + tool_descs.push(( + "hardware_capabilities", + "Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.", + )); + } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; + let mut system_prompt = crate::channels::build_system_prompt( + &config.workspace_dir, + &model_name, + &tool_descs, + &skills, + Some(&config.identity), + bootstrap_max_chars, + ); + system_prompt.push_str(&build_tool_instructions(&tools_registry)); + + let mem_context = build_context(mem.as_ref(), message).await; + let rag_limit = if config.agent.compact_context { 2 } else { 5 }; + let hw_context = hardware_rag + .as_ref() + .map(|r| build_hardware_context(r, message, &board_names, rag_limit)) + .unwrap_or_default(); + let context = format!("{mem_context}{hw_context}"); + let enriched = if context.is_empty() { + message.to_string() + } else { + format!("{context}{message}") + }; + + let mut history = vec![ + ChatMessage::system(&system_prompt), + ChatMessage::user(&enriched), + ]; + + agent_turn( + provider.as_ref(), + &mut history, + &tools_registry, + observer.as_ref(), + provider_name, + &model_name, + config.default_temperature, + true, + ) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 83fd645..e3d7d16 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,16 +1,3 @@ pub mod loop_; -pub use loop_::run; - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_reexport_exists(_value: F) {} - - #[test] - fn run_function_is_reexported() { - assert_reexport_exists(run); - assert_reexport_exists(loop_::run); - } -} +pub use loop_::{process_message, run}; diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 5e8dbcd..a3d8281 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -43,7 +43,9 @@ const BOOTSTRAP_MAX_CHARS: usize = 20_000; const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2; const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60; -const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90; +/// Timeout for processing a single channel message (LLM + tools). +/// 300s for on-device LLMs (Ollama) which are slower than cloud APIs. +const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300; const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4; const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8; const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64; @@ -190,6 +192,7 @@ async fn process_channel_message(ctx: Arc, msg: traits::C "channel-runtime", ctx.model.as_str(), ctx.temperature, + true, // silent — channels don't write to stdout ), ) .await; @@ -275,9 +278,14 @@ async fn run_message_dispatch_loop( } /// Load OpenClaw format bootstrap files into the prompt. -fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) { - prompt - .push_str("The following workspace files define your identity, behavior, and context.\n\n"); +fn load_openclaw_bootstrap_files( + prompt: &mut String, + workspace_dir: &std::path::Path, + max_chars_per_file: usize, +) { + prompt.push_str( + "The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n", + ); let bootstrap_files = [ "AGENTS.md", @@ -289,17 +297,17 @@ fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path: ]; for filename in &bootstrap_files { - inject_workspace_file(prompt, workspace_dir, filename); + inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file); } // BOOTSTRAP.md — only if it exists (first-run ritual) let bootstrap_path = workspace_dir.join("BOOTSTRAP.md"); if bootstrap_path.exists() { - inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md"); + inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file); } // MEMORY.md — curated long-term memory (main session only) - inject_workspace_file(prompt, workspace_dir, "MEMORY.md"); + inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file); } /// Load workspace identity files and build a system prompt. @@ -324,6 +332,7 @@ pub fn build_system_prompt( tools: &[(&str, &str)], skills: &[crate::skills::Skill], identity_config: Option<&crate::config::IdentityConfig>, + bootstrap_max_chars: Option, ) -> String { use std::fmt::Write; let mut prompt = String::with_capacity(8192); @@ -344,6 +353,35 @@ pub fn build_system_prompt( .push_str("Continue reasoning with the results until you can give a final answer.\n\n"); } + // ── 1b. Hardware (when gpio/arduino tools present) ─────────── + let has_hardware = tools.iter().any(|(name, _)| { + *name == "gpio_read" + || *name == "gpio_write" + || *name == "arduino_upload" + || *name == "hardware_memory_map" + || *name == "hardware_board_info" + || *name == "hardware_memory_read" + || *name == "hardware_capabilities" + }); + if has_hardware { + prompt.push_str( + "## Hardware Access\n\n\ + You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\ + All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\ + When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\ + When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\ + Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n", + ); + } + + // ── 1c. Action instruction (avoid meta-summary) ─────────────── + prompt.push_str( + "## Your Task\n\n\ + When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\ + Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\ + Instead: emit actual tags when you need to act. Just do what they ask.\n\n", + ); + // ── 2. Safety ─────────────────────────────────────────────── prompt.push_str("## Safety\n\n"); prompt.push_str( @@ -406,23 +444,27 @@ pub fn build_system_prompt( Ok(None) => { // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true) // Fall back to OpenClaw bootstrap files - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } Err(e) => { // Log error but don't fail - fall back to OpenClaw eprintln!( "Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format." ); - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } } } else { // OpenClaw format - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } } else { // No identity config - use OpenClaw format - load_openclaw_bootstrap_files(&mut prompt, workspace_dir); + let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS); + load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars); } // ── 6. Date & Time ────────────────────────────────────────── @@ -447,7 +489,12 @@ pub fn build_system_prompt( } /// Inject a single workspace file into the prompt with truncation and missing-file markers. -fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) { +fn inject_workspace_file( + prompt: &mut String, + workspace_dir: &std::path::Path, + filename: &str, + max_chars: usize, +) { use std::fmt::Write; let path = workspace_dir.join(filename); @@ -459,10 +506,10 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f } let _ = writeln!(prompt, "### {filename}\n"); // Use character-boundary-safe truncation for UTF-8 - let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS { + let truncated = if trimmed.chars().count() > max_chars { trimmed .char_indices() - .nth(BOOTSTRAP_MAX_CHARS) + .nth(max_chars) .map(|(idx, _)| &trimmed[..idx]) .unwrap_or(trimmed) } else { @@ -472,7 +519,7 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f prompt.push_str(truncated); let _ = writeln!( prompt, - "\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n" + "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n" ); } else { prompt.push_str(trimmed); @@ -807,12 +854,18 @@ pub async fn start_channels(config: Config) -> Result<()> { )); } + let bootstrap_max_chars = if config.agent.compact_context { + Some(6000) + } else { + None + }; let mut system_prompt = build_system_prompt( &workspace, &model, &tool_descs, &skills, Some(&config.identity), + bootstrap_max_chars, ); system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref())); @@ -1298,7 +1351,7 @@ mod tests { fn prompt_contains_all_sections() { let ws = make_workspace(); let tools = vec![("shell", "Run commands"), ("file_read", "Read files")]; - let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None); + let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None); // Section headers assert!(prompt.contains("## Tools"), "missing Tools section"); @@ -1322,7 +1375,7 @@ mod tests { ("shell", "Run commands"), ("memory_recall", "Search memory"), ]; - let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None); + let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None); assert!(prompt.contains("**shell**")); assert!(prompt.contains("Run commands")); @@ -1332,7 +1385,7 @@ mod tests { #[test] fn prompt_injects_safety() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains("Do not exfiltrate private data")); assert!(prompt.contains("Do not run destructive commands")); @@ -1342,7 +1395,7 @@ mod tests { #[test] fn prompt_injects_workspace_files() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header"); assert!(prompt.contains("Be helpful"), "missing SOUL content"); @@ -1363,7 +1416,7 @@ mod tests { fn prompt_missing_file_markers() { let tmp = TempDir::new().unwrap(); // Empty workspace — no files at all - let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None); assert!(prompt.contains("[File not found: SOUL.md]")); assert!(prompt.contains("[File not found: AGENTS.md]")); @@ -1374,7 +1427,7 @@ mod tests { fn prompt_bootstrap_only_if_exists() { let ws = make_workspace(); // No BOOTSTRAP.md — should not appear - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( !prompt.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should not appear when missing" @@ -1382,7 +1435,7 @@ mod tests { // Create BOOTSTRAP.md — should appear std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap(); - let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( prompt2.contains("### BOOTSTRAP.md"), "BOOTSTRAP.md should appear when present" @@ -1402,7 +1455,7 @@ mod tests { ) .unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Daily notes should NOT be in the system prompt (on-demand via tools) assert!( @@ -1418,7 +1471,7 @@ mod tests { #[test] fn prompt_runtime_metadata() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None); assert!(prompt.contains("Model: claude-sonnet-4")); assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS))); @@ -1439,7 +1492,7 @@ mod tests { location: None, }]; - let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None); + let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None); assert!(prompt.contains(""), "missing skills XML"); assert!(prompt.contains("code-review")); @@ -1460,7 +1513,7 @@ mod tests { let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000); std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!( prompt.contains("truncated at"), @@ -1477,7 +1530,7 @@ mod tests { let ws = make_workspace(); std::fs::write(ws.path().join("TOOLS.md"), "").unwrap(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Empty file should not produce a header assert!( @@ -1505,7 +1558,7 @@ mod tests { #[test] fn prompt_workspace_path() { let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); } @@ -1635,7 +1688,7 @@ mod tests { aieos_inline: None, }; - let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None); // Should contain AIEOS sections assert!(prompt.contains("## Identity")); @@ -1675,6 +1728,7 @@ mod tests { &[], &[], Some(&config), + None, ); assert!(prompt.contains("**Name:** Claw")); @@ -1692,7 +1746,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should fall back to OpenClaw format when AIEOS file is not found // (Error is logged to stderr with filename, not included in prompt) @@ -1711,7 +1765,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should use OpenClaw format (not configured for AIEOS) assert!(prompt.contains("### SOUL.md")); @@ -1729,7 +1783,7 @@ mod tests { }; let ws = make_workspace(); - let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config)); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None); // Should use OpenClaw format even if aieos_path is set assert!(prompt.contains("### SOUL.md")); @@ -1741,7 +1795,7 @@ mod tests { fn none_identity_config_uses_openclaw() { let ws = make_workspace(); // Pass None for identity config - let prompt = build_system_prompt(ws.path(), "model", &[], &[], None); + let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None); // Should use OpenClaw format assert!(prompt.contains("### SOUL.md")); diff --git a/src/config/mod.rs b/src/config/mod.rs index 3103f42..cd9601c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,12 +2,14 @@ pub mod schema; #[allow(unused_imports)] pub use schema::{ - AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, ChannelsConfig, - ComposioConfig, Config, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, - HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, - MemoryConfig, ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, - RuntimeConfig, SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig, - TelegramConfig, TunnelConfig, WebhookConfig, + AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, + ChannelsConfig, ComposioConfig, Config, CostConfig, DelegateAgentConfig, DiscordConfig, + DockerRuntimeConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, + HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, + ModelRouteConfig, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, + ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, + SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig, + WebhookConfig, }; #[cfg(test)] diff --git a/src/config/schema.rs b/src/config/schema.rs index 9473f90..f615d13 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -74,31 +74,139 @@ pub struct Config { #[serde(default)] pub cost: CostConfig, - /// Hardware Abstraction Layer (HAL) configuration. - /// Controls how ZeroClaw interfaces with physical hardware - /// (GPIO, serial, debug probes). #[serde(default)] - pub hardware: crate::hardware::HardwareConfig, + pub peripherals: PeripheralsConfig, - /// Named delegate agents for agent-to-agent handoff. - /// - /// ```toml - /// [agents.researcher] - /// provider = "gemini" - /// model = "gemini-2.0-flash" - /// system_prompt = "You are a research assistant..." - /// - /// [agents.coder] - /// provider = "openrouter" - /// model = "anthropic/claude-sonnet-4-20250514" - /// system_prompt = "You are a coding assistant..." - /// ``` + /// Agent context limits — use compact for smaller models (e.g. 13B with 4k–8k context). + #[serde(default)] + pub agent: AgentConfig, + + /// Delegate agent configurations for multi-agent workflows. #[serde(default)] pub agents: HashMap, - /// Security configuration (sandboxing, resource limits, audit logging) + /// Hardware configuration (wizard-driven physical world setup). #[serde(default)] - pub security: SecurityConfig, + pub hardware: HardwareConfig, +} + +// ── Agent (context limits for smaller models) ──────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models. + #[serde(default)] + pub compact_context: bool, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + compact_context: false, + } + } +} + +// ── Delegate Agents ────────────────────────────────────────────── + +/// Configuration for a delegate sub-agent used by the `delegate` tool. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DelegateAgentConfig { + /// Provider name (e.g. "ollama", "openrouter", "anthropic") + pub provider: String, + /// Model name + pub model: String, + /// Optional system prompt for the sub-agent + #[serde(default)] + pub system_prompt: Option, + /// Optional API key override + #[serde(default)] + pub api_key: Option, + /// Temperature override + #[serde(default)] + pub temperature: Option, + /// Max recursion depth for nested delegation + #[serde(default = "default_max_depth")] + pub max_depth: u32, +} + +fn default_max_depth() -> u32 { + 3 +} + +// ── Hardware Config (wizard-driven) ───────────────────────────── + +/// Hardware transport mode. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum HardwareTransport { + None, + Native, + Serial, + Probe, +} + +impl Default for HardwareTransport { + fn default() -> Self { + Self::None + } +} + +impl std::fmt::Display for HardwareTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, "none"), + Self::Native => write!(f, "native"), + Self::Serial => write!(f, "serial"), + Self::Probe => write!(f, "probe"), + } + } +} + +/// Wizard-driven hardware configuration for physical world interaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HardwareConfig { + /// Whether hardware access is enabled + #[serde(default)] + pub enabled: bool, + /// Transport mode + #[serde(default)] + pub transport: HardwareTransport, + /// Serial port path (e.g. "/dev/ttyACM0") + #[serde(default)] + pub serial_port: Option, + /// Serial baud rate + #[serde(default = "default_baud_rate")] + pub baud_rate: u32, + /// Probe target chip (e.g. "STM32F401RE") + #[serde(default)] + pub probe_target: Option, + /// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups) + #[serde(default)] + pub workspace_datasheets: bool, +} + +fn default_baud_rate() -> u32 { + 115200 +} + +impl HardwareConfig { + /// Return the active transport mode. + pub fn transport_mode(&self) -> HardwareTransport { + self.transport.clone() + } +} + +impl Default for HardwareConfig { + fn default() -> Self { + Self { + enabled: false, + transport: HardwareTransport::None, + serial_port: None, + baud_rate: default_baud_rate(), + probe_target: None, + workspace_datasheets: false, + } + } } // ── Identity (AIEOS / OpenClaw format) ────────────────────────── @@ -271,34 +379,64 @@ fn get_default_pricing() -> std::collections::HashMap { prices } -// ── Agent delegation ───────────────────────────────────────────── +// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ──────────────────────── -/// Configuration for a named delegate agent that can be invoked via the -/// `delegate` tool. Each agent uses its own provider/model combination -/// and system prompt, enabling multi-agent workflows with specialization. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DelegateAgentConfig { - /// Provider name (e.g. "gemini", "openrouter", "ollama") - pub provider: String, - /// Model identifier for the provider - pub model: String, - /// System prompt defining the agent's role and capabilities +pub struct PeripheralsConfig { + /// Enable peripheral support (boards become agent tools) #[serde(default)] - pub system_prompt: Option, - /// Optional API key override (uses default if not set). - /// Stored encrypted when `secrets.encrypt = true`. + pub enabled: bool, + /// Board configurations (nucleo-f401re, rpi-gpio, etc.) #[serde(default)] - pub api_key: Option, - /// Temperature override (uses 0.7 if not set) + pub boards: Vec, + /// Path to datasheet docs (relative to workspace) for RAG retrieval. + /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md). #[serde(default)] - pub temperature: Option, - /// Maximum delegation depth to prevent infinite recursion (default: 3) - #[serde(default = "default_max_delegation_depth")] - pub max_depth: u32, + pub datasheet_dir: Option, } -fn default_max_delegation_depth() -> u32 { - 3 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PeripheralBoardConfig { + /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc. + pub board: String, + /// Transport: "serial", "native", "websocket" + #[serde(default = "default_peripheral_transport")] + pub transport: String, + /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0" + #[serde(default)] + pub path: Option, + /// Baud rate for serial (default: 115200) + #[serde(default = "default_peripheral_baud")] + pub baud: u32, +} + +fn default_peripheral_transport() -> String { + "serial".into() +} + +fn default_peripheral_baud() -> u32 { + 115200 +} + +impl Default for PeripheralsConfig { + fn default() -> Self { + Self { + enabled: false, + boards: Vec::new(), + datasheet_dir: None, + } + } +} + +impl Default for PeripheralBoardConfig { + fn default() -> Self { + Self { + board: String::new(), + transport: default_peripheral_transport(), + path: None, + baud: default_peripheral_baud(), + } + } } // ── Gateway security ───────────────────────────────────────────── @@ -1381,9 +1519,10 @@ impl Default for Config { http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), + agent: AgentConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), } } } @@ -1410,37 +1549,36 @@ impl Config { // Set computed paths that are skipped during serialization config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); - - // Decrypt agent API keys if encryption is enabled - let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt); - for agent in config.agents.values_mut() { - if let Some(ref encrypted_key) = agent.api_key { - agent.api_key = Some( - store - .decrypt(encrypted_key) - .context("Failed to decrypt agent API key")?, - ); - } - } - + config.apply_env_overrides(); Ok(config) } else { let mut config = Config::default(); config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); config.save()?; + config.apply_env_overrides(); Ok(config) } } /// Apply environment variable overrides to config pub fn apply_env_overrides(&mut self) { - // API Key: ZEROCLAW_API_KEY or API_KEY + // API Key: ZEROCLAW_API_KEY or API_KEY (generic) if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) { if !key.is_empty() { self.api_key = Some(key); } } + // API Key: GLM_API_KEY overrides when provider is glm (provider-specific) + if self.default_provider.as_deref() == Some("glm") + || self.default_provider.as_deref() == Some("zhipu") + { + if let Ok(key) = std::env::var("GLM_API_KEY") { + if !key.is_empty() { + self.api_key = Some(key); + } + } + } // Provider: ZEROCLAW_PROVIDER or PROVIDER if let Ok(provider) = @@ -1737,9 +1875,10 @@ mod tests { http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), + agent: AgentConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -1814,9 +1953,10 @@ default_temperature = 0.7 http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), - hardware: crate::hardware::HardwareConfig::default(), + peripherals: PeripheralsConfig::default(), + agent: AgentConfig::default(), agents: HashMap::new(), - security: SecurityConfig::default(), + hardware: HardwareConfig::default(), }; config.save().unwrap(); @@ -2637,236 +2777,41 @@ default_temperature = 0.7 assert!(g.paired_tokens.is_empty()); } - // ── Lark config ─────────────────────────────────────────────── + // ── Peripherals config ─────────────────────────────────────── #[test] - fn lark_config_serde() { - let lc = LarkConfig { - app_id: "cli_123456".into(), - app_secret: "secret_abc".into(), - encrypt_key: Some("encrypt_key".into()), - verification_token: Some("verify_token".into()), - allowed_users: vec!["user_123".into(), "user_456".into()], - use_feishu: true, + fn peripherals_config_default_disabled() { + let p = PeripheralsConfig::default(); + assert!(!p.enabled); + assert!(p.boards.is_empty()); + } + + #[test] + fn peripheral_board_config_defaults() { + let b = PeripheralBoardConfig::default(); + assert!(b.board.is_empty()); + assert_eq!(b.transport, "serial"); + assert!(b.path.is_none()); + assert_eq!(b.baud, 115200); + } + + #[test] + fn peripherals_config_toml_roundtrip() { + let p = PeripheralsConfig { + enabled: true, + boards: vec![PeripheralBoardConfig { + board: "nucleo-f401re".into(), + transport: "serial".into(), + path: Some("/dev/ttyACM0".into()), + baud: 115200, + }], + datasheet_dir: None, }; - let json = serde_json::to_string(&lc).unwrap(); - let parsed: LarkConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.app_id, "cli_123456"); - assert_eq!(parsed.app_secret, "secret_abc"); - assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key")); - assert_eq!(parsed.verification_token.as_deref(), Some("verify_token")); - assert_eq!(parsed.allowed_users.len(), 2); - assert!(parsed.use_feishu); - } - - #[test] - fn lark_config_toml_roundtrip() { - let lc = LarkConfig { - app_id: "cli_123456".into(), - app_secret: "secret_abc".into(), - encrypt_key: Some("encrypt_key".into()), - verification_token: Some("verify_token".into()), - allowed_users: vec!["*".into()], - use_feishu: false, - }; - let toml_str = toml::to_string(&lc).unwrap(); - let parsed: LarkConfig = toml::from_str(&toml_str).unwrap(); - assert_eq!(parsed.app_id, "cli_123456"); - assert_eq!(parsed.app_secret, "secret_abc"); - assert!(!parsed.use_feishu); - } - - #[test] - fn lark_config_deserializes_without_optional_fields() { - let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert!(parsed.encrypt_key.is_none()); - assert!(parsed.verification_token.is_none()); - assert!(parsed.allowed_users.is_empty()); - assert!(!parsed.use_feishu); - } - - #[test] - fn lark_config_defaults_to_lark_endpoint() { - let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert!( - !parsed.use_feishu, - "use_feishu should default to false (Lark)" - ); - } - - #[test] - fn lark_config_with_wildcard_allowed_users() { - let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#; - let parsed: LarkConfig = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.allowed_users, vec!["*"]); - } - - // ══════════════════════════════════════════════════════════ - // AGENT DELEGATION CONFIG TESTS - // ══════════════════════════════════════════════════════════ - - #[test] - fn agents_config_default_empty() { - let c = Config::default(); - assert!(c.agents.is_empty()); - } - - #[test] - fn agents_config_backward_compat_missing_section() { - let minimal = r#" -workspace_dir = "/tmp/ws" -config_path = "/tmp/config.toml" -default_temperature = 0.7 -"#; - let parsed: Config = toml::from_str(minimal).unwrap(); - assert!(parsed.agents.is_empty()); - } - - #[test] - fn agents_config_toml_roundtrip() { - let toml_str = r#" -default_temperature = 0.7 - -[agents.researcher] -provider = "gemini" -model = "gemini-2.0-flash" -system_prompt = "You are a research assistant." -max_depth = 2 - -[agents.coder] -provider = "openrouter" -model = "anthropic/claude-sonnet-4-20250514" -"#; - let parsed: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(parsed.agents.len(), 2); - - let researcher = &parsed.agents["researcher"]; - assert_eq!(researcher.provider, "gemini"); - assert_eq!(researcher.model, "gemini-2.0-flash"); - assert_eq!( - researcher.system_prompt.as_deref(), - Some("You are a research assistant.") - ); - assert_eq!(researcher.max_depth, 2); - assert!(researcher.api_key.is_none()); - assert!(researcher.temperature.is_none()); - - let coder = &parsed.agents["coder"]; - assert_eq!(coder.provider, "openrouter"); - assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514"); - assert!(coder.system_prompt.is_none()); - assert_eq!(coder.max_depth, 3); // default - } - - #[test] - fn agents_config_with_api_key_and_temperature() { - let toml_str = r#" -[agents.fast] -provider = "groq" -model = "llama-3.3-70b-versatile" -api_key = "gsk-test-key" -temperature = 0.3 -"#; - let parsed: HashMap = toml::from_str::(toml_str) - .unwrap()["agents"] - .clone() - .try_into() - .unwrap(); - let fast = &parsed["fast"]; - assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key")); - assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON); - } - - #[test] - fn agent_api_key_encrypted_on_save_and_decrypted_on_load() { - let tmp = TempDir::new().unwrap(); - let zeroclaw_dir = tmp.path(); - let config_path = zeroclaw_dir.join("config.toml"); - - // Create a config with a plaintext agent API key - let mut agents = HashMap::new(); - agents.insert( - "test_agent".to_string(), - DelegateAgentConfig { - provider: "openrouter".to_string(), - model: "test-model".to_string(), - system_prompt: None, - api_key: Some("sk-super-secret".to_string()), - temperature: None, - max_depth: 3, - }, - ); - let config = Config { - config_path: config_path.clone(), - workspace_dir: zeroclaw_dir.join("workspace"), - secrets: SecretsConfig { encrypt: true }, - agents, - ..Config::default() - }; - std::fs::create_dir_all(&config.workspace_dir).unwrap(); - config.save().unwrap(); - - // Read the raw TOML and verify the key is encrypted (not plaintext) - let raw = std::fs::read_to_string(&config_path).unwrap(); - assert!( - !raw.contains("sk-super-secret"), - "Plaintext API key should not appear in saved config" - ); - assert!( - raw.contains("enc2:"), - "Encrypted key should use enc2: prefix" - ); - - // Parse and decrypt — simulate load_or_init by reading + decrypting - let store = crate::security::SecretStore::new(zeroclaw_dir, true); - let mut loaded: Config = toml::from_str(&raw).unwrap(); - for agent in loaded.agents.values_mut() { - if let Some(ref encrypted_key) = agent.api_key { - agent.api_key = Some(store.decrypt(encrypted_key).unwrap()); - } - } - assert_eq!( - loaded.agents["test_agent"].api_key.as_deref(), - Some("sk-super-secret"), - "Decrypted key should match original" - ); - } - - #[test] - fn agent_api_key_not_encrypted_when_disabled() { - let tmp = TempDir::new().unwrap(); - let zeroclaw_dir = tmp.path(); - let config_path = zeroclaw_dir.join("config.toml"); - - let mut agents = HashMap::new(); - agents.insert( - "test_agent".to_string(), - DelegateAgentConfig { - provider: "openrouter".to_string(), - model: "test-model".to_string(), - system_prompt: None, - api_key: Some("sk-plaintext-ok".to_string()), - temperature: None, - max_depth: 3, - }, - ); - let config = Config { - config_path: config_path.clone(), - workspace_dir: zeroclaw_dir.join("workspace"), - secrets: SecretsConfig { encrypt: false }, - agents, - ..Config::default() - }; - std::fs::create_dir_all(&config.workspace_dir).unwrap(); - config.save().unwrap(); - - let raw = std::fs::read_to_string(&config_path).unwrap(); - assert!( - raw.contains("sk-plaintext-ok"), - "With encryption disabled, key should remain plaintext" - ); - assert!(!raw.contains("enc2:"), "No encryption prefix when disabled"); + let toml_str = toml::to_string(&p).unwrap(); + let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.boards.len(), 1); + assert_eq!(parsed.boards[0].board, "nucleo-f401re"); + assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0")); } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index f1bc4a1..c7935ca 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -194,7 +194,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> { let prompt = format!("[Heartbeat Task] {task}"); let temp = config.default_temperature; if let Err(e) = - crate::agent::run(config.clone(), Some(prompt), None, None, temp, false).await + crate::agent::run(config.clone(), Some(prompt), None, None, temp, vec![]).await { crate::health::mark_component_error("heartbeat", e.to_string()); tracing::warn!("Heartbeat task failed: {e}"); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 638de00..baf66fc 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -73,6 +73,7 @@ async fn gateway_agent_reply(state: &AppState, message: &str) -> Result "gateway", &state.model, state.temperature, + true, // silent — gateway responses go over HTTP ) .await?; @@ -285,6 +286,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { &tool_descs, &skills, Some(&config.identity), + None, // bootstrap_max_chars — no compact context for gateway ); system_prompt.push_str(&crate::agent::loop_::build_tool_instructions( tools_registry.as_ref(), diff --git a/src/hardware/discover.rs b/src/hardware/discover.rs new file mode 100644 index 0000000..4bbf31f --- /dev/null +++ b/src/hardware/discover.rs @@ -0,0 +1,45 @@ +//! USB device discovery — enumerate devices and enrich with board registry. + +use super::registry; +use anyhow::Result; +use nusb::MaybeFuture; + +/// Information about a discovered USB device. +#[derive(Debug, Clone)] +pub struct UsbDeviceInfo { + pub bus_id: String, + pub device_address: u8, + pub vid: u16, + pub pid: u16, + pub product_string: Option, + pub board_name: Option, + pub architecture: Option, +} + +/// Enumerate all connected USB devices and enrich with board registry lookup. +#[cfg(feature = "hardware")] +pub fn list_usb_devices() -> Result> { + let mut devices = Vec::new(); + + let iter = nusb::list_devices() + .wait() + .map_err(|e| anyhow::anyhow!("USB enumeration failed: {e}"))?; + + for dev in iter { + let vid = dev.vendor_id(); + let pid = dev.product_id(); + let board = registry::lookup_board(vid, pid); + + devices.push(UsbDeviceInfo { + bus_id: dev.bus_id().to_string(), + device_address: dev.device_address(), + vid, + pid, + product_string: dev.product_string().map(String::from), + board_name: board.map(|b| b.name.to_string()), + architecture: board.and_then(|b| b.architecture.map(String::from)), + }); + } + + Ok(devices) +} diff --git a/src/hardware/introspect.rs b/src/hardware/introspect.rs new file mode 100644 index 0000000..21b5744 --- /dev/null +++ b/src/hardware/introspect.rs @@ -0,0 +1,121 @@ +//! Device introspection — correlate serial path with USB device info. + +use super::discover; +use super::registry; +use anyhow::Result; + +/// Result of introspecting a device by path. +#[derive(Debug, Clone)] +pub struct IntrospectResult { + pub path: String, + pub vid: Option, + pub pid: Option, + pub board_name: Option, + pub architecture: Option, + pub memory_map_note: String, +} + +/// Introspect a device by its serial path (e.g. /dev/ttyACM0, /dev/tty.usbmodem*). +/// Attempts to correlate with USB devices from discovery. +#[cfg(feature = "hardware")] +pub fn introspect_device(path: &str) -> Result { + let devices = discover::list_usb_devices()?; + + // Try to correlate path with a discovered device. + // On Linux, /dev/ttyACM0 corresponds to a CDC-ACM device; we may have multiple. + // Best-effort: if we have exactly one CDC-like device, use it. Otherwise unknown. + let matched = if devices.len() == 1 { + devices.first().cloned() + } else if devices.is_empty() { + None + } else { + // Multiple devices: try to match by path. On Linux we could use sysfs; + // for stub, pick first known board or first device. + devices + .iter() + .find(|d| d.board_name.is_some()) + .cloned() + .or_else(|| devices.first().cloned()) + }; + + let (vid, pid, board_name, architecture) = match matched { + Some(d) => (Some(d.vid), Some(d.pid), d.board_name, d.architecture), + None => (None, None, None, None), + }; + + let board_info = vid.and_then(|v| pid.and_then(|p| registry::lookup_board(v, p))); + let architecture = + architecture.or_else(|| board_info.and_then(|b| b.architecture.map(String::from))); + let board_name = board_name.or_else(|| board_info.map(|b| b.name.to_string())); + + let memory_map_note = memory_map_for_board(board_name.as_deref()); + + Ok(IntrospectResult { + path: path.to_string(), + vid, + pid, + board_name, + architecture, + memory_map_note, + }) +} + +/// Get memory map: via probe-rs when probe feature on and Nucleo, else static or stub. +#[cfg(feature = "hardware")] +fn memory_map_for_board(board_name: Option<&str>) -> String { + #[cfg(feature = "probe")] + if let Some(board) = board_name { + let chip = match board { + "nucleo-f401re" => "STM32F401RETx", + "nucleo-f411re" => "STM32F411RETx", + _ => return "Build with --features probe for live memory map (Nucleo)".to_string(), + }; + match probe_memory_map(chip) { + Ok(s) => return s, + Err(_) => return format!("probe-rs attach failed (chip {}). Connect via USB.", chip), + } + } + + #[cfg(not(feature = "probe"))] + let _ = board_name; + + "Build with --features probe for live memory map via USB".to_string() +} + +#[cfg(all(feature = "hardware", feature = "probe"))] +fn probe_memory_map(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let target = session.target(); + let mut out = String::new(); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let (start, end) = (ram.range.start, ram.range.end); + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + MemoryRegion::Nvm(flash) => { + let (start, end) = (flash.range.start, flash.range.end); + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + _ => {} + } + } + if out.is_empty() { + out = "Could not read memory regions".to_string(); + } + Ok(out) +} diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs index ff467f5..8dcd90d 100644 --- a/src/hardware/mod.rs +++ b/src/hardware/mod.rs @@ -1,1348 +1,229 @@ -//! Hardware Abstraction Layer (HAL) for ZeroClaw. +//! Hardware discovery — USB device enumeration and introspection. //! -//! Provides auto-discovery of connected hardware, transport abstraction, -//! and a unified interface so the LLM agent can control physical devices -//! without knowing the underlying communication protocol. -//! -//! # Supported Transport Modes -//! -//! | Transport | Backend | Use Case | -//! |-----------|-------------|---------------------------------------------| -//! | `native` | rppal / sysfs | Raspberry Pi / Linux SBC with local GPIO | -//! | `serial` | JSON/UART | Arduino, ESP32, Nucleo via USB serial | -//! | `probe` | probe-rs | STM32/ESP32 via SWD/JTAG debug interface | -//! | `none` | — | Software-only mode (no hardware access) | +//! See `docs/hardware-peripherals-design.md` for the full design. -use anyhow::{bail, Result}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +pub mod registry; -// ── Hardware transport enum ────────────────────────────────────── +#[cfg(feature = "hardware")] +pub mod discover; -/// Transport protocol used to communicate with physical hardware. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum HardwareTransport { - /// Direct GPIO access on a Linux SBC (Raspberry Pi, Orange Pi, etc.) - Native, - /// JSON commands over USB serial (Arduino, ESP32, Nucleo) - Serial, - /// SWD/JTAG debug probe (probe-rs) for bare-metal MCUs - Probe, - /// No hardware — software-only mode - #[default] - None, +#[cfg(feature = "hardware")] +pub mod introspect; + +use crate::config::Config; +use anyhow::Result; + +// Re-export config types so wizard can use `hardware::HardwareConfig` etc. +pub use crate::config::{HardwareConfig, HardwareTransport}; + +/// A hardware device discovered during auto-scan. +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub name: String, + pub detail: Option, + pub device_path: Option, + pub transport: HardwareTransport, } -impl std::fmt::Display for HardwareTransport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Native => write!(f, "native"), - Self::Serial => write!(f, "serial"), - Self::Probe => write!(f, "probe"), - Self::None => write!(f, "none"), +/// Auto-discover connected hardware devices. +/// Returns an empty vec on platforms without hardware support. +pub fn discover_hardware() -> Vec { + // USB/serial discovery is behind the "hardware" feature gate. + #[cfg(feature = "hardware")] + { + if let Ok(devices) = discover::list_usb_devices() { + return devices + .into_iter() + .map(|d| DiscoveredDevice { + name: d + .board_name + .unwrap_or_else(|| format!("{:04x}:{:04x}", d.vid, d.pid)), + detail: d.product_string, + device_path: None, + transport: if d.architecture.as_deref() == Some("native") { + HardwareTransport::Native + } else { + HardwareTransport::Serial + }, + }) + .collect(); } } + Vec::new() +} + +/// Return the recommended default wizard choice index based on discovered devices. +/// 0 = Native, 1 = Tethered/Serial, 2 = Debug Probe, 3 = Software Only +pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { + if devices.is_empty() { + 3 // software only + } else { + 1 // tethered (most common for detected USB devices) + } } -impl HardwareTransport { - /// Parse from a string value (config file or CLI arg). - pub fn from_str_loose(s: &str) -> Self { - match s.to_ascii_lowercase().trim() { - "native" | "gpio" | "rppal" | "sysfs" => Self::Native, - "serial" | "uart" | "usb" | "tethered" => Self::Serial, - "probe" | "probe-rs" | "swd" | "jtag" | "jlink" | "j-link" => Self::Probe, - _ => Self::None, +/// Build a `HardwareConfig` from the wizard menu choice (0–3) and discovered devices. +pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig { + match choice { + 0 => HardwareConfig { + enabled: true, + transport: HardwareTransport::Native, + ..HardwareConfig::default() + }, + 1 => { + let serial_port = devices + .iter() + .find(|d| d.transport == HardwareTransport::Serial) + .and_then(|d| d.device_path.clone()); + HardwareConfig { + enabled: true, + transport: HardwareTransport::Serial, + serial_port, + ..HardwareConfig::default() + } } + 2 => HardwareConfig { + enabled: true, + transport: HardwareTransport::Probe, + ..HardwareConfig::default() + }, + _ => HardwareConfig::default(), // software only } } -// ── Hardware configuration ────────────────────────────────────── +/// Handle `zeroclaw hardware` subcommands. +#[allow(clippy::module_name_repetitions)] +pub fn handle_command(cmd: crate::HardwareCommands, _config: &Config) -> Result<()> { + #[cfg(not(feature = "hardware"))] + { + println!("Hardware discovery requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + return Ok(()); + } -/// Hardware configuration stored in `config.toml` under `[hardware]`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HardwareConfig { - /// Enable hardware integration - #[serde(default)] - pub enabled: bool, - - /// Transport mode: "native", "serial", "probe", "none" - #[serde(default = "default_transport")] - pub transport: String, - - /// Serial port path (e.g. `/dev/ttyUSB0`, `/dev/tty.usbmodem14201`) - #[serde(default)] - pub serial_port: Option, - - /// Serial baud rate (default: 115200) - #[serde(default = "default_baud_rate")] - pub baud_rate: u32, - - /// Enable datasheet RAG — index PDF schematics in workspace for pin lookups - #[serde(default)] - pub workspace_datasheets: bool, - - /// Auto-discovered board description (informational, set by discovery) - #[serde(default)] - pub discovered_board: Option, - - /// Probe target chip (e.g. "STM32F411CEUx", "nRF52840_xxAA") - #[serde(default)] - pub probe_target: Option, - - /// GPIO pin safety allowlist — only these pins can be written to. - /// Empty = all pins allowed (for development). Recommended for production. - #[serde(default)] - pub allowed_pins: Vec, - - /// Maximum PWM frequency in Hz (safety cap, default: 50_000) - #[serde(default = "default_max_pwm_freq")] - pub max_pwm_frequency_hz: u32, -} - -fn default_transport() -> String { - "none".into() -} - -fn default_baud_rate() -> u32 { - 115_200 -} - -fn default_max_pwm_freq() -> u32 { - 50_000 -} - -impl Default for HardwareConfig { - fn default() -> Self { - Self { - enabled: false, - transport: default_transport(), - serial_port: None, - baud_rate: default_baud_rate(), - workspace_datasheets: false, - discovered_board: None, - probe_target: None, - allowed_pins: Vec::new(), - max_pwm_frequency_hz: default_max_pwm_freq(), - } + #[cfg(feature = "hardware")] + match cmd { + crate::HardwareCommands::Discover => run_discover(), + crate::HardwareCommands::Introspect { path } => run_introspect(&path), + crate::HardwareCommands::Info { chip } => run_info(&chip), } } -impl HardwareConfig { - /// Return the parsed transport enum. - pub fn transport_mode(&self) -> HardwareTransport { - HardwareTransport::from_str_loose(&self.transport) +#[cfg(feature = "hardware")] +fn run_discover() -> Result<()> { + let devices = discover::list_usb_devices()?; + + if devices.is_empty() { + println!("No USB devices found."); + println!(); + println!("Connect a board (e.g. Nucleo-F401RE) via USB and try again."); + return Ok(()); } - /// Check if pin access is allowed by the safety allowlist. - /// An empty allowlist means all pins are permitted (dev mode). - pub fn is_pin_allowed(&self, pin: u8) -> bool { - self.allowed_pins.is_empty() || self.allowed_pins.contains(&pin) + println!("USB devices:"); + println!(); + for d in &devices { + let board = d.board_name.as_deref().unwrap_or("(unknown)"); + let arch = d.architecture.as_deref().unwrap_or("—"); + let product = d.product_string.as_deref().unwrap_or("—"); + println!( + " {:04x}:{:04x} {} {} {}", + d.vid, d.pid, board, arch, product + ); + } + println!(); + println!("Known boards: nucleo-f401re, nucleo-f411re, arduino-uno, arduino-mega, cp2102"); + + Ok(()) +} + +#[cfg(feature = "hardware")] +fn run_introspect(path: &str) -> Result<()> { + let result = introspect::introspect_device(path)?; + + println!("Device at {}:", result.path); + println!(); + if let (Some(vid), Some(pid)) = (result.vid, result.pid) { + println!(" VID:PID {:04x}:{:04x}", vid, pid); + } else { + println!(" VID:PID (could not correlate with USB device)"); + } + if let Some(name) = &result.board_name { + println!(" Board {}", name); + } + if let Some(arch) = &result.architecture { + println!(" Architecture {}", arch); + } + println!(" Memory map {}", result.memory_map_note); + + Ok(()) +} + +#[cfg(feature = "hardware")] +fn run_info(chip: &str) -> Result<()> { + #[cfg(feature = "probe")] + { + match info_via_probe(chip) { + Ok(()) => return Ok(()), + Err(e) => { + println!("probe-rs attach failed: {}", e); + println!(); + println!( + "Ensure Nucleo is connected via USB. The ST-Link is built into the board." + ); + println!("No firmware needs to be flashed — probe-rs reads chip info over SWD."); + return Err(e.into()); + } + } } - /// Validate the configuration, returning errors for invalid combos. - pub fn validate(&self) -> Result<()> { - if !self.enabled { - return Ok(()); - } - - let mode = self.transport_mode(); - - // Serial requires a port - if mode == HardwareTransport::Serial && self.serial_port.is_none() { - bail!("Hardware transport is 'serial' but no serial_port is configured. Run `zeroclaw onboard --interactive` or set hardware.serial_port in config.toml."); - } - - // Probe requires a target chip - if mode == HardwareTransport::Probe && self.probe_target.is_none() { - bail!("Hardware transport is 'probe' but no probe_target chip is configured. Set hardware.probe_target in config.toml (e.g. \"STM32F411CEUx\")."); - } - - // Baud rate sanity - if self.baud_rate == 0 { - bail!("hardware.baud_rate must be greater than 0."); - } - if self.baud_rate > 4_000_000 { - bail!( - "hardware.baud_rate of {} exceeds the 4 MHz safety limit.", - self.baud_rate - ); - } - - // PWM frequency sanity - if self.max_pwm_frequency_hz == 0 { - bail!("hardware.max_pwm_frequency_hz must be greater than 0."); - } - + #[cfg(not(feature = "probe"))] + { + println!("Chip info via USB requires the 'probe' feature."); + println!(); + println!("Build with: cargo build --features hardware,probe"); + println!(); + println!("Then run: zeroclaw hardware info --chip {}", chip); + println!(); + println!("This uses probe-rs to attach to the Nucleo's ST-Link over USB"); + println!("and read chip info (memory map, etc.) — no firmware on target needed."); Ok(()) } } -// ── Discovery: detected hardware on this system ───────────────── +#[cfg(all(feature = "hardware", feature = "probe"))] +fn info_via_probe(chip: &str) -> anyhow::Result<()> { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; -/// A single discovered hardware device. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DiscoveredDevice { - /// Human-readable name (e.g. "Raspberry Pi GPIO", "Arduino Uno") - pub name: String, - /// Recommended transport mode - pub transport: HardwareTransport, - /// Path to the device (e.g. `/dev/ttyUSB0`, `/dev/gpiomem`) - pub device_path: Option, - /// Additional detail (e.g. board revision, chip ID) - pub detail: Option, -} + println!("Connecting to {} via USB (ST-Link)...", chip); + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; -/// Scan the system for connected hardware. -/// -/// This function performs non-destructive, read-only probes: -/// 1. Check for Raspberry Pi GPIO (`/dev/gpiomem`, `/proc/device-tree/model`) -/// 2. Check for USB serial devices (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/tty.usbmodem*`) -/// 3. Check for SWD/JTAG probes (`/dev/ttyACM*` with probe-rs markers) -/// -/// This is intentionally conservative — it never writes to any device. -pub fn discover_hardware() -> Vec { - let mut devices = Vec::new(); - - // ── 1. Raspberry Pi / Linux SBC native GPIO ────────────── - discover_native_gpio(&mut devices); - - // ── 2. USB Serial devices (Arduino, ESP32, etc.) ───────── - discover_serial_devices(&mut devices); - - // ── 3. SWD / JTAG debug probes ────────────────────────── - discover_debug_probes(&mut devices); - - devices -} - -/// Check for native GPIO availability (Raspberry Pi, Orange Pi, etc.) -fn discover_native_gpio(devices: &mut Vec) { - // Primary indicator: /dev/gpiomem exists (Pi-specific) - let gpiomem = Path::new("/dev/gpiomem"); - // Secondary: /dev/gpiochip0 exists (any Linux with GPIO) - let gpiochip = Path::new("/dev/gpiochip0"); - - if gpiomem.exists() || gpiochip.exists() { - // Try to read model from device tree - let model = read_board_model(); - let name = model.as_deref().unwrap_or("Linux SBC with GPIO"); - - devices.push(DiscoveredDevice { - name: format!("{name} (Native GPIO)"), - transport: HardwareTransport::Native, - device_path: Some(if gpiomem.exists() { - "/dev/gpiomem".into() - } else { - "/dev/gpiochip0".into() - }), - detail: model, - }); - } -} - -/// Read the board model string from the device tree (Linux). -fn read_board_model() -> Option { - let model_path = Path::new("/proc/device-tree/model"); - if model_path.exists() { - std::fs::read_to_string(model_path) - .ok() - .map(|s| s.trim_end_matches('\0').trim().to_string()) - .filter(|s| !s.is_empty()) - } else { - None - } -} - -/// Scan for USB serial devices. -fn discover_serial_devices(devices: &mut Vec) { - let serial_patterns = serial_device_paths(); - - for pattern in &serial_patterns { - let matches = glob_paths(pattern); - for path in matches { - let name = classify_serial_device(&path); - devices.push(DiscoveredDevice { - name: format!("{name} (USB Serial)"), - transport: HardwareTransport::Serial, - device_path: Some(path.to_string_lossy().to_string()), - detail: None, - }); - } - } -} - -/// Return platform-specific glob patterns for serial devices. -fn serial_device_paths() -> Vec { - if cfg!(target_os = "macos") { - vec![ - "/dev/tty.usbmodem*".into(), - "/dev/tty.usbserial*".into(), - "/dev/tty.wchusbserial*".into(), // CH340 clones - ] - } else if cfg!(target_os = "linux") { - vec!["/dev/ttyUSB*".into(), "/dev/ttyACM*".into()] - } else { - // Windows / other — not yet supported for auto-discovery - vec![] - } -} - -/// Classify a serial device path into a human-readable name. -fn classify_serial_device(path: &Path) -> String { - let name = path.file_name().unwrap_or_default().to_string_lossy(); - let lower = name.to_ascii_lowercase(); - - if lower.contains("usbmodem") { - "Arduino/Teensy".into() - } else if lower.contains("usbserial") || lower.contains("ttyusb") { - "USB-Serial Device (FTDI/CH340/CP2102)".into() - } else if lower.contains("wchusbserial") { - "CH340/CH341 Serial".into() - } else if lower.contains("ttyacm") { - "USB CDC Device (Arduino/STM32)".into() - } else { - "Unknown Serial Device".into() - } -} - -/// Simple glob expansion for device paths. -fn glob_paths(pattern: &str) -> Vec { - glob::glob(pattern) - .map(|paths| paths.filter_map(Result::ok).collect()) - .unwrap_or_default() -} - -/// Check for SWD/JTAG debug probes. -fn discover_debug_probes(devices: &mut Vec) { - // On Linux, ST-Link probes often show up as /dev/stlinkv* - // We also check for known USB VIDs via sysfs if available - let stlink_paths = glob_paths("/dev/stlinkv*"); - for path in stlink_paths { - devices.push(DiscoveredDevice { - name: "ST-Link Debug Probe (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: Some(path.to_string_lossy().to_string()), - detail: Some("Use probe-rs for flash/debug".into()), - }); - } - - // J-Link probes on macOS - let jlink_paths = glob_paths("/dev/tty.SLAB_USBtoUART*"); - for path in jlink_paths { - devices.push(DiscoveredDevice { - name: "SEGGER J-Link (SWD/JTAG)".into(), - transport: HardwareTransport::Probe, - device_path: Some(path.to_string_lossy().to_string()), - detail: Some("Use probe-rs for flash/debug".into()), - }); - } -} - -// ── HAL Trait: Unified hardware operations ────────────────────── - -/// The core HAL trait that all transport backends implement. -/// -/// The LLM agent calls these methods via tool invocations. The HAL -/// translates them into the correct protocol for the underlying hardware. -pub trait HardwareHal: Send + Sync { - /// Read the digital state of a GPIO pin. - fn gpio_read(&self, pin: u8) -> Result; - - /// Write a digital value to a GPIO pin. - fn gpio_write(&self, pin: u8, value: bool) -> Result<()>; - - /// Read a memory address (for probe-rs or memory-mapped I/O). - fn memory_read(&self, address: u32, length: u32) -> Result>; - - /// Upload firmware to a connected device (Arduino sketch, STM32 binary). - fn firmware_upload(&self, path: &Path) -> Result<()>; - - /// Return a human-readable description of the connected hardware. - fn describe(&self) -> String; - - /// Set PWM duty cycle on a pin (0–100%). - fn pwm_set(&self, pin: u8, duty_percent: f32) -> Result<()>; - - /// Read an analog value (ADC) from a pin, returning 0.0–1.0. - fn analog_read(&self, pin: u8) -> Result; -} - -// ── NoopHal: used in software-only mode ───────────────────────── - -/// A no-op HAL implementation for software-only mode. -/// All hardware operations return descriptive errors. -pub struct NoopHal; - -impl HardwareHal for NoopHal { - fn gpio_read(&self, pin: u8) -> Result { - bail!("Hardware not enabled. Cannot read GPIO pin {pin}. Enable hardware in config.toml or run `zeroclaw onboard --interactive`."); - } - - fn gpio_write(&self, pin: u8, value: bool) -> Result<()> { - bail!("Hardware not enabled. Cannot write GPIO pin {pin}={value}. Enable hardware in config.toml."); - } - - fn memory_read(&self, address: u32, _length: u32) -> Result> { - bail!("Hardware not enabled. Cannot read memory at 0x{address:08X}."); - } - - fn firmware_upload(&self, path: &Path) -> Result<()> { - bail!( - "Hardware not enabled. Cannot upload firmware from {}.", - path.display() - ); - } - - fn describe(&self) -> String { - "NoopHal (software-only mode — no hardware connected)".into() - } - - fn pwm_set(&self, pin: u8, _duty_percent: f32) -> Result<()> { - bail!("Hardware not enabled. Cannot set PWM on pin {pin}."); - } - - fn analog_read(&self, pin: u8) -> Result { - bail!("Hardware not enabled. Cannot read analog pin {pin}."); - } -} - -// ── Factory: create the right HAL from config ─────────────────── - -/// Create the appropriate HAL backend from the hardware configuration. -/// -/// This is the main entry point — call this once at startup and pass -/// the resulting `Box` to the tool registry. -pub fn create_hal(config: &HardwareConfig) -> Result> { - config.validate()?; - - if !config.enabled { - return Ok(Box::new(NoopHal)); - } - - match config.transport_mode() { - HardwareTransport::None => Ok(Box::new(NoopHal)), - HardwareTransport::Native => { - // In a full implementation, this would return a RppalHal or SysfsHal. - // For now, we return a stub that validates the transport is correct. - bail!( - "Native GPIO transport requires the `rppal` crate (Raspberry Pi only). \ - This will be available in a future release. For now, use 'serial' transport \ - with an Arduino/ESP32 bridge." - ); - } - HardwareTransport::Serial => { - let port = config.serial_port.as_deref().unwrap_or("/dev/ttyUSB0"); - // In a full implementation, this would open the serial port and - // return a SerialHal that sends JSON commands over UART. - bail!( - "Serial transport to '{}' at {} baud is configured but the serial HAL \ - backend is not yet compiled in. This will be available in the next release.", - port, - config.baud_rate - ); - } - HardwareTransport::Probe => { - let target = config.probe_target.as_deref().unwrap_or("unknown"); - bail!( - "Probe transport targeting '{}' is configured but the probe-rs HAL \ - backend is not yet compiled in. This will be available in a future release.", - target - ); - } - } -} - -// ── Wizard helper: build config from discovery ────────────────── - -/// Determine the best default selection index for the wizard -/// based on discovery results. -pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { - // If we found native GPIO → recommend Native (index 0) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Native) - { - return 0; - } - // If we found serial devices → recommend Tethered (index 1) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Serial) - { - return 1; - } - // If we found debug probes → recommend Probe (index 2) - if devices - .iter() - .any(|d| d.transport == HardwareTransport::Probe) - { - return 2; - } - // Default: Software Only (index 3) - 3 -} - -/// Build a `HardwareConfig` from a wizard selection and discovered devices. -pub fn config_from_wizard_choice(choice: usize, devices: &[DiscoveredDevice]) -> HardwareConfig { - match choice { - // Native - 0 => { - let native_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Native); - HardwareConfig { - enabled: true, - transport: "native".into(), - discovered_board: native_device - .and_then(|d| d.detail.clone()) - .or_else(|| native_device.map(|d| d.name.clone())), - ..HardwareConfig::default() + let target = session.target(); + println!(); + println!("Chip: {}", target.name); + println!("Architecture: {:?}", session.architecture()); + println!(); + println!("Memory map:"); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let start = ram.range.start; + let end = ram.range.end; + let size_kb = (end - start) / 1024; + println!(" RAM: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb); } - } - // Serial / Tethered - 1 => { - let serial_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Serial); - HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: serial_device.and_then(|d| d.device_path.clone()), - discovered_board: serial_device.map(|d| d.name.clone()), - ..HardwareConfig::default() + MemoryRegion::Nvm(flash) => { + let start = flash.range.start; + let end = flash.range.end; + let size_kb = (end - start) / 1024; + println!(" Flash: 0x{:08X} - 0x{:08X} ({} KB)", start, end, size_kb); } + _ => {} } - // Probe - 2 => { - let probe_device = devices - .iter() - .find(|d| d.transport == HardwareTransport::Probe); - HardwareConfig { - enabled: true, - transport: "probe".into(), - discovered_board: probe_device.map(|d| d.name.clone()), - ..HardwareConfig::default() - } - } - // Software only - _ => HardwareConfig::default(), - } -} - -// ═══════════════════════════════════════════════════════════════════ -// ── Tests ─────────────────────────────────────────────────────── -// ═══════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod tests { - use super::*; - - // ── HardwareTransport parsing ────────────────────────────── - - #[test] - fn transport_parse_native_variants() { - assert_eq!( - HardwareTransport::from_str_loose("native"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("gpio"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("rppal"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("sysfs"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose("NATIVE"), - HardwareTransport::Native - ); - assert_eq!( - HardwareTransport::from_str_loose(" Native "), - HardwareTransport::Native - ); - } - - #[test] - fn transport_parse_serial_variants() { - assert_eq!( - HardwareTransport::from_str_loose("serial"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("uart"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("usb"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("tethered"), - HardwareTransport::Serial - ); - assert_eq!( - HardwareTransport::from_str_loose("SERIAL"), - HardwareTransport::Serial - ); - } - - #[test] - fn transport_parse_probe_variants() { - assert_eq!( - HardwareTransport::from_str_loose("probe"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("probe-rs"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("swd"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("jtag"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("jlink"), - HardwareTransport::Probe - ); - assert_eq!( - HardwareTransport::from_str_loose("j-link"), - HardwareTransport::Probe - ); - } - - #[test] - fn transport_parse_none_and_unknown() { - assert_eq!( - HardwareTransport::from_str_loose("none"), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose(""), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose("foobar"), - HardwareTransport::None - ); - assert_eq!( - HardwareTransport::from_str_loose("bluetooth"), - HardwareTransport::None - ); - } - - #[test] - fn transport_default_is_none() { - assert_eq!(HardwareTransport::default(), HardwareTransport::None); - } - - #[test] - fn transport_display() { - assert_eq!(format!("{}", HardwareTransport::Native), "native"); - assert_eq!(format!("{}", HardwareTransport::Serial), "serial"); - assert_eq!(format!("{}", HardwareTransport::Probe), "probe"); - assert_eq!(format!("{}", HardwareTransport::None), "none"); - } - - // ── HardwareTransport serde ──────────────────────────────── - - #[test] - fn transport_serde_roundtrip() { - let json = serde_json::to_string(&HardwareTransport::Native).unwrap(); - assert_eq!(json, "\"native\""); - let parsed: HardwareTransport = serde_json::from_str("\"serial\"").unwrap(); - assert_eq!(parsed, HardwareTransport::Serial); - let parsed2: HardwareTransport = serde_json::from_str("\"probe\"").unwrap(); - assert_eq!(parsed2, HardwareTransport::Probe); - let parsed3: HardwareTransport = serde_json::from_str("\"none\"").unwrap(); - assert_eq!(parsed3, HardwareTransport::None); - } - - // ── HardwareConfig defaults ──────────────────────────────── - - #[test] - fn config_default_values() { - let cfg = HardwareConfig::default(); - assert!(!cfg.enabled); - assert_eq!(cfg.transport, "none"); - assert_eq!(cfg.baud_rate, 115_200); - assert!(cfg.serial_port.is_none()); - assert!(!cfg.workspace_datasheets); - assert!(cfg.discovered_board.is_none()); - assert!(cfg.probe_target.is_none()); - assert!(cfg.allowed_pins.is_empty()); - assert_eq!(cfg.max_pwm_frequency_hz, 50_000); - } - - #[test] - fn config_transport_mode_maps_correctly() { - let mut cfg = HardwareConfig::default(); - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - - cfg.transport = "native".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Native); - - cfg.transport = "serial".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); - - cfg.transport = "probe".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Probe); - - cfg.transport = "UART".into(); - assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); - } - - // ── HardwareConfig::is_pin_allowed ───────────────────────── - - #[test] - fn pin_allowed_empty_allowlist_permits_all() { - let cfg = HardwareConfig::default(); - assert!(cfg.is_pin_allowed(0)); - assert!(cfg.is_pin_allowed(13)); - assert!(cfg.is_pin_allowed(255)); - } - - #[test] - fn pin_allowed_nonempty_allowlist_restricts() { - let cfg = HardwareConfig { - allowed_pins: vec![2, 13, 27], - ..HardwareConfig::default() - }; - assert!(cfg.is_pin_allowed(2)); - assert!(cfg.is_pin_allowed(13)); - assert!(cfg.is_pin_allowed(27)); - assert!(!cfg.is_pin_allowed(0)); - assert!(!cfg.is_pin_allowed(14)); - assert!(!cfg.is_pin_allowed(255)); - } - - #[test] - fn pin_allowed_single_pin_allowlist() { - let cfg = HardwareConfig { - allowed_pins: vec![13], - ..HardwareConfig::default() - }; - assert!(cfg.is_pin_allowed(13)); - assert!(!cfg.is_pin_allowed(12)); - assert!(!cfg.is_pin_allowed(14)); - } - - // ── HardwareConfig::validate ─────────────────────────────── - - #[test] - fn validate_disabled_always_ok() { - let cfg = HardwareConfig::default(); - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_disabled_ignores_bad_values() { - // Even with invalid values, disabled config should pass - let cfg = HardwareConfig { - enabled: false, - transport: "serial".into(), - serial_port: None, // Would fail if enabled - baud_rate: 0, // Would fail if enabled - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_serial_requires_port() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("serial_port")); - } - - #[test] - fn validate_serial_with_port_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_probe_requires_target() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - probe_target: None, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("probe_target")); - } - - #[test] - fn validate_probe_with_target_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - probe_target: Some("STM32F411CEUx".into()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_native_ok_without_extras() { - let cfg = HardwareConfig { - enabled: true, - transport: "native".into(), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_none_transport_enabled_ok() { - let cfg = HardwareConfig { - enabled: true, - transport: "none".into(), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_baud_rate_zero_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("baud_rate")); - } - - #[test] - fn validate_baud_rate_too_high_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 5_000_000, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("safety limit")); - } - - #[test] - fn validate_baud_rate_boundary_ok() { - // Exactly at the limit - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 4_000_000, - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_baud_rate_common_values_ok() { - for baud in [ - 9600, 19200, 38400, 57600, 115_200, 230_400, 460_800, 921_600, - ] { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: baud, - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok(), "baud rate {baud} should be valid"); - } - } - - #[test] - fn validate_pwm_frequency_zero_fails() { - let cfg = HardwareConfig { - enabled: true, - transport: "native".into(), - max_pwm_frequency_hz: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("max_pwm_frequency_hz")); - } - - // ── HardwareConfig serde ─────────────────────────────────── - - #[test] - fn config_serde_roundtrip_toml() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 9600, - workspace_datasheets: true, - discovered_board: Some("Arduino Uno".into()), - probe_target: None, - allowed_pins: vec![2, 13], - max_pwm_frequency_hz: 25_000, - }; - - let toml_str = toml::to_string_pretty(&cfg).unwrap(); - let parsed: HardwareConfig = toml::from_str(&toml_str).unwrap(); - - assert_eq!(parsed.enabled, cfg.enabled); - assert_eq!(parsed.transport, cfg.transport); - assert_eq!(parsed.serial_port, cfg.serial_port); - assert_eq!(parsed.baud_rate, cfg.baud_rate); - assert_eq!(parsed.workspace_datasheets, cfg.workspace_datasheets); - assert_eq!(parsed.discovered_board, cfg.discovered_board); - assert_eq!(parsed.allowed_pins, cfg.allowed_pins); - assert_eq!(parsed.max_pwm_frequency_hz, cfg.max_pwm_frequency_hz); - } - - #[test] - fn config_serde_minimal_toml() { - // Deserializing an empty TOML section should produce defaults - let toml_str = "enabled = false\n"; - let parsed: HardwareConfig = toml::from_str(toml_str).unwrap(); - assert!(!parsed.enabled); - assert_eq!(parsed.transport, "none"); - assert_eq!(parsed.baud_rate, 115_200); - } - - #[test] - fn config_serde_json_roundtrip() { - let cfg = HardwareConfig { - enabled: true, - transport: "probe".into(), - serial_port: None, - baud_rate: 115_200, - workspace_datasheets: false, - discovered_board: None, - probe_target: Some("nRF52840_xxAA".into()), - allowed_pins: vec![], - max_pwm_frequency_hz: 50_000, - }; - - let json = serde_json::to_string(&cfg).unwrap(); - let parsed: HardwareConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.probe_target, cfg.probe_target); - assert_eq!(parsed.transport, "probe"); - } - - // ── NoopHal ──────────────────────────────────────────────── - - #[test] - fn noop_hal_gpio_read_fails() { - let hal = NoopHal; - let err = hal.gpio_read(13).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("13")); - } - - #[test] - fn noop_hal_gpio_write_fails() { - let hal = NoopHal; - let err = hal.gpio_write(5, true).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - #[test] - fn noop_hal_memory_read_fails() { - let hal = NoopHal; - let err = hal.memory_read(0x2000_0000, 4).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("0x20000000")); - } - - #[test] - fn noop_hal_firmware_upload_fails() { - let hal = NoopHal; - let err = hal - .firmware_upload(Path::new("/tmp/firmware.bin")) - .unwrap_err(); - assert!(err.to_string().contains("not enabled")); - assert!(err.to_string().contains("firmware.bin")); - } - - #[test] - fn noop_hal_describe() { - let hal = NoopHal; - let desc = hal.describe(); - assert!(desc.contains("software-only")); - } - - #[test] - fn noop_hal_pwm_set_fails() { - let hal = NoopHal; - let err = hal.pwm_set(9, 50.0).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - #[test] - fn noop_hal_analog_read_fails() { - let hal = NoopHal; - let err = hal.analog_read(0).unwrap_err(); - assert!(err.to_string().contains("not enabled")); - } - - // ── create_hal factory ───────────────────────────────────── - - #[test] - fn create_hal_disabled_returns_noop() { - let cfg = HardwareConfig::default(); - let hal = create_hal(&cfg).unwrap(); - assert!(hal.describe().contains("software-only")); - } - - #[test] - fn create_hal_none_transport_returns_noop() { - let cfg = HardwareConfig { - enabled: true, - transport: "none".into(), - ..HardwareConfig::default() - }; - let hal = create_hal(&cfg).unwrap(); - assert!(hal.describe().contains("software-only")); - } - - #[test] - fn create_hal_serial_without_port_fails_validation() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - ..HardwareConfig::default() - }; - assert!(create_hal(&cfg).is_err()); - } - - #[test] - fn create_hal_invalid_baud_fails_validation() { - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some("/dev/ttyUSB0".into()), - baud_rate: 0, - ..HardwareConfig::default() - }; - assert!(create_hal(&cfg).is_err()); - } - - // ── Discovery helpers ────────────────────────────────────── - - #[test] - fn classify_serial_arduino() { - let path = Path::new("/dev/tty.usbmodem14201"); - assert!(classify_serial_device(path).contains("Arduino")); - } - - #[test] - fn classify_serial_ftdi() { - let path = Path::new("/dev/tty.usbserial-1234"); - assert!(classify_serial_device(path).contains("FTDI")); - } - - #[test] - fn classify_serial_ch340() { - let path = Path::new("/dev/tty.wchusbserial1420"); - assert!(classify_serial_device(path).contains("CH340")); - } - - #[test] - fn classify_serial_ttyacm() { - let path = Path::new("/dev/ttyACM0"); - assert!(classify_serial_device(path).contains("CDC")); - } - - #[test] - fn classify_serial_ttyusb() { - let path = Path::new("/dev/ttyUSB0"); - assert!(classify_serial_device(path).contains("USB-Serial")); - } - - #[test] - fn classify_serial_unknown() { - let path = Path::new("/dev/ttyXYZ99"); - assert!(classify_serial_device(path).contains("Unknown")); - } - - // ── Serial device path patterns ──────────────────────────── - - #[test] - fn serial_paths_macos_patterns() { - if cfg!(target_os = "macos") { - let patterns = serial_device_paths(); - assert!(patterns.iter().any(|p| p.contains("usbmodem"))); - assert!(patterns.iter().any(|p| p.contains("usbserial"))); - assert!(patterns.iter().any(|p| p.contains("wchusbserial"))); - } - } - - #[test] - fn serial_paths_linux_patterns() { - if cfg!(target_os = "linux") { - let patterns = serial_device_paths(); - assert!(patterns.iter().any(|p| p.contains("ttyUSB"))); - assert!(patterns.iter().any(|p| p.contains("ttyACM"))); - } - } - - // ── Wizard helpers ───────────────────────────────────────── - - #[test] - fn recommended_default_no_devices() { - let devices: Vec = vec![]; - assert_eq!(recommended_wizard_default(&devices), 3); // Software only - } - - #[test] - fn recommended_default_native_found() { - let devices = vec![DiscoveredDevice { - name: "Raspberry Pi (Native GPIO)".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 0); // Native - } - - #[test] - fn recommended_default_serial_found() { - let devices = vec![DiscoveredDevice { - name: "Arduino (USB Serial)".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 1); // Tethered - } - - #[test] - fn recommended_default_probe_found() { - let devices = vec![DiscoveredDevice { - name: "ST-Link (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: None, - detail: None, - }]; - assert_eq!(recommended_wizard_default(&devices), 2); // Probe - } - - #[test] - fn recommended_default_native_priority_over_serial() { - // When both native and serial are found, native wins - let devices = vec![ - DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }, - DiscoveredDevice { - name: "RPi GPIO".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }, - ]; - assert_eq!(recommended_wizard_default(&devices), 0); // Native wins - } - - #[test] - fn config_from_wizard_native() { - let devices = vec![DiscoveredDevice { - name: "Raspberry Pi 4 (Native GPIO)".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: Some("Raspberry Pi 4 Model B Rev 1.5".into()), - }]; - - let cfg = config_from_wizard_choice(0, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "native"); - assert_eq!( - cfg.discovered_board.as_deref(), - Some("Raspberry Pi 4 Model B Rev 1.5") - ); - } - - #[test] - fn config_from_wizard_serial() { - let devices = vec![DiscoveredDevice { - name: "Arduino Uno (USB Serial)".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(1, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "serial"); - assert_eq!(cfg.serial_port.as_deref(), Some("/dev/ttyUSB0")); - } - - #[test] - fn config_from_wizard_probe() { - let devices = vec![DiscoveredDevice { - name: "ST-Link (SWD)".into(), - transport: HardwareTransport::Probe, - device_path: Some("/dev/stlinkv2".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(2, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "probe"); - } - - #[test] - fn config_from_wizard_software_only() { - let devices: Vec = vec![]; - let cfg = config_from_wizard_choice(3, &devices); - assert!(!cfg.enabled); - assert_eq!(cfg.transport, "none"); - } - - #[test] - fn config_from_wizard_serial_no_serial_device_found() { - // User picks serial but no serial device was discovered - let devices = vec![DiscoveredDevice { - name: "RPi GPIO".into(), - transport: HardwareTransport::Native, - device_path: Some("/dev/gpiomem".into()), - detail: None, - }]; - - let cfg = config_from_wizard_choice(1, &devices); - assert!(cfg.enabled); - assert_eq!(cfg.transport, "serial"); - assert!(cfg.serial_port.is_none()); // Will need manual config later - } - - #[test] - fn config_from_wizard_out_of_bounds_defaults_to_software() { - let devices: Vec = vec![]; - let cfg = config_from_wizard_choice(99, &devices); - assert!(!cfg.enabled); - } - - // ── Discovery function runs without panicking ────────────── - - #[test] - fn discover_hardware_does_not_panic() { - // Should never panic regardless of the platform - let devices = discover_hardware(); - // We can't assert what's found (platform-dependent) but it should not crash - assert!(devices.len() < 100); // Sanity check - } - - // ── DiscoveredDevice equality ────────────────────────────── - - #[test] - fn discovered_device_equality() { - let d1 = DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }; - let d2 = d1.clone(); - assert_eq!(d1, d2); - } - - #[test] - fn discovered_device_inequality() { - let d1 = DiscoveredDevice { - name: "Arduino".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB0".into()), - detail: None, - }; - let d2 = DiscoveredDevice { - name: "ESP32".into(), - transport: HardwareTransport::Serial, - device_path: Some("/dev/ttyUSB1".into()), - detail: None, - }; - assert_ne!(d1, d2); - } - - // ── Edge cases ───────────────────────────────────────────── - - #[test] - fn config_with_all_pins_in_allowlist() { - let cfg = HardwareConfig { - allowed_pins: (0..=255).collect(), - ..HardwareConfig::default() - }; - // Every pin should be allowed - for pin in 0..=255u8 { - assert!(cfg.is_pin_allowed(pin)); - } - } - - #[test] - fn config_transport_unknown_string() { - let cfg = HardwareConfig { - transport: "quantum_bus".into(), - ..HardwareConfig::default() - }; - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - } - - #[test] - fn config_transport_empty_string() { - let cfg = HardwareConfig { - transport: String::new(), - ..HardwareConfig::default() - }; - assert_eq!(cfg.transport_mode(), HardwareTransport::None); - } - - #[test] - fn validate_serial_empty_port_string_treated_as_set() { - // An empty string is still Some(""), which passes the None check - // but the serial backend would fail at open time — that's acceptable - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: Some(String::new()), - ..HardwareConfig::default() - }; - assert!(cfg.validate().is_ok()); - } - - #[test] - fn validate_multiple_errors_first_wins() { - // Serial with no port AND zero baud — the port error should surface first - let cfg = HardwareConfig { - enabled: true, - transport: "serial".into(), - serial_port: None, - baud_rate: 0, - ..HardwareConfig::default() - }; - let err = cfg.validate().unwrap_err(); - assert!(err.to_string().contains("serial_port")); } + println!(); + println!("Info read via USB (SWD) — no firmware on target needed."); + Ok(()) } diff --git a/src/hardware/registry.rs b/src/hardware/registry.rs new file mode 100644 index 0000000..aac15f2 --- /dev/null +++ b/src/hardware/registry.rs @@ -0,0 +1,102 @@ +//! Board registry — maps USB VID/PID to known board names and architectures. + +/// Information about a known board. +#[derive(Debug, Clone)] +pub struct BoardInfo { + pub vid: u16, + pub pid: u16, + pub name: &'static str, + pub architecture: Option<&'static str>, +} + +/// Known USB VID/PID to board mappings. +/// VID 0x0483 = STMicroelectronics, 0x2341 = Arduino, 0x10c4 = Silicon Labs. +const KNOWN_BOARDS: &[BoardInfo] = &[ + BoardInfo { + vid: 0x0483, + pid: 0x374b, + name: "nucleo-f401re", + architecture: Some("ARM Cortex-M4"), + }, + BoardInfo { + vid: 0x0483, + pid: 0x3748, + name: "nucleo-f411re", + architecture: Some("ARM Cortex-M4"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0043, + name: "arduino-uno", + architecture: Some("AVR ATmega328P"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0078, + name: "arduino-uno", + architecture: Some("Arduino Uno Q / ATmega328P"), + }, + BoardInfo { + vid: 0x2341, + pid: 0x0042, + name: "arduino-mega", + architecture: Some("AVR ATmega2560"), + }, + BoardInfo { + vid: 0x10c4, + pid: 0xea60, + name: "cp2102", + architecture: Some("USB-UART bridge"), + }, + BoardInfo { + vid: 0x10c4, + pid: 0xea70, + name: "cp2102n", + architecture: Some("USB-UART bridge"), + }, + // ESP32 dev boards often use CH340 USB-UART + BoardInfo { + vid: 0x1a86, + pid: 0x7523, + name: "esp32", + architecture: Some("ESP32 (CH340)"), + }, + BoardInfo { + vid: 0x1a86, + pid: 0x55d4, + name: "esp32", + architecture: Some("ESP32 (CH340)"), + }, +]; + +/// Look up a board by VID and PID. +pub fn lookup_board(vid: u16, pid: u16) -> Option<&'static BoardInfo> { + KNOWN_BOARDS.iter().find(|b| b.vid == vid && b.pid == pid) +} + +/// Return all known board entries. +pub fn known_boards() -> &'static [BoardInfo] { + KNOWN_BOARDS +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_nucleo_f401re() { + let b = lookup_board(0x0483, 0x374b).unwrap(); + assert_eq!(b.name, "nucleo-f401re"); + assert_eq!(b.architecture, Some("ARM Cortex-M4")); + } + + #[test] + fn lookup_unknown_returns_none() { + assert!(lookup_board(0x0000, 0x0000).is_none()); + } + + #[test] + fn known_boards_not_empty() { + assert!(!known_boards().is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 588ada3..cfde7a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,9 @@ pub mod memory; pub mod migration; pub mod observability; pub mod onboard; +pub mod peripherals; pub mod providers; +pub mod rag; pub mod runtime; pub mod security; pub mod service; @@ -182,74 +184,48 @@ pub enum IntegrationCommands { }, } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn service_commands_serde_roundtrip() { - let command = ServiceCommands::Status; - let json = serde_json::to_string(&command).unwrap(); - let parsed: ServiceCommands = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, ServiceCommands::Status); - } - - #[test] - fn channel_commands_struct_variants_roundtrip() { - let add = ChannelCommands::Add { - channel_type: "telegram".into(), - config: "{}".into(), - }; - let remove = ChannelCommands::Remove { - name: "main".into(), - }; - - let add_json = serde_json::to_string(&add).unwrap(); - let remove_json = serde_json::to_string(&remove).unwrap(); - - let parsed_add: ChannelCommands = serde_json::from_str(&add_json).unwrap(); - let parsed_remove: ChannelCommands = serde_json::from_str(&remove_json).unwrap(); - - assert_eq!(parsed_add, add); - assert_eq!(parsed_remove, remove); - } - - #[test] - fn commands_with_payloads_roundtrip() { - let skill = SkillCommands::Install { - source: "https://example.com/skill".into(), - }; - let migrate = MigrateCommands::Openclaw { - source: Some(std::path::PathBuf::from("/tmp/openclaw")), - dry_run: true, - }; - let cron = CronCommands::Add { - expression: "*/5 * * * *".into(), - command: "echo hi".into(), - }; - let integration = IntegrationCommands::Info { - name: "Telegram".into(), - }; - - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&skill).unwrap()).unwrap(), - skill - ); - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&migrate).unwrap()) - .unwrap(), - migrate - ); - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&cron).unwrap()).unwrap(), - cron - ); - assert_eq!( - serde_json::from_str::( - &serde_json::to_string(&integration).unwrap() - ) - .unwrap(), - integration - ); - } +/// Hardware discovery subcommands +#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HardwareCommands { + /// Enumerate USB devices (VID/PID) and show known boards + Discover, + /// Introspect a device by path (e.g. /dev/ttyACM0) + Introspect { + /// Serial or device path + path: String, + }, + /// Get chip info via USB (probe-rs over ST-Link). No firmware needed on target. + Info { + /// Chip name (e.g. STM32F401RETx). Default: STM32F401RETx for Nucleo-F401RE + #[arg(long, default_value = "STM32F401RETx")] + chip: String, + }, +} + +/// Peripheral (hardware) management subcommands +#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum PeripheralCommands { + /// List configured peripherals + List, + /// Add a peripheral (board path, e.g. nucleo-f401re /dev/ttyACM0) + Add { + /// Board type (nucleo-f401re, rpi-gpio, esp32) + board: String, + /// Path for serial transport (/dev/ttyACM0) or "native" for local GPIO + path: String, + }, + /// Flash ZeroClaw firmware to Arduino (creates .ino, installs arduino-cli if needed, uploads) + Flash { + /// Serial port (e.g. /dev/cu.usbmodem12345). If omitted, uses first arduino-uno from config. + #[arg(short, long)] + port: Option, + }, + /// Setup Arduino Uno Q Bridge app (deploy GPIO bridge for agent control) + SetupUnoQ { + /// Uno Q IP (e.g. 192.168.0.48). If omitted, assumes running ON the Uno Q. + #[arg(long)] + host: Option, + }, + /// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run) + FlashNucleo, } diff --git a/src/main.rs b/src/main.rs index 478ce41..b12bc06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,9 @@ use tracing_subscriber::FmtSubscriber; mod agent; mod channels; +mod rag { + pub use zeroclaw::rag::*; +} mod config; mod cron; mod daemon; @@ -53,6 +56,7 @@ mod memory; mod migration; mod observability; mod onboard; +mod peripherals; mod providers; mod runtime; mod security; @@ -65,6 +69,9 @@ mod util; use config::Config; +// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc. +pub use zeroclaw::{HardwareCommands, PeripheralCommands}; + /// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust. #[derive(Parser, Debug)] #[command(name = "zeroclaw")] @@ -133,9 +140,9 @@ enum Commands { #[arg(short, long, default_value = "0.7")] temperature: f64, - /// Print user-facing progress lines via observer (`>` send, `<` receive/complete). + /// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0) #[arg(long)] - verbose: bool, + peripheral: Vec, }, /// Start the gateway server (webhooks, websockets) @@ -207,6 +214,18 @@ enum Commands { #[command(subcommand)] migrate_command: MigrateCommands, }, + + /// Discover and introspect USB hardware + Hardware { + #[command(subcommand)] + hardware_command: zeroclaw::HardwareCommands, + }, + + /// Manage hardware peripherals (STM32, RPi GPIO, etc.) + Peripheral { + #[command(subcommand)] + peripheral_command: zeroclaw::PeripheralCommands, + }, } #[derive(Subcommand, Debug)] @@ -380,8 +399,8 @@ async fn main() -> Result<()> { provider, model, temperature, - verbose, - } => agent::run(config, message, provider, model, temperature, verbose).await, + peripheral, + } => agent::run(config, message, provider, model, temperature, peripheral).await, Commands::Gateway { port, host } => { if port == 0 { @@ -466,6 +485,17 @@ async fn main() -> Result<()> { } ); } + println!(); + println!("Peripherals:"); + println!( + " Enabled: {}", + if config.peripherals.enabled { + "yes" + } else { + "no" + } + ); + println!(" Boards: {}", config.peripherals.boards.len()); Ok(()) } @@ -499,6 +529,14 @@ async fn main() -> Result<()> { Commands::Migrate { migrate_command } => { migration::handle_command(migrate_command, &config).await } + + Commands::Hardware { hardware_command } => { + hardware::handle_command(hardware_command.clone(), &config) + } + + Commands::Peripheral { peripheral_command } => { + peripherals::handle_command(peripheral_command.clone(), &config) + } } } diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 77dbe3b..13ed3a8 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -125,10 +125,11 @@ pub fn run_wizard() -> Result { browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), - cost: crate::config::schema::CostConfig::default(), - hardware: hardware_config, + cost: crate::config::CostConfig::default(), + peripherals: crate::config::PeripheralsConfig::default(), + agent: crate::config::AgentConfig::default(), agents: std::collections::HashMap::new(), - security: crate::config::SecurityConfig::default(), + hardware: hardware_config, }; println!( @@ -328,10 +329,11 @@ pub fn run_quick_setup( browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), - cost: crate::config::schema::CostConfig::default(), - hardware: HardwareConfig::default(), + cost: crate::config::CostConfig::default(), + peripherals: crate::config::PeripheralsConfig::default(), + agent: crate::config::AgentConfig::default(), agents: std::collections::HashMap::new(), - security: crate::config::SecurityConfig::default(), + hardware: crate::config::HardwareConfig::default(), }; config.save()?; @@ -2328,18 +2330,27 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — reqwest::blocking Response + // must be used and dropped there to avoid "Cannot drop a runtime" panic) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - let url = format!("https://api.telegram.org/bot{token}/getMe"); - match client.get(&url).send() { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let bot_name = data - .get("result") - .and_then(|r| r.get("username")) - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let url = format!("https://api.telegram.org/bot{token_clone}/getMe"); + let resp = client.get(&url).send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let bot_name = data + .get("result") + .and_then(|r| r.get("username")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, bot_name)) + }) + .join(); + match thread_result { + Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as @{bot_name} ", style("✅").green().bold() @@ -2412,20 +2423,27 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get("https://discord.com/api/v10/users/@me") - .header("Authorization", format!("Bot {token}")) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let bot_name = data - .get("username") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://discord.com/api/v10/users/@me") + .header("Authorization", format!("Bot {token_clone}")) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let bot_name = data + .get("username") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, bot_name)) + }) + .join(); + match thread_result { + Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as {bot_name} ", style("✅").green().bold() @@ -2504,37 +2522,44 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get("https://slack.com/api/auth.test") - .bearer_auth(&token) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let ok = data - .get("ok") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let team = data - .get("team") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); - if ok { - println!( - "\r {} Connected to workspace: {team} ", - style("✅").green().bold() - ); - } else { - let err = data - .get("error") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown error"); - println!("\r {} Slack error: {err}", style("❌").red().bold()); - continue; - } + let token_clone = token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get("https://slack.com/api/auth.test") + .bearer_auth(&token_clone) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let api_ok = data + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let team = data + .get("team") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + let err = data + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown error") + .to_string(); + Ok::<_, reqwest::Error>((ok, api_ok, team, err)) + }) + .join(); + match thread_result { + Ok(Ok((true, true, team, _))) => { + println!( + "\r {} Connected to workspace: {team} ", + style("✅").green().bold() + ); + } + Ok(Ok((true, false, _, err))) => { + println!("\r {} Slack error: {err}", style("❌").red().bold()); + continue; } _ => { println!( @@ -2673,21 +2698,29 @@ fn setup_channels() -> Result { continue; } - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) let hs = homeserver.trim_end_matches('/'); print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - match client - .get(format!("{hs}/_matrix/client/v3/account/whoami")) - .header("Authorization", format!("Bearer {access_token}")) - .send() - { - Ok(resp) if resp.status().is_success() => { - let data: serde_json::Value = resp.json().unwrap_or_default(); - let user_id = data - .get("user_id") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); + let hs_owned = hs.to_string(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let resp = client + .get(format!("{hs_owned}/_matrix/client/v3/account/whoami")) + .header("Authorization", format!("Bearer {access_token_clone}")) + .send()?; + let ok = resp.status().is_success(); + let data: serde_json::Value = resp.json().unwrap_or_default(); + let user_id = data + .get("user_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + Ok::<_, reqwest::Error>((ok, user_id)) + }) + .join(); + match thread_result { + Ok(Ok((true, user_id))) => { println!( "\r {} Connected as {user_id} ", style("✅").green().bold() @@ -2761,19 +2794,28 @@ fn setup_channels() -> Result { .default("zeroclaw-whatsapp-verify".into()) .interact_text()?; - // Test connection + // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); - let client = reqwest::blocking::Client::new(); - let url = format!( - "https://graph.facebook.com/v18.0/{}", - phone_number_id.trim() - ); - match client - .get(&url) - .header("Authorization", format!("Bearer {}", access_token.trim())) - .send() - { - Ok(resp) if resp.status().is_success() => { + let phone_number_id_clone = phone_number_id.clone(); + let access_token_clone = access_token.clone(); + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::new(); + let url = format!( + "https://graph.facebook.com/v18.0/{}", + phone_number_id_clone.trim() + ); + let resp = client + .get(&url) + .header( + "Authorization", + format!("Bearer {}", access_token_clone.trim()), + ) + .send()?; + Ok::<_, reqwest::Error>(resp.status().is_success()) + }) + .join(); + match thread_result { + Ok(Ok(true)) => { println!( "\r {} Connected to WhatsApp API ", style("✅").green().bold() diff --git a/src/peripherals/arduino_flash.rs b/src/peripherals/arduino_flash.rs new file mode 100644 index 0000000..8aaf287 --- /dev/null +++ b/src/peripherals/arduino_flash.rs @@ -0,0 +1,144 @@ +//! Flash ZeroClaw Arduino firmware via arduino-cli. +//! +//! Ensures arduino-cli is available (installs via brew on macOS if missing), +//! installs the AVR core, compiles and uploads the base firmware. + +use anyhow::{Context, Result}; +use std::process::Command; + +/// ZeroClaw Arduino Uno base firmware (capabilities, gpio_read, gpio_write). +const FIRMWARE_INO: &str = include_str!("../../firmware/zeroclaw-arduino/zeroclaw-arduino.ino"); + +const FQBN: &str = "arduino:avr:uno"; +const SKETCH_NAME: &str = "zeroclaw-arduino"; + +/// Check if arduino-cli is available. +pub fn arduino_cli_available() -> bool { + Command::new("arduino-cli") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Try to install arduino-cli. Returns Ok(()) if installed or already present. +pub fn ensure_arduino_cli() -> Result<()> { + if arduino_cli_available() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + println!("arduino-cli not found. Installing via Homebrew..."); + let status = Command::new("brew") + .args(["install", "arduino-cli"]) + .status() + .context("Failed to run brew install")?; + if !status.success() { + anyhow::bail!("brew install arduino-cli failed. Install manually: https://arduino.github.io/arduino-cli/"); + } + println!("arduino-cli installed."); + } + + #[cfg(target_os = "linux")] + { + println!("arduino-cli not found. Run the install script:"); + println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh"); + println!(); + println!("Or install via package manager (e.g. apt install arduino-cli on Debian/Ubuntu)."); + anyhow::bail!("arduino-cli not installed. Install it and try again."); + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + println!("arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/"); + anyhow::bail!("arduino-cli not installed."); + } + + if !arduino_cli_available() { + anyhow::bail!("arduino-cli still not found after install. Ensure it's in PATH."); + } + Ok(()) +} + +/// Ensure arduino:avr core is installed. +fn ensure_avr_core() -> Result<()> { + let out = Command::new("arduino-cli") + .args(["core", "list"]) + .output() + .context("arduino-cli core list failed")?; + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.contains("arduino:avr") { + return Ok(()); + } + + println!("Installing Arduino AVR core..."); + let status = Command::new("arduino-cli") + .args(["core", "install", "arduino:avr"]) + .status() + .context("arduino-cli core install failed")?; + if !status.success() { + anyhow::bail!("Failed to install arduino:avr core"); + } + println!("AVR core installed."); + Ok(()) +} + +/// Flash ZeroClaw firmware to Arduino at the given port. +pub fn flash_arduino_firmware(port: &str) -> Result<()> { + ensure_arduino_cli()?; + ensure_avr_core()?; + + let temp_dir = std::env::temp_dir().join(format!("zeroclaw_flash_{}", uuid::Uuid::new_v4())); + let sketch_dir = temp_dir.join(SKETCH_NAME); + let ino_path = sketch_dir.join(format!("{}.ino", SKETCH_NAME)); + + std::fs::create_dir_all(&sketch_dir).context("Failed to create sketch dir")?; + std::fs::write(&ino_path, FIRMWARE_INO).context("Failed to write firmware")?; + + let sketch_path = sketch_dir.to_string_lossy(); + + // Compile + println!("Compiling ZeroClaw Arduino firmware..."); + let compile = Command::new("arduino-cli") + .args(["compile", "--fqbn", FQBN, &*sketch_path]) + .output() + .context("arduino-cli compile failed")?; + + if !compile.status.success() { + let stderr = String::from_utf8_lossy(&compile.stderr); + let _ = std::fs::remove_dir_all(&temp_dir); + anyhow::bail!("Compile failed:\n{}", stderr); + } + + // Upload + println!("Uploading to {}...", port); + let upload = Command::new("arduino-cli") + .args(["upload", "-p", port, "--fqbn", FQBN, &*sketch_path]) + .output() + .context("arduino-cli upload failed")?; + + let _ = std::fs::remove_dir_all(&temp_dir); + + if !upload.status.success() { + let stderr = String::from_utf8_lossy(&upload.stderr); + anyhow::bail!("Upload failed:\n{}\n\nEnsure the board is connected and the port is correct (e.g. /dev/cu.usbmodem* on macOS).", stderr); + } + + println!("ZeroClaw firmware flashed successfully."); + println!("The Arduino now supports: capabilities, gpio_read, gpio_write."); + Ok(()) +} + +/// Resolve port from config or path. Returns the path to use for flashing. +pub fn resolve_port(config: &crate::config::Config, path_override: Option<&str>) -> Option { + if let Some(p) = path_override { + return Some(p.to_string()); + } + config + .peripherals + .boards + .iter() + .find(|b| b.board == "arduino-uno" && b.transport == "serial") + .and_then(|b| b.path.clone()) +} diff --git a/src/peripherals/arduino_upload.rs b/src/peripherals/arduino_upload.rs new file mode 100644 index 0000000..e11b19f --- /dev/null +++ b/src/peripherals/arduino_upload.rs @@ -0,0 +1,161 @@ +//! Arduino upload tool — agent generates code, uploads via arduino-cli. +//! +//! When user says "make a heart on the LED grid", the agent generates Arduino +//! sketch code and calls this tool. ZeroClaw compiles and uploads it — no +//! manual IDE or file editing. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::process::Command; + +/// Tool: upload Arduino sketch (agent-generated code) to the board. +pub struct ArduinoUploadTool { + /// Serial port path (e.g. /dev/cu.usbmodem33000283452) + pub port: String, +} + +impl ArduinoUploadTool { + pub fn new(port: String) -> Self { + Self { port } + } +} + +#[async_trait] +impl Tool for ArduinoUploadTool { + fn name(&self) -> &str { + "arduino_upload" + } + + fn description(&self) -> &str { + "Generate Arduino sketch code and upload it to the connected Arduino. Use when: user asks to 'make a heart', 'blink LED', or run any custom pattern on Arduino. You MUST write the full .ino sketch code (setup + loop). Arduino Uno: pin 13 = built-in LED. Saves to temp dir, runs arduino-cli compile and upload. Requires arduino-cli installed." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Full Arduino sketch code (complete .ino file content)" + } + }, + "required": ["code"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let code = args + .get("code") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?; + + if code.trim().is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("Code cannot be empty".into()), + }); + } + + // Check arduino-cli exists + if Command::new("arduino-cli").arg("version").output().is_err() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/" + .into(), + ), + }); + } + + let sketch_name = "zeroclaw_sketch"; + let temp_dir = std::env::temp_dir().join(format!("zeroclaw_{}", uuid::Uuid::new_v4())); + let sketch_dir = temp_dir.join(sketch_name); + let ino_path = sketch_dir.join(format!("{}.ino", sketch_name)); + + if let Err(e) = std::fs::create_dir_all(&sketch_dir) { + return Ok(ToolResult { + success: false, + output: format!("Failed to create sketch dir: {}", e), + error: Some(e.to_string()), + }); + } + + if let Err(e) = std::fs::write(&ino_path, code) { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("Failed to write sketch: {}", e), + error: Some(e.to_string()), + }); + } + + let sketch_path = sketch_dir.to_string_lossy(); + let fqbn = "arduino:avr:uno"; + + // Compile + let compile = Command::new("arduino-cli") + .args(["compile", "--fqbn", fqbn, &sketch_path]) + .output(); + + let compile_output = match compile { + Ok(o) => o, + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("arduino-cli compile failed: {}", e), + error: Some(e.to_string()), + }); + } + }; + + if !compile_output.status.success() { + let stderr = String::from_utf8_lossy(&compile_output.stderr); + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("Compile failed:\n{}", stderr), + error: Some("Arduino compile error".into()), + }); + } + + // Upload + let upload = Command::new("arduino-cli") + .args(["upload", "-p", &self.port, "--fqbn", fqbn, &sketch_path]) + .output(); + + let upload_output = match upload { + Ok(o) => o, + Err(e) => { + let _ = std::fs::remove_dir_all(&temp_dir); + return Ok(ToolResult { + success: false, + output: format!("arduino-cli upload failed: {}", e), + error: Some(e.to_string()), + }); + } + }; + + let _ = std::fs::remove_dir_all(&temp_dir); + + if !upload_output.status.success() { + let stderr = String::from_utf8_lossy(&upload_output.stderr); + return Ok(ToolResult { + success: false, + output: format!("Upload failed:\n{}", stderr), + error: Some("Arduino upload error".into()), + }); + } + + Ok(ToolResult { + success: true, + output: + "Sketch compiled and uploaded successfully. The Arduino is now running your code." + .into(), + error: None, + }) + } +} diff --git a/src/peripherals/capabilities_tool.rs b/src/peripherals/capabilities_tool.rs new file mode 100644 index 0000000..c3fca4f --- /dev/null +++ b/src/peripherals/capabilities_tool.rs @@ -0,0 +1,99 @@ +//! Hardware capabilities tool — Phase C: query device for reported GPIO pins. + +use super::serial::SerialTransport; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Tool: query device capabilities (GPIO pins, LED pin) from firmware. +pub struct HardwareCapabilitiesTool { + /// (board_name, transport) for each serial board. + boards: Vec<(String, Arc)>, +} + +impl HardwareCapabilitiesTool { + pub(crate) fn new(boards: Vec<(String, Arc)>) -> Self { + Self { boards } + } +} + +#[async_trait] +impl Tool for HardwareCapabilitiesTool { + fn name(&self) -> &str { + "hardware_capabilities" + } + + fn description(&self) -> &str { + "Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name. If omitted, queries all." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let filter = args.get("board").and_then(|v| v.as_str()); + let mut outputs = Vec::new(); + + for (board_name, transport) in &self.boards { + if let Some(b) = filter { + if b != board_name { + continue; + } + } + match transport.capabilities().await { + Ok(result) => { + let output = if result.success { + if let Ok(parsed) = + serde_json::from_str::(&result.output) + { + format!( + "{}: gpio {:?}, led_pin {:?}", + board_name, + parsed.get("gpio").unwrap_or(&json!([])), + parsed.get("led_pin").unwrap_or(&json!(null)) + ) + } else { + format!("{}: {}", board_name, result.output) + } + } else { + format!( + "{}: {}", + board_name, + result.error.as_deref().unwrap_or("unknown") + ) + }; + outputs.push(output); + } + Err(e) => { + outputs.push(format!("{}: error - {}", board_name, e)); + } + } + } + + let output = if outputs.is_empty() { + if filter.is_some() { + "No matching board or capabilities not supported.".to_string() + } else { + "No serial boards configured or capabilities not supported.".to_string() + } + } else { + outputs.join("\n") + }; + + Ok(ToolResult { + success: !outputs.is_empty(), + output, + error: None, + }) + } +} diff --git a/src/peripherals/mod.rs b/src/peripherals/mod.rs new file mode 100644 index 0000000..6084cab --- /dev/null +++ b/src/peripherals/mod.rs @@ -0,0 +1,231 @@ +//! Hardware peripherals — STM32, RPi GPIO, etc. +//! +//! Peripherals extend the agent with physical capabilities. See +//! `docs/hardware-peripherals-design.md` for the full design. + +pub mod traits; + +#[cfg(feature = "hardware")] +pub mod serial; + +#[cfg(feature = "hardware")] +pub mod arduino_flash; +#[cfg(feature = "hardware")] +pub mod arduino_upload; +#[cfg(feature = "hardware")] +pub mod capabilities_tool; +#[cfg(feature = "hardware")] +pub mod nucleo_flash; +#[cfg(feature = "hardware")] +pub mod uno_q_bridge; +#[cfg(feature = "hardware")] +pub mod uno_q_setup; + +#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] +pub mod rpi; + +pub use traits::Peripheral; + +use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig}; +use crate::tools::{HardwareMemoryMapTool, Tool}; +use anyhow::Result; + +/// List configured boards from config (no connection yet). +pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> { + if !config.enabled { + return Vec::new(); + } + config.boards.iter().collect() +} + +/// Handle `zeroclaw peripheral` subcommands. +#[allow(clippy::module_name_repetitions)] +pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> { + match cmd { + crate::PeripheralCommands::List => { + let boards = list_configured_boards(&config.peripherals); + if boards.is_empty() { + println!("No peripherals configured."); + println!(); + println!("Add one with: zeroclaw peripheral add "); + println!(" Example: zeroclaw peripheral add nucleo-f401re /dev/ttyACM0"); + println!(); + println!("Or add to config.toml:"); + println!(" [peripherals]"); + println!(" enabled = true"); + println!(); + println!(" [[peripherals.boards]]"); + println!(" board = \"nucleo-f401re\""); + println!(" transport = \"serial\""); + println!(" path = \"/dev/ttyACM0\""); + } else { + println!("Configured peripherals:"); + for b in boards { + let path = b.path.as_deref().unwrap_or("(native)"); + println!(" {} {} {}", b.board, b.transport, path); + } + } + } + crate::PeripheralCommands::Add { board, path } => { + let transport = if path == "native" { "native" } else { "serial" }; + let path_opt = if path == "native" { + None + } else { + Some(path.clone()) + }; + + let mut cfg = crate::config::Config::load_or_init()?; + cfg.peripherals.enabled = true; + + if cfg + .peripherals + .boards + .iter() + .any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref()) + { + println!("Board {} at {:?} already configured.", board, path_opt); + return Ok(()); + } + + cfg.peripherals.boards.push(PeripheralBoardConfig { + board: board.clone(), + transport: transport.to_string(), + path: path_opt, + baud: 115200, + }); + cfg.save()?; + println!("Added {} at {}. Restart daemon to apply.", board, path); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::Flash { port } => { + let port_str = arduino_flash::resolve_port(config, port.as_deref()) + .or_else(|| port.clone()) + .ok_or_else(|| anyhow::anyhow!( + "No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml" + ))?; + arduino_flash::flash_arduino_firmware(&port_str)?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::Flash { .. } => { + println!("Arduino flash requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::SetupUnoQ { host } => { + uno_q_setup::setup_uno_q_bridge(host.as_deref())?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::SetupUnoQ { .. } => { + println!("Uno Q setup requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] + crate::PeripheralCommands::FlashNucleo => { + nucleo_flash::flash_nucleo_firmware()?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::FlashNucleo => { + println!("Nucleo flash requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + } + Ok(()) +} + +/// Create and connect peripherals from config, returning their tools. +/// Returns empty vec if peripherals disabled or hardware feature off. +#[cfg(feature = "hardware")] +pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result>> { + if !config.enabled || config.boards.is_empty() { + return Ok(Vec::new()); + } + + let mut tools: Vec> = Vec::new(); + let mut serial_transports: Vec<(String, std::sync::Arc)> = Vec::new(); + + for board in &config.boards { + // Arduino Uno Q: Bridge transport (socket to local Bridge app) + if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q") + { + tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool)); + tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool)); + tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added"); + continue; + } + + // Native transport: RPi GPIO (Linux only) + #[cfg(all(feature = "peripheral-rpi", target_os = "linux"))] + if board.transport == "native" + && (board.board == "rpi-gpio" || board.board == "raspberry-pi") + { + match rpi::RpiGpioPeripheral::connect_from_config(board).await { + Ok(peripheral) => { + tools.extend(peripheral.tools()); + tracing::info!(board = %board.board, "RPi GPIO peripheral connected"); + } + Err(e) => { + tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e); + } + } + continue; + } + + // Serial transport (STM32, ESP32, Arduino, etc.) + if board.transport != "serial" { + continue; + } + if board.path.is_none() { + tracing::warn!("Skipping serial board {}: no path", board.board); + continue; + } + + match serial::SerialPeripheral::connect(board).await { + Ok(peripheral) => { + let mut p = peripheral; + if p.connect().await.is_err() { + tracing::warn!("Peripheral {} connect warning (continuing)", p.name()); + } + serial_transports.push((board.board.clone(), p.transport())); + tools.extend(p.tools()); + if board.board == "arduino-uno" { + if let Some(ref path) = board.path { + tools.push(Box::new(arduino_upload::ArduinoUploadTool::new( + path.clone(), + ))); + tracing::info!("Arduino upload tool added (port: {})", path); + } + } + tracing::info!(board = %board.board, "Serial peripheral connected"); + } + Err(e) => { + tracing::warn!("Failed to connect {}: {}", board.board, e); + } + } + } + + // Phase B: Add hardware tools when any boards configured + if !tools.is_empty() { + let board_names: Vec = config.boards.iter().map(|b| b.board.clone()).collect(); + tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone()))); + tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new( + board_names.clone(), + ))); + tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new( + board_names, + ))); + } + + // Phase C: Add hardware_capabilities tool when any serial boards + if !serial_transports.is_empty() { + tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new( + serial_transports, + ))); + } + + Ok(tools) +} + +#[cfg(not(feature = "hardware"))] +pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result>> { + Ok(Vec::new()) +} diff --git a/src/peripherals/nucleo_flash.rs b/src/peripherals/nucleo_flash.rs new file mode 100644 index 0000000..5558872 --- /dev/null +++ b/src/peripherals/nucleo_flash.rs @@ -0,0 +1,83 @@ +//! Flash ZeroClaw Nucleo-F401RE firmware via probe-rs. +//! +//! Builds the Embassy firmware and flashes via ST-Link (built into Nucleo). +//! Requires: cargo install probe-rs-tools --locked + +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::process::Command; + +const CHIP: &str = "STM32F401RETx"; +const TARGET: &str = "thumbv7em-none-eabihf"; + +/// Check if probe-rs CLI is available (from probe-rs-tools). +pub fn probe_rs_available() -> bool { + Command::new("probe-rs") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Flash ZeroClaw Nucleo firmware. Builds from firmware/zeroclaw-nucleo. +pub fn flash_nucleo_firmware() -> Result<()> { + if !probe_rs_available() { + anyhow::bail!( + "probe-rs not found. Install it:\n cargo install probe-rs-tools --locked\n\n\ + Or: curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh\n\n\ + Connect Nucleo via USB (ST-Link). Then run this command again." + ); + } + + // CARGO_MANIFEST_DIR = repo root (zeroclaw's Cargo.toml) + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let firmware_dir = repo_root.join("firmware").join("zeroclaw-nucleo"); + if !firmware_dir.join("Cargo.toml").exists() { + anyhow::bail!( + "Nucleo firmware not found at {}. Run from zeroclaw repo root.", + firmware_dir.display() + ); + } + + println!("Building ZeroClaw Nucleo firmware..."); + let build = Command::new("cargo") + .args(["build", "--release", "--target", TARGET]) + .current_dir(&firmware_dir) + .output() + .context("cargo build failed")?; + + if !build.status.success() { + let stderr = String::from_utf8_lossy(&build.stderr); + anyhow::bail!("Build failed:\n{}", stderr); + } + + let elf_path = firmware_dir + .join("target") + .join(TARGET) + .join("release") + .join("zeroclaw-nucleo"); + + if !elf_path.exists() { + anyhow::bail!("Built binary not found at {}", elf_path.display()); + } + + println!("Flashing to Nucleo-F401RE (connect via USB)..."); + let flash = Command::new("probe-rs") + .args(["run", "--chip", CHIP, elf_path.to_str().unwrap()]) + .output() + .context("probe-rs run failed")?; + + if !flash.status.success() { + let stderr = String::from_utf8_lossy(&flash.stderr); + anyhow::bail!( + "Flash failed:\n{}\n\n\ + Ensure Nucleo is connected via USB. The ST-Link is built into the board.", + stderr + ); + } + + println!("ZeroClaw Nucleo firmware flashed successfully."); + println!("The Nucleo now supports: ping, capabilities, gpio_read, gpio_write."); + println!("Add to config.toml: board = \"nucleo-f401re\", transport = \"serial\", path = \"/dev/ttyACM0\""); + Ok(()) +} diff --git a/src/peripherals/rpi.rs b/src/peripherals/rpi.rs new file mode 100644 index 0000000..6cea075 --- /dev/null +++ b/src/peripherals/rpi.rs @@ -0,0 +1,173 @@ +//! Raspberry Pi GPIO peripheral — native rppal access. +//! +//! Only compiled when `peripheral-rpi` feature is enabled and target is Linux. +//! Uses BCM pin numbering (e.g. GPIO 17, 27). + +use crate::config::PeripheralBoardConfig; +use crate::peripherals::traits::Peripheral; +use crate::tools::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// RPi GPIO peripheral — direct access via rppal. +pub struct RpiGpioPeripheral { + board: PeripheralBoardConfig, +} + +impl RpiGpioPeripheral { + /// Create a new RPi GPIO peripheral from config. + pub fn new(board: PeripheralBoardConfig) -> Self { + Self { board } + } + + /// Attempt to connect (init rppal). Returns Ok if GPIO is available. + pub async fn connect_from_config(board: &PeripheralBoardConfig) -> anyhow::Result { + let mut peripheral = Self::new(board.clone()); + peripheral.connect().await?; + Ok(peripheral) + } +} + +#[async_trait] +impl Peripheral for RpiGpioPeripheral { + fn name(&self) -> &str { + &self.board.board + } + + fn board_type(&self) -> &str { + "rpi-gpio" + } + + async fn connect(&mut self) -> anyhow::Result<()> { + // Verify GPIO is accessible by doing a no-op init + let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??; + drop(result); + Ok(()) + } + + async fn disconnect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new().is_ok()) + .await + .unwrap_or(false) + } + + fn tools(&self) -> Vec> { + vec![Box::new(RpiGpioReadTool), Box::new(RpiGpioWriteTool)] + } +} + +/// Tool: read GPIO pin value (BCM numbering). +struct RpiGpioReadTool; + +#[async_trait] +impl Tool for RpiGpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the value (0 or 1) of a GPIO pin on Raspberry Pi. Uses BCM pin numbers (e.g. 17, 27)." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO pin number (e.g. 17, 27)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let pin_u8 = pin as u8; + + let value = tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let pin = gpio.get(pin_u8)?.into_input(); + Ok::<_, anyhow::Error>(match pin.read() { + rppal::gpio::Level::Low => 0, + rppal::gpio::Level::High => 1, + }) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("pin {} = {}", pin, value), + error: None, + }) + } +} + +/// Tool: write GPIO pin value (BCM numbering). +struct RpiGpioWriteTool; + +#[async_trait] +impl Tool for RpiGpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin high (1) or low (0) on Raspberry Pi. Uses BCM pin numbers." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "BCM GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + let pin_u8 = pin as u8; + let level = match value { + 0 => rppal::gpio::Level::Low, + _ => rppal::gpio::Level::High, + }; + + tokio::task::spawn_blocking(move || { + let gpio = rppal::gpio::Gpio::new()?; + let mut pin = gpio.get(pin_u8)?.into_output(); + pin.write(level); + Ok::<_, anyhow::Error>(()) + }) + .await??; + + Ok(ToolResult { + success: true, + output: format!("pin {} = {}", pin, value), + error: None, + }) + } +} diff --git a/src/peripherals/serial.rs b/src/peripherals/serial.rs new file mode 100644 index 0000000..ab40d71 --- /dev/null +++ b/src/peripherals/serial.rs @@ -0,0 +1,274 @@ +//! Serial peripheral — STM32 and similar boards over USB CDC/serial. +//! +//! Protocol: newline-delimited JSON. +//! Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}} +//! Response: {"id":"1","ok":true,"result":"done"} + +use super::traits::Peripheral; +use crate::config::PeripheralBoardConfig; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +/// Allowed serial path patterns (security: deny arbitrary paths). +const ALLOWED_PATH_PREFIXES: &[&str] = &[ + "/dev/ttyACM", + "/dev/ttyUSB", + "/dev/tty.usbmodem", + "/dev/cu.usbmodem", + "/dev/tty.usbserial", + "/dev/cu.usbserial", // Arduino Uno (FTDI), clones + "COM", // Windows +]; + +fn is_path_allowed(path: &str) -> bool { + ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p)) +} + +/// JSON request/response over serial. +async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow::Result { + static ID: AtomicU64 = AtomicU64::new(0); + let id = ID.fetch_add(1, Ordering::Relaxed); + let id_str = id.to_string(); + + let req = json!({ + "id": id_str, + "cmd": cmd, + "args": args + }); + let line = format!("{}\n", req); + + port.write_all(line.as_bytes()).await?; + port.flush().await?; + + let mut buf = Vec::new(); + let mut b = [0u8; 1]; + while port.read_exact(&mut b).await.is_ok() { + if b[0] == b'\n' { + break; + } + buf.push(b[0]); + } + let line_str = String::from_utf8_lossy(&buf); + let resp: Value = serde_json::from_str(line_str.trim())?; + let resp_id = resp["id"].as_str().unwrap_or(""); + if resp_id != id_str { + anyhow::bail!("Response id mismatch: expected {}, got {}", id_str, resp_id); + } + Ok(resp) +} + +/// Shared serial transport for tools. Pub(crate) for capabilities tool. +pub(crate) struct SerialTransport { + port: Mutex, +} + +/// Timeout for serial request/response (seconds). +const SERIAL_TIMEOUT_SECS: u64 = 5; + +impl SerialTransport { + async fn request(&self, cmd: &str, args: Value) -> anyhow::Result { + let mut port = self.port.lock().await; + let resp = tokio::time::timeout( + std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS), + send_request(&mut *port, cmd, args), + ) + .await + .map_err(|_| { + anyhow::anyhow!("Serial request timed out after {}s", SERIAL_TIMEOUT_SECS) + })??; + + let ok = resp["ok"].as_bool().unwrap_or(false); + let result = resp["result"] + .as_str() + .map(String::from) + .unwrap_or_else(|| resp["result"].to_string()); + let error = resp["error"].as_str().map(String::from); + + Ok(ToolResult { + success: ok, + output: result, + error, + }) + } + + /// Phase C: fetch capabilities from device (gpio pins, led_pin). + pub async fn capabilities(&self) -> anyhow::Result { + self.request("capabilities", json!({})).await + } +} + +/// Serial peripheral for STM32, Arduino, etc. over USB CDC. +pub struct SerialPeripheral { + name: String, + board_type: String, + transport: Arc, +} + +impl SerialPeripheral { + /// Create and connect to a serial peripheral. + pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result { + let path = config + .path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Serial peripheral requires path"))?; + + if !is_path_allowed(path) { + anyhow::bail!( + "Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*", + path + ); + } + + let port = tokio_serial::new(path, config.baud) + .open_native_async() + .map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path, e))?; + + let name = format!("{}-{}", config.board, path.replace('/', "_")); + let transport = Arc::new(SerialTransport { + port: Mutex::new(port), + }); + + Ok(Self { + name: name.clone(), + board_type: config.board.clone(), + transport, + }) + } +} + +#[async_trait] +impl Peripheral for SerialPeripheral { + fn name(&self) -> &str { + &self.name + } + + fn board_type(&self) -> &str { + &self.board_type + } + + async fn connect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn disconnect(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn health_check(&self) -> bool { + self.transport + .request("ping", json!({})) + .await + .map(|r| r.success) + .unwrap_or(false) + } + + fn tools(&self) -> Vec> { + vec![ + Box::new(GpioReadTool { + transport: self.transport.clone(), + }), + Box::new(GpioWriteTool { + transport: self.transport.clone(), + }), + ] + } +} + +impl SerialPeripheral { + /// Expose transport for capabilities tool (Phase C). + pub(crate) fn transport(&self) -> Arc { + self.transport.clone() + } +} + +/// Tool: read GPIO pin value. +struct GpioReadTool { + transport: Arc, +} + +#[async_trait] +impl Tool for GpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number (e.g. 13 for LED on Nucleo)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + self.transport + .request("gpio_read", json!({ "pin": pin })) + .await + } +} + +/// Tool: write GPIO pin value. +struct GpioWriteTool { + transport: Arc, +} + +#[async_trait] +impl Tool for GpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set a GPIO pin high (1) or low (0) on a connected peripheral (e.g. turn on/off LED)" + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + self.transport + .request("gpio_write", json!({ "pin": pin, "value": value })) + .await + } +} diff --git a/src/peripherals/traits.rs b/src/peripherals/traits.rs new file mode 100644 index 0000000..6081d1d --- /dev/null +++ b/src/peripherals/traits.rs @@ -0,0 +1,33 @@ +//! Peripheral trait — hardware boards (STM32, RPi GPIO) that expose tools. +//! +//! Peripherals are the agent's "arms and legs": remote devices that run minimal +//! firmware and expose capabilities (GPIO, sensors, actuators) as tools. + +use async_trait::async_trait; + +use crate::tools::Tool; + +/// A hardware peripheral that exposes capabilities as tools. +/// +/// Implement this for boards like Nucleo-F401RE (serial), RPi GPIO (native), etc. +/// When connected, the peripheral's tools are merged into the agent's tool registry. +#[async_trait] +pub trait Peripheral: Send + Sync { + /// Human-readable peripheral name (e.g. "nucleo-f401re-0") + fn name(&self) -> &str; + + /// Board type identifier (e.g. "nucleo-f401re", "rpi-gpio") + fn board_type(&self) -> &str; + + /// Connect to the peripheral (open serial, init GPIO, etc.) + async fn connect(&mut self) -> anyhow::Result<()>; + + /// Disconnect and release resources + async fn disconnect(&mut self) -> anyhow::Result<()>; + + /// Check if the peripheral is reachable and responsive + async fn health_check(&self) -> bool; + + /// Tools this peripheral provides (e.g. gpio_read, gpio_write, sensor_read) + fn tools(&self) -> Vec>; +} diff --git a/src/peripherals/uno_q_bridge.rs b/src/peripherals/uno_q_bridge.rs new file mode 100644 index 0000000..a621831 --- /dev/null +++ b/src/peripherals/uno_q_bridge.rs @@ -0,0 +1,151 @@ +//! Arduino Uno Q Bridge — GPIO via socket to Bridge app. +//! +//! When ZeroClaw runs on Uno Q, the Bridge app (Python + MCU) exposes +//! digitalWrite/digitalRead over a local socket. These tools connect to it. + +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +const BRIDGE_HOST: &str = "127.0.0.1"; +const BRIDGE_PORT: u16 = 9999; + +async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result { + let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT); + let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr)) + .await + .map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??; + + let msg = format!("{} {}\n", cmd, args.join(" ")); + stream.write_all(msg.as_bytes()).await?; + + let mut buf = vec![0u8; 64]; + let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf)) + .await + .map_err(|_| anyhow::anyhow!("Bridge response timed out"))??; + let resp = String::from_utf8_lossy(&buf[..n]).trim().to_string(); + Ok(resp) +} + +/// Tool: read GPIO pin via Uno Q Bridge. +pub struct UnoQGpioReadTool; + +#[async_trait] +impl Tool for UnoQGpioReadTool { + fn name(&self) -> &str { + "gpio_read" + } + + fn description(&self) -> &str { + "Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number (e.g. 13 for LED)" + } + }, + "required": ["pin"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + match bridge_request("gpio_read", &[pin.to_string()]).await { + Ok(resp) => { + if resp.starts_with("error:") { + Ok(ToolResult { + success: false, + output: resp.clone(), + error: Some(resp), + }) + } else { + Ok(ToolResult { + success: true, + output: resp, + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }), + } + } +} + +/// Tool: write GPIO pin via Uno Q Bridge. +pub struct UnoQGpioWriteTool; + +#[async_trait] +impl Tool for UnoQGpioWriteTool { + fn name(&self) -> &str { + "gpio_write" + } + + fn description(&self) -> &str { + "Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "GPIO pin number" + }, + "value": { + "type": "integer", + "description": "0 for low, 1 for high" + } + }, + "required": ["pin", "value"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let value = args + .get("value") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; + match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await { + Ok(resp) => { + if resp.starts_with("error:") { + Ok(ToolResult { + success: false, + output: resp.clone(), + error: Some(resp), + }) + } else { + Ok(ToolResult { + success: true, + output: "done".into(), + error: None, + }) + } + } + Err(e) => Ok(ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }), + } + } +} diff --git a/src/peripherals/uno_q_setup.rs b/src/peripherals/uno_q_setup.rs new file mode 100644 index 0000000..3b7d114 --- /dev/null +++ b/src/peripherals/uno_q_setup.rs @@ -0,0 +1,143 @@ +//! Deploy ZeroClaw Bridge app to Arduino Uno Q. + +use anyhow::{Context, Result}; +use std::process::Command; + +const BRIDGE_APP_NAME: &str = "zeroclaw-uno-q-bridge"; + +/// Deploy the Bridge app. If host is Some, scp from repo and ssh to start. +/// If host is None, assume we're ON the Uno Q — use embedded files and start. +pub fn setup_uno_q_bridge(host: Option<&str>) -> Result<()> { + let bridge_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("firmware") + .join("zeroclaw-uno-q-bridge"); + + if let Some(h) = host { + if bridge_dir.exists() { + deploy_remote(h, &bridge_dir)?; + } else { + anyhow::bail!( + "Bridge app not found at {}. Run from zeroclaw repo root.", + bridge_dir.display() + ); + } + } else { + deploy_local(if bridge_dir.exists() { + Some(&bridge_dir) + } else { + None + })?; + } + Ok(()) +} + +fn deploy_remote(host: &str, bridge_dir: &std::path::Path) -> Result<()> { + let ssh_target = if host.contains('@') { + host.to_string() + } else { + format!("arduino@{}", host) + }; + + println!("Copying Bridge app to {}...", host); + let status = Command::new("ssh") + .args([&ssh_target, "mkdir", "-p", "~/ArduinoApps"]) + .status() + .context("ssh mkdir failed")?; + if !status.success() { + anyhow::bail!("Failed to create ArduinoApps dir on Uno Q"); + } + + let status = Command::new("scp") + .args([ + "-r", + bridge_dir.to_str().unwrap(), + &format!("{}:~/ArduinoApps/", ssh_target), + ]) + .status() + .context("scp failed")?; + if !status.success() { + anyhow::bail!("Failed to copy Bridge app"); + } + + println!("Starting Bridge app on Uno Q..."); + let status = Command::new("ssh") + .args([ + &ssh_target, + "arduino-app-cli", + "app", + "start", + &format!("~/ArduinoApps/zeroclaw-uno-q-bridge"), + ]) + .status() + .context("arduino-app-cli start failed")?; + if !status.success() { + anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q."); + } + + println!("ZeroClaw Bridge app started. Add to config.toml:"); + println!(" [[peripherals.boards]]"); + println!(" board = \"arduino-uno-q\""); + println!(" transport = \"bridge\""); + Ok(()) +} + +fn deploy_local(bridge_dir: Option<&std::path::Path>) -> Result<()> { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/arduino".into()); + let apps_dir = std::path::Path::new(&home).join("ArduinoApps"); + let dest_dir = apps_dir.join(BRIDGE_APP_NAME); + + std::fs::create_dir_all(&dest_dir).context("create dest dir")?; + + if let Some(src) = bridge_dir { + println!("Copying Bridge app from repo..."); + copy_dir(src, &dest_dir)?; + } else { + println!("Writing embedded Bridge app..."); + write_embedded_bridge(&dest_dir)?; + } + + println!("Starting Bridge app..."); + let status = Command::new("arduino-app-cli") + .args(["app", "start", dest_dir.to_str().unwrap()]) + .status() + .context("arduino-app-cli start failed")?; + if !status.success() { + anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q."); + } + + println!("ZeroClaw Bridge app started."); + Ok(()) +} + +fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> { + let app_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/app.yaml"); + let sketch_ino = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino"); + let sketch_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml"); + let main_py = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/main.py"); + let requirements = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/requirements.txt"); + + std::fs::write(dest.join("app.yaml"), app_yaml)?; + std::fs::create_dir_all(dest.join("sketch"))?; + std::fs::write(dest.join("sketch").join("sketch.ino"), sketch_ino)?; + std::fs::write(dest.join("sketch").join("sketch.yaml"), sketch_yaml)?; + std::fs::create_dir_all(dest.join("python"))?; + std::fs::write(dest.join("python").join("main.py"), main_py)?; + std::fs::write(dest.join("python").join("requirements.txt"), requirements)?; + Ok(()) +} + +fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> { + for entry in std::fs::read_dir(src)? { + let e = entry?; + let name = e.file_name(); + let src_path = src.join(&name); + let dst_path = dst.join(&name); + if e.file_type()?.is_dir() { + std::fs::create_dir_all(&dst_path)?; + copy_dir(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} diff --git a/src/providers/compatible.rs b/src/providers/compatible.rs index 4c59992..e9e39e1 100644 --- a/src/providers/compatible.rs +++ b/src/providers/compatible.rs @@ -15,6 +15,9 @@ pub struct OpenAiCompatibleProvider { pub(crate) base_url: String, pub(crate) api_key: Option, pub(crate) auth_header: AuthStyle, + /// When false, do not fall back to /v1/responses on chat completions 404. + /// GLM/Zhipu does not support the responses API. + supports_responses_fallback: bool, client: Client, } @@ -36,6 +39,29 @@ impl OpenAiCompatibleProvider { base_url: base_url.trim_end_matches('/').to_string(), api_key: api_key.map(ToString::to_string), auth_header: auth_style, + supports_responses_fallback: true, + client: Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + /// Same as `new` but skips the /v1/responses fallback on 404. + /// Use for providers (e.g. GLM) that only support chat completions. + pub fn new_no_responses_fallback( + name: &str, + base_url: &str, + api_key: Option<&str>, + auth_style: AuthStyle, + ) -> Self { + Self { + name: name.to_string(), + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.map(ToString::to_string), + auth_header: auth_style, + supports_responses_fallback: false, client: Client::builder() .timeout(std::time::Duration::from_secs(120)) .connect_timeout(std::time::Duration::from_secs(10)) @@ -112,6 +138,8 @@ struct ChatRequest { model: String, messages: Vec, temperature: f64, + #[serde(skip_serializing_if = "Option::is_none")] + stream: Option, } #[derive(Debug, Serialize)] @@ -348,6 +376,7 @@ impl Provider for OpenAiCompatibleProvider { model: model.to_string(), messages, temperature, + stream: Some(false), }; let url = self.chat_completions_url(); @@ -362,7 +391,7 @@ impl Provider for OpenAiCompatibleProvider { let error = response.text().await?; let sanitized = super::sanitize_api_error(&error); - if status == reqwest::StatusCode::NOT_FOUND { + if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { return self .chat_via_responses(api_key, system_prompt, message, model) .await @@ -413,6 +442,7 @@ impl Provider for OpenAiCompatibleProvider { model: model.to_string(), messages: api_messages, temperature, + stream: Some(false), }; let url = self.chat_completions_url(); @@ -425,7 +455,7 @@ impl Provider for OpenAiCompatibleProvider { let status = response.status(); // Mirror chat_with_system: 404 may mean this provider uses the Responses API - if status == reqwest::StatusCode::NOT_FOUND { + if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { // Extract system prompt and last user message for responses fallback let system = messages.iter().find(|m| m.role == "system"); let last_user = messages.iter().rfind(|m| m.role == "user"); @@ -517,7 +547,8 @@ mod tests { content: "hello".to_string(), }, ], - temperature: 0.7, + temperature: 0.4, + stream: Some(false), }; let json = serde_json::to_string(&req).unwrap(); assert!(json.contains("llama-3.3-70b")); diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 1808499..ca4eaa4 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -217,8 +217,8 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result Ok(Box::new(OpenAiCompatibleProvider::new( "Z.AI", "https://api.z.ai/api/coding/paas/v4", key, AuthStyle::Bearer, ))), - "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new( - "GLM", "https://open.bigmodel.cn/api/paas/v4", key, AuthStyle::Bearer, + "glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback( + "GLM", "https://api.z.ai/api/paas/v4", key, AuthStyle::Bearer, ))), "minimax" => Ok(Box::new(OpenAiCompatibleProvider::new( "MiniMax", diff --git a/src/rag/mod.rs b/src/rag/mod.rs new file mode 100644 index 0000000..cc98c5a --- /dev/null +++ b/src/rag/mod.rs @@ -0,0 +1,397 @@ +//! RAG pipeline for hardware datasheet retrieval. +//! +//! Supports: +//! - Markdown and text datasheets (always) +//! - PDF ingestion (with `rag-pdf` feature) +//! - Pin/alias tables (e.g. `red_led: 13`) for explicit lookup +//! - Keyword retrieval (default) or semantic search via embeddings (optional) + +use crate::memory::chunker; +use std::collections::HashMap; +use std::path::Path; + +/// A chunk of datasheet content with board metadata. +#[derive(Debug, Clone)] +pub struct DatasheetChunk { + /// Board this chunk applies to (e.g. "nucleo-f401re", "rpi-gpio"), or None for generic. + pub board: Option, + /// Source file path (for debugging). + pub source: String, + /// Chunk content. + pub content: String, +} + +/// Pin alias: human-readable name → pin number (e.g. "red_led" → 13). +pub type PinAliases = HashMap; + +/// Parse pin aliases from markdown. Looks for: +/// - `## Pin Aliases` section with `alias: pin` lines +/// - Markdown table `| alias | pin |` +fn parse_pin_aliases(content: &str) -> PinAliases { + let mut aliases = PinAliases::new(); + let content_lower = content.to_lowercase(); + + // Find ## Pin Aliases section + let section_markers = ["## pin aliases", "## pin alias", "## pins"]; + let mut in_section = false; + let mut section_start = 0; + + for marker in section_markers { + if let Some(pos) = content_lower.find(marker) { + in_section = true; + section_start = pos + marker.len(); + break; + } + } + + if !in_section { + return aliases; + } + + let rest = &content[section_start..]; + let section_end = rest + .find("\n## ") + .map(|i| section_start + i) + .unwrap_or(content.len()); + let section = &content[section_start..section_end]; + + // Parse "alias: pin" or "alias = pin" lines + for line in section.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Table row: | red_led | 13 | (skip header | alias | pin | and separator |---|) + if line.starts_with('|') { + let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect(); + if parts.len() >= 3 { + let alias = parts[1].trim().to_lowercase().replace(' ', "_"); + let pin_str = parts[2].trim(); + // Skip header row and separator (|---|) + if alias.eq("alias") + || alias.eq("pin") + || pin_str.eq("pin") + || alias.contains("---") + || pin_str.contains("---") + { + continue; + } + if let Ok(pin) = pin_str.parse::() { + if !alias.is_empty() { + aliases.insert(alias, pin); + } + } + } + continue; + } + // Key: value + if let Some((k, v)) = line.split_once(':').or_else(|| line.split_once('=')) { + let alias = k.trim().to_lowercase().replace(' ', "_"); + if let Ok(pin) = v.trim().parse::() { + if !alias.is_empty() { + aliases.insert(alias, pin); + } + } + } + } + + aliases +} + +fn collect_md_txt_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_md_txt_paths(&path, out); + } else if path.is_file() { + let ext = path.extension().and_then(|e| e.to_str()); + if ext == Some("md") || ext == Some("txt") { + out.push(path); + } + } + } +} + +#[cfg(feature = "rag-pdf")] +fn collect_pdf_paths(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_pdf_paths(&path, out); + } else if path.is_file() { + if path.extension().and_then(|e| e.to_str()) == Some("pdf") { + out.push(path); + } + } + } +} + +#[cfg(feature = "rag-pdf")] +fn extract_pdf_text(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + pdf_extract::extract_text_from_mem(&bytes).ok() +} + +/// Hardware RAG index — loads and retrieves datasheet chunks. +pub struct HardwareRag { + chunks: Vec, + /// Per-board pin aliases (board -> alias -> pin). + pin_aliases: HashMap, +} + +impl HardwareRag { + /// Load datasheets from a directory. Expects .md, .txt, and optionally .pdf (with rag-pdf). + /// Filename (without extension) is used as board tag. + /// Supports `## Pin Aliases` section for explicit alias→pin mapping. + pub fn load(workspace_dir: &Path, datasheet_dir: &str) -> anyhow::Result { + let base = workspace_dir.join(datasheet_dir); + if !base.exists() || !base.is_dir() { + return Ok(Self { + chunks: Vec::new(), + pin_aliases: HashMap::new(), + }); + } + + let mut paths: Vec = Vec::new(); + collect_md_txt_paths(&base, &mut paths); + #[cfg(feature = "rag-pdf")] + collect_pdf_paths(&base, &mut paths); + + let mut chunks = Vec::new(); + let mut pin_aliases: HashMap = HashMap::new(); + let max_tokens = 512; + + for path in paths { + let content = if path.extension().and_then(|e| e.to_str()) == Some("pdf") { + #[cfg(feature = "rag-pdf")] + { + extract_pdf_text(&path).unwrap_or_default() + } + #[cfg(not(feature = "rag-pdf"))] + { + String::new() + } + } else { + std::fs::read_to_string(&path).unwrap_or_default() + }; + + if content.trim().is_empty() { + continue; + } + + let board = infer_board_from_path(&path, &base); + let source = path + .strip_prefix(workspace_dir) + .unwrap_or(&path) + .display() + .to_string(); + + // Parse pin aliases from full content + let aliases = parse_pin_aliases(&content); + if let Some(ref b) = board { + if !aliases.is_empty() { + pin_aliases.insert(b.clone(), aliases); + } + } + + for chunk in chunker::chunk_markdown(&content, max_tokens) { + chunks.push(DatasheetChunk { + board: board.clone(), + source: source.clone(), + content: chunk.content, + }); + } + } + + Ok(Self { + chunks, + pin_aliases, + }) + } + + /// Get pin aliases for a board (e.g. "red_led" -> 13). + pub fn pin_aliases_for_board(&self, board: &str) -> Option<&PinAliases> { + self.pin_aliases.get(board) + } + + /// Build pin-alias context for query. When user says "red led", inject "red_led: 13" for matching boards. + pub fn pin_alias_context(&self, query: &str, boards: &[String]) -> String { + let query_lower = query.to_lowercase(); + let query_words: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 1) + .collect(); + + let mut lines = Vec::new(); + for board in boards { + if let Some(aliases) = self.pin_aliases.get(board) { + for (alias, pin) in aliases { + let alias_words: Vec<&str> = alias.split('_').collect(); + let matches = query_words + .iter() + .any(|qw| alias_words.iter().any(|aw| *aw == *qw)) + || query_lower.contains(&alias.replace('_', " ")); + if matches { + lines.push(format!("{board}: {alias} = pin {pin}")); + } + } + } + } + if lines.is_empty() { + return String::new(); + } + format!("[Pin aliases for query]\n{}\n\n", lines.join("\n")) + } + + /// Retrieve chunks relevant to the query and boards. + /// Uses keyword matching and board filter. Pin-alias context is built separately via `pin_alias_context`. + pub fn retrieve(&self, query: &str, boards: &[String], limit: usize) -> Vec<&DatasheetChunk> { + if self.chunks.is_empty() || limit == 0 { + return Vec::new(); + } + + let query_lower = query.to_lowercase(); + let query_terms: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 2) + .collect(); + + let mut scored: Vec<(&DatasheetChunk, f32)> = Vec::new(); + for chunk in &self.chunks { + let content_lower = chunk.content.to_lowercase(); + let mut score = 0.0f32; + + for term in &query_terms { + if content_lower.contains(term) { + score += 1.0; + } + } + + if score > 0.0 { + let board_match = chunk.board.as_ref().map_or(false, |b| boards.contains(b)); + if board_match { + score += 2.0; + } + scored.push((chunk, score)); + } + } + + scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(limit); + scored.into_iter().map(|(c, _)| c).collect() + } + + /// Number of indexed chunks. + pub fn len(&self) -> usize { + self.chunks.len() + } + + /// True if no chunks are indexed. + pub fn is_empty(&self) -> bool { + self.chunks.is_empty() + } +} + +/// Infer board tag from file path. `nucleo-f401re.md` → Some("nucleo-f401re"). +fn infer_board_from_path(path: &Path, base: &Path) -> Option { + let rel = path.strip_prefix(base).ok()?; + let stem = path.file_stem()?.to_str()?; + + if stem == "generic" || stem.starts_with("generic_") { + return None; + } + if rel.parent().and_then(|p| p.to_str()) == Some("_generic") { + return None; + } + + Some(stem.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pin_aliases_key_value() { + let md = r#"## Pin Aliases +red_led: 13 +builtin_led: 13 +user_led: 5"#; + let a = parse_pin_aliases(md); + assert_eq!(a.get("red_led"), Some(&13)); + assert_eq!(a.get("builtin_led"), Some(&13)); + assert_eq!(a.get("user_led"), Some(&5)); + } + + #[test] + fn parse_pin_aliases_table() { + let md = r#"## Pin Aliases +| alias | pin | +|-------|-----| +| red_led | 13 | +| builtin_led | 13 |"#; + let a = parse_pin_aliases(md); + assert_eq!(a.get("red_led"), Some(&13)); + assert_eq!(a.get("builtin_led"), Some(&13)); + } + + #[test] + fn parse_pin_aliases_empty() { + let a = parse_pin_aliases("No aliases here"); + assert!(a.is_empty()); + } + + #[test] + fn infer_board_from_path_nucleo() { + let base = std::path::Path::new("/base"); + let path = std::path::Path::new("/base/nucleo-f401re.md"); + assert_eq!( + infer_board_from_path(path, base), + Some("nucleo-f401re".into()) + ); + } + + #[test] + fn infer_board_generic_none() { + let base = std::path::Path::new("/base"); + let path = std::path::Path::new("/base/generic.md"); + assert_eq!(infer_board_from_path(path, base), None); + } + + #[test] + fn hardware_rag_load_and_retrieve() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("datasheets"); + std::fs::create_dir_all(&base).unwrap(); + let content = r#"# Test Board +## Pin Aliases +red_led: 13 +## GPIO +Pin 13: LED +"#; + std::fs::write(base.join("test-board.md"), content).unwrap(); + + let rag = HardwareRag::load(tmp.path(), "datasheets").unwrap(); + assert!(!rag.is_empty()); + let boards = vec!["test-board".to_string()]; + let chunks = rag.retrieve("led", &boards, 5); + assert!(!chunks.is_empty()); + let ctx = rag.pin_alias_context("red led", &boards); + assert!(ctx.contains("13")); + } + + #[test] + fn hardware_rag_load_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join("empty_ds"); + std::fs::create_dir_all(&base).unwrap(); + let rag = HardwareRag::load(tmp.path(), "empty_ds").unwrap(); + assert!(rag.is_empty()); + } +} diff --git a/src/tools/hardware_board_info.rs b/src/tools/hardware_board_info.rs new file mode 100644 index 0000000..f7af262 --- /dev/null +++ b/src/tools/hardware_board_info.rs @@ -0,0 +1,205 @@ +//! Hardware board info tool — returns chip name, architecture, memory map for Telegram/agent. +//! +//! Use when user asks "what board do I have?", "board info", "connected hardware", etc. +//! Uses probe-rs for Nucleo when available; otherwise static datasheet info. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Static board info (datasheets). Used when probe-rs is unavailable. +const BOARD_INFO: &[(&str, &str, &str)] = &[ + ( + "nucleo-f401re", + "STM32F401RET6", + "ARM Cortex-M4, 84 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).", + ), + ( + "nucleo-f411re", + "STM32F411RET6", + "ARM Cortex-M4, 100 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).", + ), + ( + "arduino-uno", + "ATmega328P", + "8-bit AVR, 16 MHz. Flash: 16 KB, SRAM: 2 KB. Built-in LED on pin 13.", + ), + ( + "arduino-uno-q", + "STM32U585 + Qualcomm", + "Dual-core: STM32 (MCU) + Linux (aarch64). GPIO via Bridge app on port 9999.", + ), + ( + "esp32", + "ESP32", + "Dual-core Xtensa LX6, 240 MHz. Flash: 4 MB typical. Built-in LED on GPIO 2.", + ), + ( + "rpi-gpio", + "Raspberry Pi", + "ARM Linux. Native GPIO via sysfs/rppal. No fixed LED pin.", + ), +]; + +/// Tool: return full board info (chip, architecture, memory map) for agent/Telegram. +pub struct HardwareBoardInfoTool { + boards: Vec, +} + +impl HardwareBoardInfoTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn static_info_for_board(&self, board: &str) -> Option { + BOARD_INFO + .iter() + .find(|(b, _, _)| *b == board) + .map(|(_, chip, desc)| { + format!( + "**Board:** {}\n**Chip:** {}\n**Description:** {}", + board, chip, desc + ) + }) + } +} + +#[async_trait] +impl Tool for HardwareBoardInfoTool { + fn name(&self) -> &str { + "hardware_board_info" + } + + fn description(&self) -> &str { + "Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name (e.g. nucleo-f401re). If omitted, returns info for first configured board." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()); + + let board = board.as_deref().unwrap_or("unknown"); + + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add boards to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let mut output = String::new(); + + #[cfg(feature = "probe")] + if board == "nucleo-f401re" || board == "nucleo-f411re" { + let chip = if board == "nucleo-f411re" { + "STM32F411RETx" + } else { + "STM32F401RETx" + }; + match probe_board_info(chip) { + Ok(info) => { + return Ok(ToolResult { + success: true, + output: info, + error: None, + }); + } + Err(e) => { + output.push_str(&format!( + "probe-rs attach failed: {}. Using static info.\n\n", + e + )); + } + } + } + + if let Some(info) = self.static_info_for_board(board) { + output.push_str(&info); + if let Some(mem) = memory_map_static(board) { + output.push_str(&format!("\n\n**Memory map:**\n{}", mem)); + } + } else { + output.push_str(&format!( + "Board '{}' configured. No static info available.", + board + )); + } + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +#[cfg(feature = "probe")] +fn probe_board_info(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let target = session.target(); + let arch = session.architecture(); + + let mut out = format!( + "**Board:** {}\n**Chip:** {}\n**Architecture:** {:?}\n\n**Memory map:**\n", + chip, target.name, arch + ); + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let (start, end) = (ram.range.start, ram.range.end); + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + MemoryRegion::Nvm(flash) => { + let (start, end) = (flash.range.start, flash.range.end); + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, + end, + (end - start) / 1024 + )); + } + _ => {} + } + } + out.push_str("\n(Info read via USB/SWD — no firmware on target needed.)"); + Ok(out) +} + +fn memory_map_static(board: &str) -> Option<&'static str> { + match board { + "nucleo-f401re" | "nucleo-f411re" => Some( + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)", + ), + "arduino-uno" => Some("Flash: 16 KB, SRAM: 2 KB, EEPROM: 1 KB"), + "esp32" => Some("Flash: 4 MB, IRAM/DRAM per ESP-IDF layout"), + _ => None, + } +} diff --git a/src/tools/hardware_memory_map.rs b/src/tools/hardware_memory_map.rs new file mode 100644 index 0000000..bdb4f96 --- /dev/null +++ b/src/tools/hardware_memory_map.rs @@ -0,0 +1,205 @@ +//! Hardware memory map tool — returns flash/RAM address ranges for connected boards. +//! +//! Phase B: When user asks "what are the upper and lower memory addresses?", this tool +//! returns the memory map. Uses probe-rs for Nucleo/STM32 when available; otherwise +//! returns static maps from datasheets. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Known memory maps (from datasheets). Used when probe-rs is unavailable. +const MEMORY_MAPS: &[(&str, &str)] = &[ + ( + "nucleo-f401re", + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F401RET6, ARM Cortex-M4", + ), + ( + "nucleo-f411re", + "Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F411RET6, ARM Cortex-M4", + ), + ( + "arduino-uno", + "Flash: 0x0000 - 0x3FFF (16 KB, ATmega328P)\nSRAM: 0x0100 - 0x08FF (2 KB)\nEEPROM: 0x0000 - 0x03FF (1 KB)", + ), + ( + "arduino-mega", + "Flash: 0x0000 - 0x3FFFF (256 KB, ATmega2560)\nSRAM: 0x0200 - 0x21FF (8 KB)\nEEPROM: 0x0000 - 0x0FFF (4 KB)", + ), + ( + "esp32", + "Flash: 0x3F40_0000 - 0x3F7F_FFFF (4 MB typical)\nIRAM: 0x4000_0000 - 0x4005_FFFF\nDRAM: 0x3FFB_0000 - 0x3FFF_FFFF", + ), +]; + +/// Tool: report hardware memory map for connected boards. +pub struct HardwareMemoryMapTool { + boards: Vec, +} + +impl HardwareMemoryMapTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn static_map_for_board(&self, board: &str) -> Option<&'static str> { + MEMORY_MAPS + .iter() + .find(|(b, _)| *b == board) + .map(|(_, m)| *m) + } +} + +#[async_trait] +impl Tool for HardwareMemoryMapTool { + fn name(&self) -> &str { + "hardware_memory_map" + } + + fn description(&self) -> &str { + "Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "board": { + "type": "string", + "description": "Optional board name (e.g. nucleo-f401re, arduino-uno). If omitted, returns map for first configured board." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()); + + let board = board.as_deref().unwrap_or("unknown"); + + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add boards to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let mut output = String::new(); + + #[cfg(feature = "probe")] + let probe_ok = { + if board == "nucleo-f401re" || board == "nucleo-f411re" { + let chip = if board == "nucleo-f411re" { + "STM32F411RETx" + } else { + "STM32F401RETx" + }; + match probe_rs_memory_map(chip) { + Ok(probe_msg) => { + output.push_str(&format!("**{}** (via probe-rs):\n{}\n", board, probe_msg)); + true + } + Err(e) => { + output.push_str(&format!("Probe-rs failed: {}. ", e)); + false + } + } + } else { + false + } + }; + + #[cfg(not(feature = "probe"))] + let probe_ok = false; + + if !probe_ok { + if let Some(map) = self.static_map_for_board(board) { + output.push_str(&format!("**{}** (from datasheet):\n{}", board, map)); + } else { + let known: Vec<&str> = MEMORY_MAPS.iter().map(|(b, _)| *b).collect(); + output.push_str(&format!( + "No memory map for board '{}'. Known boards: {}", + board, + known.join(", ") + )); + } + } + + Ok(ToolResult { + success: true, + output, + error: None, + }) + } +} + +#[cfg(feature = "probe")] +fn probe_rs_memory_map(chip: &str) -> anyhow::Result { + use probe_rs::config::MemoryRegion; + use probe_rs::{Session, SessionConfig}; + + let session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("probe-rs attach failed: {}", e))?; + + let target = session.target(); + let mut out = String::new(); + + for region in target.memory_map.iter() { + match region { + MemoryRegion::Ram(ram) => { + let start = ram.range.start; + let end = ram.range.end; + let size_kb = (end - start) / 1024; + out.push_str(&format!( + "RAM: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, end, size_kb + )); + } + MemoryRegion::Nvm(flash) => { + let start = flash.range.start; + let end = flash.range.end; + let size_kb = (end - start) / 1024; + out.push_str(&format!( + "Flash: 0x{:08X} - 0x{:08X} ({} KB)\n", + start, end, size_kb + )); + } + _ => {} + } + } + + if out.is_empty() { + out = "Could not read memory regions from probe.".to_string(); + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_map_nucleo() { + let tool = HardwareMemoryMapTool::new(vec!["nucleo-f401re".into()]); + assert!(tool.static_map_for_board("nucleo-f401re").is_some()); + assert!(tool + .static_map_for_board("nucleo-f401re") + .unwrap() + .contains("Flash")); + } + + #[test] + fn static_map_arduino() { + let tool = HardwareMemoryMapTool::new(vec!["arduino-uno".into()]); + assert!(tool.static_map_for_board("arduino-uno").is_some()); + } +} diff --git a/src/tools/hardware_memory_read.rs b/src/tools/hardware_memory_read.rs new file mode 100644 index 0000000..4cc42d5 --- /dev/null +++ b/src/tools/hardware_memory_read.rs @@ -0,0 +1,181 @@ +//! Hardware memory read tool — read actual memory/register values from Nucleo via probe-rs. +//! +//! Use when user asks to "read register values", "read memory at address", "dump lower memory", etc. +//! Requires probe feature and Nucleo connected via USB. + +use super::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// RAM base for Nucleo-F401RE (STM32F401) +const NUCLEO_RAM_BASE: u64 = 0x2000_0000; + +/// Tool: read memory at address from connected Nucleo via probe-rs. +pub struct HardwareMemoryReadTool { + boards: Vec, +} + +impl HardwareMemoryReadTool { + pub fn new(boards: Vec) -> Self { + Self { boards } + } + + fn chip_for_board(board: &str) -> Option<&'static str> { + match board { + "nucleo-f401re" => Some("STM32F401RETx"), + "nucleo-f411re" => Some("STM32F411RETx"), + _ => None, + } + } +} + +#[async_trait] +impl Tool for HardwareMemoryReadTool { + fn name(&self) -> &str { + "hardware_memory_read" + } + + fn description(&self) -> &str { + "Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "address": { + "type": "string", + "description": "Memory address in hex (e.g. 0x20000000 for RAM start). Default: 0x20000000 (RAM base)." + }, + "length": { + "type": "integer", + "description": "Number of bytes to read (default 128, max 256)." + }, + "board": { + "type": "string", + "description": "Board name (nucleo-f401re). Optional if only one configured." + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + if self.boards.is_empty() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "No peripherals configured. Add nucleo-f401re to config.toml [peripherals.boards]." + .into(), + ), + }); + } + + let board = args + .get("board") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| self.boards.first().cloned()) + .unwrap_or_else(|| "nucleo-f401re".into()); + + let chip = Self::chip_for_board(&board); + if chip.is_none() { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Memory read only supports nucleo-f401re, nucleo-f411re. Got: {}", + board + )), + }); + } + + let address_str = args + .get("address") + .and_then(|v| v.as_str()) + .unwrap_or("0x20000000"); + let address = parse_hex_address(address_str).unwrap_or(NUCLEO_RAM_BASE); + + let length = args.get("length").and_then(|v| v.as_u64()).unwrap_or(128) as usize; + let length = length.min(256).max(1); + + #[cfg(feature = "probe")] + { + match probe_read_memory(chip.unwrap(), address, length) { + Ok(output) => { + return Ok(ToolResult { + success: true, + output, + error: None, + }); + } + Err(e) => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "probe-rs read failed: {}. Ensure Nucleo is connected via USB and built with --features probe.", + e + )), + }); + } + } + } + + #[cfg(not(feature = "probe"))] + { + Ok(ToolResult { + success: false, + output: String::new(), + error: Some( + "Memory read requires probe feature. Build with: cargo build --features hardware,probe" + .into(), + ), + }) + } + } +} + +fn parse_hex_address(s: &str) -> Option { + let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); + u64::from_str_radix(s, 16).ok() +} + +#[cfg(feature = "probe")] +fn probe_read_memory(chip: &str, address: u64, length: usize) -> anyhow::Result { + use probe_rs::MemoryInterface; + use probe_rs::Session; + use probe_rs::SessionConfig; + + let mut session = Session::auto_attach(chip, SessionConfig::default()) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let mut core = session.core(0)?; + let mut buf = vec![0u8; length]; + core.read_8(address, &mut buf) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + // Format as hex dump: address | bytes (16 per line) + let mut out = format!("Memory read from 0x{:08X} ({} bytes):\n\n", address, length); + const COLS: usize = 16; + for (i, chunk) in buf.chunks(COLS).enumerate() { + let addr = address + (i * COLS) as u64; + let hex: String = chunk + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" "); + let ascii: String = chunk + .iter() + .map(|&b| { + if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + } + }) + .collect(); + out.push_str(&format!("0x{:08X} {:48} {}\n", addr, hex, ascii)); + } + Ok(out) +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index d239c5e..0a7a2bf 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -5,6 +5,9 @@ pub mod delegate; pub mod file_read; pub mod file_write; pub mod git_operations; +pub mod hardware_board_info; +pub mod hardware_memory_map; +pub mod hardware_memory_read; pub mod http_request; pub mod image_info; pub mod memory_forget; @@ -22,6 +25,9 @@ pub use delegate::DelegateTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; pub use git_operations::GitOperationsTool; +pub use hardware_board_info::HardwareBoardInfoTool; +pub use hardware_memory_map::HardwareMemoryMapTool; +pub use hardware_memory_read::HardwareMemoryReadTool; pub use http_request::HttpRequestTool; pub use image_info::ImageInfoTool; pub use memory_forget::MemoryForgetTool;