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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com>
This commit is contained in:
parent
b36f23784a
commit
de3ec87d16
59 changed files with 9607 additions and 1885 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/target
|
||||
firmware/*/target
|
||||
*.db
|
||||
*.db-journal
|
||||
.DS_Store
|
||||
.wt-pr37/
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
|
|
|
|||
15
AGENTS.md
15
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.
|
||||
|
|
|
|||
1266
Cargo.lock
generated
1266
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
36
Cargo.toml
36
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
|
||||
|
|
|
|||
BIN
docs/Hardware_architecture.jpg
Normal file
BIN
docs/Hardware_architecture.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
116
docs/adding-boards-and-tools.md
Normal file
116
docs/adding-boards-and-tools.md
Normal file
|
|
@ -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 <board> <path>` | 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`
|
||||
217
docs/arduino-uno-q-setup.md
Normal file
217
docs/arduino-uno-q-setup.md
Normal file
|
|
@ -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@<UNO_Q_IP>
|
||||
# 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@<UNO_Q_IP>
|
||||
|
||||
# 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@<UNO_Q_IP>:~/
|
||||
ssh arduino@<UNO_Q_IP> "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@<UNO_Q_IP>
|
||||
|
||||
# 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@<UNO_Q_IP>
|
||||
|
||||
# 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@<IP>` |
|
||||
| 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.
|
||||
37
docs/datasheets/arduino-uno.md
Normal file
37
docs/datasheets/arduino-uno.md
Normal file
|
|
@ -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.
|
||||
22
docs/datasheets/esp32.md
Normal file
22
docs/datasheets/esp32.md
Normal file
|
|
@ -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"}`
|
||||
16
docs/datasheets/nucleo-f401re.md
Normal file
16
docs/datasheets/nucleo-f401re.md
Normal file
|
|
@ -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
|
||||
324
docs/hardware-peripherals-design.md
Normal file
324
docs/hardware-peripherals-design.md
Normal file
|
|
@ -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<Box<dyn Tool>>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <path>`: 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."*
|
||||
182
docs/network-deployment.md
Normal file
182
docs/network-deployment.md
Normal file
|
|
@ -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
|
||||
147
docs/nucleo-setup.md
Normal file
147
docs/nucleo-setup.md
Normal file
|
|
@ -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.
|
||||
143
firmware/zeroclaw-arduino/zeroclaw-arduino.ino
Normal file
143
firmware/zeroclaw-arduino/zeroclaw-arduino.ino
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
5
firmware/zeroclaw-esp32/.cargo/config.toml
Normal file
5
firmware/zeroclaw-esp32/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[build]
|
||||
target = "riscv32imc-esp-espidf"
|
||||
|
||||
[target.riscv32imc-esp-espidf]
|
||||
runner = "espflash flash --monitor"
|
||||
1840
firmware/zeroclaw-esp32/Cargo.lock
generated
Normal file
1840
firmware/zeroclaw-esp32/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
35
firmware/zeroclaw-esp32/Cargo.toml
Normal file
35
firmware/zeroclaw-esp32/Cargo.toml
Normal file
|
|
@ -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"
|
||||
52
firmware/zeroclaw-esp32/README.md
Normal file
52
firmware/zeroclaw-esp32/README.md
Normal file
|
|
@ -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.
|
||||
3
firmware/zeroclaw-esp32/build.rs
Normal file
3
firmware/zeroclaw-esp32/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
embuild::espidf::sysenv::output();
|
||||
}
|
||||
154
firmware/zeroclaw-esp32/src/main.rs
Normal file
154
firmware/zeroclaw-esp32/src/main.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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::<esp_idf_svc::hal::gpio::Gpio0>::None,
|
||||
Option::<esp_idf_svc::hal::gpio::Gpio1>::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<Response> {
|
||||
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<u8> {
|
||||
// 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(())
|
||||
}
|
||||
849
firmware/zeroclaw-nucleo/Cargo.lock
generated
Normal file
849
firmware/zeroclaw-nucleo/Cargo.lock
generated
Normal file
|
|
@ -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",
|
||||
]
|
||||
39
firmware/zeroclaw-nucleo/Cargo.toml
Normal file
39
firmware/zeroclaw-nucleo/Cargo.toml
Normal file
|
|
@ -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
|
||||
187
firmware/zeroclaw-nucleo/src/main.rs
Normal file
187
firmware/zeroclaw-nucleo/src/main.rs
Normal file
|
|
@ -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<i32> {
|
||||
// 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<u8, 256> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
firmware/zeroclaw-uno-q-bridge/app.yaml
Normal file
9
firmware/zeroclaw-uno-q-bridge/app.yaml
Normal file
|
|
@ -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: []
|
||||
66
firmware/zeroclaw-uno-q-bridge/python/main.py
Normal file
66
firmware/zeroclaw-uno-q-bridge/python/main.py
Normal file
|
|
@ -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()
|
||||
1
firmware/zeroclaw-uno-q-bridge/python/requirements.txt
Normal file
1
firmware/zeroclaw-uno-q-bridge/python/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
# ZeroClaw Bridge — no extra deps (arduino.app_utils is preinstalled on Uno Q)
|
||||
24
firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino
Normal file
24
firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino
Normal file
|
|
@ -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();
|
||||
}
|
||||
11
firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml
Normal file
11
firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml
Normal file
|
|
@ -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
|
||||
|
|
@ -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<dyn Tool>], 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<ChatMessage>,
|
||||
|
|
@ -382,6 +421,7 @@ pub(crate) async fn agent_turn(
|
|||
provider_name: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
silent: bool,
|
||||
) -> Result<String> {
|
||||
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<String> {
|
||||
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<dyn Tool>]) -> Strin
|
|||
instructions.push_str("\n## Tool Use Protocol\n\n");
|
||||
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
|
||||
instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
|
||||
instructions.push_str(
|
||||
"CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
|
||||
);
|
||||
instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n\n");
|
||||
instructions.push_str("You may use multiple tool calls in a single response. ");
|
||||
instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
|
||||
instructions
|
||||
|
|
@ -555,18 +600,11 @@ pub async fn run(
|
|||
provider_override: Option<String>,
|
||||
model_override: Option<String>,
|
||||
temperature: f64,
|
||||
verbose: bool,
|
||||
peripheral_overrides: Vec<String>,
|
||||
) -> Result<()> {
|
||||
// ── Wire up agnostic subsystems ──────────────────────────────
|
||||
let base_observer = observability::create_observer(&config.observability);
|
||||
let observer: Arc<dyn Observer> = if verbose {
|
||||
Arc::from(Box::new(observability::MultiObserver::new(vec![
|
||||
base_observer,
|
||||
Box::new(observability::VerboseObserver::new()),
|
||||
])) as Box<dyn Observer>)
|
||||
} else {
|
||||
Arc::from(base_observer)
|
||||
};
|
||||
let observer: Arc<dyn Observer> = Arc::from(base_observer);
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
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<Box<dyn Tool>> =
|
||||
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<crate::rag::HardwareRag> = 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<String> = 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<String> {
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
Arc::from(runtime::create_runtime(&config.runtime)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let mem: Arc<dyn Memory> = 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<Box<dyn Tool>> =
|
||||
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<dyn Provider> = providers::create_routed_provider(
|
||||
provider_name,
|
||||
config.api_key.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&model_name,
|
||||
)?;
|
||||
|
||||
let hardware_rag: Option<crate::rag::HardwareRag> = 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<String> = 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::*;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,3 @@
|
|||
pub mod loop_;
|
||||
|
||||
pub use loop_::run;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn assert_reexport_exists<F>(_value: F) {}
|
||||
|
||||
#[test]
|
||||
fn run_function_is_reexported() {
|
||||
assert_reexport_exists(run);
|
||||
assert_reexport_exists(loop_::run);
|
||||
}
|
||||
}
|
||||
pub use loop_::{process_message, run};
|
||||
|
|
|
|||
|
|
@ -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<ChannelRuntimeContext>, 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<usize>,
|
||||
) -> 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 <tool_call> 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("<available_skills>"), "missing skills XML");
|
||||
assert!(prompt.contains("<name>code-review</name>"));
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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<String, DelegateAgentConfig>,
|
||||
|
||||
/// 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<String>,
|
||||
/// Optional API key override
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Temperature override
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f64>,
|
||||
/// 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<String>,
|
||||
/// Serial baud rate
|
||||
#[serde(default = "default_baud_rate")]
|
||||
pub baud_rate: u32,
|
||||
/// Probe target chip (e.g. "STM32F401RE")
|
||||
#[serde(default)]
|
||||
pub probe_target: Option<String>,
|
||||
/// 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<String, ModelPricing> {
|
|||
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<String>,
|
||||
/// 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<String>,
|
||||
/// Temperature override (uses 0.7 if not set)
|
||||
pub boards: Vec<PeripheralBoardConfig>,
|
||||
/// 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<f64>,
|
||||
/// Maximum delegation depth to prevent infinite recursion (default: 3)
|
||||
#[serde(default = "default_max_delegation_depth")]
|
||||
pub max_depth: u32,
|
||||
pub datasheet_dir: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 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<String, DelegateAgentConfig> = toml::from_str::<toml::Value>(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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ async fn gateway_agent_reply(state: &AppState, message: &str) -> Result<String>
|
|||
"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(),
|
||||
|
|
|
|||
45
src/hardware/discover.rs
Normal file
45
src/hardware/discover.rs
Normal file
|
|
@ -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<String>,
|
||||
pub board_name: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
}
|
||||
|
||||
/// Enumerate all connected USB devices and enrich with board registry lookup.
|
||||
#[cfg(feature = "hardware")]
|
||||
pub fn list_usb_devices() -> Result<Vec<UsbDeviceInfo>> {
|
||||
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)
|
||||
}
|
||||
121
src/hardware/introspect.rs
Normal file
121
src/hardware/introspect.rs
Normal file
|
|
@ -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<u16>,
|
||||
pub pid: Option<u16>,
|
||||
pub board_name: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
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<IntrospectResult> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
||||
1511
src/hardware/mod.rs
1511
src/hardware/mod.rs
File diff suppressed because it is too large
Load diff
102
src/hardware/registry.rs
Normal file
102
src/hardware/registry.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
116
src/lib.rs
116
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::<SkillCommands>(&serde_json::to_string(&skill).unwrap()).unwrap(),
|
||||
skill
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MigrateCommands>(&serde_json::to_string(&migrate).unwrap())
|
||||
.unwrap(),
|
||||
migrate
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CronCommands>(&serde_json::to_string(&cron).unwrap()).unwrap(),
|
||||
cron
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<IntegrationCommands>(
|
||||
&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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run)
|
||||
FlashNucleo,
|
||||
}
|
||||
|
|
|
|||
46
src/main.rs
46
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<String>,
|
||||
},
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,10 +125,11 @@ pub fn run_wizard() -> Result<Config> {
|
|||
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<ChannelsConfig> {
|
|||
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<ChannelsConfig> {
|
|||
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<ChannelsConfig> {
|
|||
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<ChannelsConfig> {
|
|||
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<ChannelsConfig> {
|
|||
.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()
|
||||
|
|
|
|||
144
src/peripherals/arduino_flash.rs
Normal file
144
src/peripherals/arduino_flash.rs
Normal file
|
|
@ -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<String> {
|
||||
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())
|
||||
}
|
||||
161
src/peripherals/arduino_upload.rs
Normal file
161
src/peripherals/arduino_upload.rs
Normal file
|
|
@ -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<ToolResult> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
99
src/peripherals/capabilities_tool.rs
Normal file
99
src/peripherals/capabilities_tool.rs
Normal file
|
|
@ -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<SerialTransport>)>,
|
||||
}
|
||||
|
||||
impl HardwareCapabilitiesTool {
|
||||
pub(crate) fn new(boards: Vec<(String, Arc<SerialTransport>)>) -> 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<ToolResult> {
|
||||
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::<serde_json::Value>(&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,
|
||||
})
|
||||
}
|
||||
}
|
||||
231
src/peripherals/mod.rs
Normal file
231
src/peripherals/mod.rs
Normal file
|
|
@ -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 <board> <path>");
|
||||
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<Vec<Box<dyn Tool>>> {
|
||||
if !config.enabled || config.boards.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let mut serial_transports: Vec<(String, std::sync::Arc<serial::SerialTransport>)> = 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<String> = 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<Vec<Box<dyn Tool>>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
83
src/peripherals/nucleo_flash.rs
Normal file
83
src/peripherals/nucleo_flash.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
173
src/peripherals/rpi.rs
Normal file
173
src/peripherals/rpi.rs
Normal file
|
|
@ -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<Self> {
|
||||
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<Box<dyn Tool>> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
274
src/peripherals/serial.rs
Normal file
274
src/peripherals/serial.rs
Normal file
|
|
@ -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<Value> {
|
||||
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<SerialStream>,
|
||||
}
|
||||
|
||||
/// Timeout for serial request/response (seconds).
|
||||
const SERIAL_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
impl SerialTransport {
|
||||
async fn request(&self, cmd: &str, args: Value) -> anyhow::Result<ToolResult> {
|
||||
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<ToolResult> {
|
||||
self.request("capabilities", json!({})).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serial peripheral for STM32, Arduino, etc. over USB CDC.
|
||||
pub struct SerialPeripheral {
|
||||
name: String,
|
||||
board_type: String,
|
||||
transport: Arc<SerialTransport>,
|
||||
}
|
||||
|
||||
impl SerialPeripheral {
|
||||
/// Create and connect to a serial peripheral.
|
||||
pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result<Self> {
|
||||
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<Box<dyn Tool>> {
|
||||
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<SerialTransport> {
|
||||
self.transport.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: read GPIO pin value.
|
||||
struct GpioReadTool {
|
||||
transport: Arc<SerialTransport>,
|
||||
}
|
||||
|
||||
#[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<ToolResult> {
|
||||
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<SerialTransport>,
|
||||
}
|
||||
|
||||
#[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<ToolResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
33
src/peripherals/traits.rs
Normal file
33
src/peripherals/traits.rs
Normal file
|
|
@ -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<Box<dyn Tool>>;
|
||||
}
|
||||
151
src/peripherals/uno_q_bridge.rs
Normal file
151
src/peripherals/uno_q_bridge.rs
Normal file
|
|
@ -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<String> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/peripherals/uno_q_setup.rs
Normal file
143
src/peripherals/uno_q_setup.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -15,6 +15,9 @@ pub struct OpenAiCompatibleProvider {
|
|||
pub(crate) base_url: String,
|
||||
pub(crate) api_key: Option<String>,
|
||||
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<Message>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
}
|
||||
|
||||
#[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"));
|
||||
|
|
|
|||
|
|
@ -217,8 +217,8 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
|||
"zai" | "z.ai" => 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",
|
||||
|
|
|
|||
397
src/rag/mod.rs
Normal file
397
src/rag/mod.rs
Normal file
|
|
@ -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<String>,
|
||||
/// 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<String, u32>;
|
||||
|
||||
/// 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::<u32>() {
|
||||
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::<u32>() {
|
||||
if !alias.is_empty() {
|
||||
aliases.insert(alias, pin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aliases
|
||||
}
|
||||
|
||||
fn collect_md_txt_paths(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
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<std::path::PathBuf>) {
|
||||
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<String> {
|
||||
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<DatasheetChunk>,
|
||||
/// Per-board pin aliases (board -> alias -> pin).
|
||||
pin_aliases: HashMap<String, PinAliases>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<std::path::PathBuf> = 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<String, PinAliases> = 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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
205
src/tools/hardware_board_info.rs
Normal file
205
src/tools/hardware_board_info.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl HardwareBoardInfoTool {
|
||||
pub fn new(boards: Vec<String>) -> Self {
|
||||
Self { boards }
|
||||
}
|
||||
|
||||
fn static_info_for_board(&self, board: &str) -> Option<String> {
|
||||
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<ToolResult> {
|
||||
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<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
205
src/tools/hardware_memory_map.rs
Normal file
205
src/tools/hardware_memory_map.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl HardwareMemoryMapTool {
|
||||
pub fn new(boards: Vec<String>) -> 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<ToolResult> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
181
src/tools/hardware_memory_read.rs
Normal file
181
src/tools/hardware_memory_read.rs
Normal file
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl HardwareMemoryReadTool {
|
||||
pub fn new(boards: Vec<String>) -> 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<ToolResult> {
|
||||
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<u64> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue