feat: add Raspberry Pi support with GPIO tools and hardware context management
- Introduced `rpi-config.toml` for Raspberry Pi configuration settings. - Created `zeroclaw.service` for systemd service management of ZeroClaw on Raspberry Pi. - Implemented hardware context management endpoints in `hardware_context.rs` for GPIO pin registration and context appending. - Added new tools for GPIO operations: `gpio_rpi_write`, `gpio_rpi_read`, `gpio_rpi_blink`, and `rpi_system_info`. - Enhanced hardware boot process to include Raspberry Pi self-discovery and tool registration. - Developed `rpi.rs` for Raspberry Pi model detection and system context information.
This commit is contained in:
parent
bdecd6bbb3
commit
15816dfaa0
@ -4,6 +4,10 @@ rustflags = ["-C", "link-arg=-static"]
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "link-arg=-static"]
|
||||
|
||||
# Raspberry Pi 3B/4B/5 — glibc linked, built with `cross` or brew aarch64 toolchain
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-unknown-linux-gnu-gcc"
|
||||
|
||||
# Android targets (NDK toolchain)
|
||||
[target.armv7-linux-androideabi]
|
||||
linker = "armv7a-linux-androideabi21-clang"
|
||||
|
||||
169
SESSION_CONTEXT.md
Normal file
169
SESSION_CONTEXT.md
Normal file
@ -0,0 +1,169 @@
|
||||
# ZeroClaw Session Context — March 8 2026
|
||||
|
||||
## Repository
|
||||
- Path: `/Users/ehushubhamshaw/Documents/zeroclaw`
|
||||
- Branch: `Enabling_claw_to_do_multiple_responce` (also tagged `zeroclaw_homecomming`)
|
||||
- HEAD: `bdecd6bb` — `feat(hardware): register board-info tools unconditionally; fix nucleo firmware build`
|
||||
- Only uncommitted change: `firmware/zeroclaw-nucleo/Cargo.lock` (harmless lockfile)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Standard (no hardware)
|
||||
cargo build
|
||||
|
||||
# With Pico/serial hardware support
|
||||
cargo build --features hardware
|
||||
|
||||
# With Pico + Nucleo probe-rs support (live register reads) — current binary
|
||||
cargo build --features hardware,probe
|
||||
|
||||
# Run agent
|
||||
./target/debug/zeroclaw agent -m "your prompt"
|
||||
```
|
||||
|
||||
> The binary currently on disk (`./target/debug/zeroclaw`) was built with `--features hardware,probe`.
|
||||
|
||||
## External Tools
|
||||
- `mpremote`: `~/.local/bin/mpremote` — used by `device_exec` to run MicroPython on Pico
|
||||
- `probe-rs` v0.31.0: `~/.cargo/bin/probe-rs` — used by `hardware_memory_read` for live Nucleo register reads
|
||||
|
||||
---
|
||||
|
||||
## Hardware Config — `~/.zeroclaw/config.toml` (peripherals section)
|
||||
|
||||
```toml
|
||||
[[peripherals.boards]]
|
||||
board = "pico"
|
||||
transport = "serial"
|
||||
path = "/dev/cu.usbmodem1101"
|
||||
baud = 115200
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/cu.usbmodem1103"
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
> **Port warning:** macOS re-assigns `usbmodemXXXX` on every replug.
|
||||
> If a board stops responding: `ls /dev/cu.usbmodem*` → update path in `~/.zeroclaw/config.toml`. No rebuild needed.
|
||||
|
||||
---
|
||||
|
||||
## Physical Hardware
|
||||
|
||||
### Board 1 — Raspberry Pi Pico (alias `pico0`)
|
||||
- Physical board: **Pico Breadboard Kit** (loose components on half-size breadboard, jumper-wired)
|
||||
- Port: `/dev/cu.usbmodem1101`
|
||||
- Runtime: MicroPython v1.27.0
|
||||
|
||||
| GPIO | Component | Notes |
|
||||
|------|-----------|-------|
|
||||
| GP25 | Onboard LED | Soldered, always works — no jumper needed |
|
||||
| GP15 | Active buzzer | `Pin.OUT HIGH/LOW` only — **no PWM**, it's active not passive |
|
||||
| GP16 | LED1 | Jumper-wired on breadboard |
|
||||
| GP17 | LED2 | Jumper-wired |
|
||||
| GP18 | LED3 | Jumper-wired |
|
||||
| GP19 | LED4 | Jumper-wired |
|
||||
| GP11 | Button K1 | Active LOW (reads 0 when pressed) |
|
||||
| GP12 | Button K2 | Active LOW |
|
||||
| GP13 | Button K3 | Active LOW |
|
||||
| GP14 | Button K4 | Active LOW |
|
||||
| GP26–28 | ADC0–2 | Spare analog |
|
||||
|
||||
### Board 2 — STM32 Nucleo-F401RE (alias `nucleo0`)
|
||||
- Port: `/dev/cu.usbmodem1103`
|
||||
- `hardware_board_info` / `hardware_memory_map` → work **without board connected** (static datasheet data)
|
||||
- `hardware_memory_read` → requires board **physically connected** via USB (uses probe-rs ST-Link)
|
||||
- GPIOA base: `0x4002_0000`, ODR at `0x4002_0014`
|
||||
- Flash: `0x0800_0000`–`0x0807_FFFF` (512KB), RAM: `0x2000_0000`–`0x2001_FFFF` (128KB)
|
||||
|
||||
---
|
||||
|
||||
## Key Source Files Modified This Sprint
|
||||
|
||||
### `src/peripherals/mod.rs`
|
||||
Added `create_board_info_tools()` — no feature gate, no serial port:
|
||||
```rust
|
||||
pub fn create_board_info_tools(config: &PeripheralsConfig) -> Vec<Box<dyn Tool>> {
|
||||
if !config.enabled || config.boards.is_empty() { return Vec::new(); }
|
||||
let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
|
||||
vec![
|
||||
Box::new(crate::tools::HardwareMemoryMapTool::new(board_names.clone())),
|
||||
Box::new(crate::tools::HardwareBoardInfoTool::new(board_names.clone())),
|
||||
Box::new(crate::tools::HardwareMemoryReadTool::new(board_names)),
|
||||
]
|
||||
}
|
||||
```
|
||||
**Root cause fixed:** These tools were previously inside `create_peripheral_tools()` which is `#[cfg(feature = "hardware")]` gated — so they were never loaded when the hardware feature was enabled.
|
||||
|
||||
### `src/agent/loop_.rs`
|
||||
Both `run()` and `process_message_with_session()` now call `create_board_info_tools()` unconditionally after the hardware feature block.
|
||||
|
||||
### `firmware/zeroclaw-nucleo/Cargo.toml`
|
||||
- Added `[workspace]` table (isolates from root workspace)
|
||||
- Removed `strip = true` (was stripping `.text` → probe-rs couldn't find flash range)
|
||||
- Added `cortex-m = { version = "0.7", features = ["inline-asm", "critical-section-single-core"] }`
|
||||
- `debug = 2`
|
||||
|
||||
### `firmware/zeroclaw-nucleo/.cargo/config.toml` (created)
|
||||
```toml
|
||||
[target.thumbv7em-none-eabihf]
|
||||
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"]
|
||||
runner = "probe-rs run --chip STM32F401RETx"
|
||||
```
|
||||
|
||||
### `Cargo.toml` (root)
|
||||
Added `exclude` array for all firmware dirs to isolate them from root workspace.
|
||||
|
||||
### `~/.zeroclaw/hardware/devices/pico0.md`
|
||||
Declarative pin map for Pico Breadboard Kit. Active buzzer on GP15 — no PWM.
|
||||
|
||||
### `~/.zeroclaw/hardware/HARDWARE.md`
|
||||
Matching pin map + tool usage rules for the agent.
|
||||
|
||||
---
|
||||
|
||||
## Verified Working Demo Commands
|
||||
|
||||
```bash
|
||||
# Pico — onboard LED
|
||||
./target/debug/zeroclaw agent -m "blink the onboard LED 3 times"
|
||||
|
||||
# Pico — buzzer
|
||||
./target/debug/zeroclaw agent -m "beep the buzzer once on pico0"
|
||||
|
||||
# Pico — countdown + victory tune (all 4 LEDs + buzzer)
|
||||
./target/debug/zeroclaw agent -m "on pico0: blink all 4 LEDs and beep the buzzer at the same time, 3 times like a countdown, then play a victory tune on the buzzer"
|
||||
|
||||
# Nucleo — static datasheet (no board needed)
|
||||
./target/debug/zeroclaw agent -m "what peripherals does the STM32F401 expose and what are their base addresses?"
|
||||
./target/debug/zeroclaw agent -m "show me the upper and lower memory map of the nucleo board"
|
||||
|
||||
# Nucleo — live register read (board must be connected)
|
||||
./target/debug/zeroclaw agent -m "read the current value of the GPIOA ODR register on nucleo0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alert Escalation Demo Sequence
|
||||
|
||||
Run these in order — escalating LED + buzz pattern like a traffic alert:
|
||||
|
||||
```bash
|
||||
./target/debug/zeroclaw agent -m "on pico0: light up LED1 and beep the buzzer once"
|
||||
./target/debug/zeroclaw agent -m "on pico0: light up LED1 and LED2, then beep the buzzer twice"
|
||||
./target/debug/zeroclaw agent -m "on pico0: light up LED1 LED2 and LED3, then beep the buzzer 3 times"
|
||||
./target/debug/zeroclaw agent -m "on pico0: turn on all 4 LEDs and beep the buzzer 4 times fast — full alarm mode"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending / Not Yet Done
|
||||
|
||||
- Telegram E2E tests (Section 8 of pre-Aardvark checklist)
|
||||
- `crates/aardvark-sys` — Total Phase Aardvark USB I2C/SPI adapter; vendored `.so` is x86_64, needs arm64 build from totalphase.com or compile with `--target x86_64-apple-darwin`
|
||||
- Button input demo (GP11–GP14 read)
|
||||
- Internal temperature sensor demo (ADC4)
|
||||
- Commit current working state to branch
|
||||
2
firmware/zeroclaw-nucleo/Cargo.lock
generated
2
firmware/zeroclaw-nucleo/Cargo.lock
generated
@ -88,6 +88,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"bitfield",
|
||||
"critical-section",
|
||||
"embedded-hal 0.2.7",
|
||||
"volatile-register",
|
||||
]
|
||||
@ -837,6 +838,7 @@ dependencies = [
|
||||
name = "zeroclaw-nucleo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
|
||||
119
scripts/deploy-rpi.sh
Executable file
119
scripts/deploy-rpi.sh
Executable file
@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-rpi.sh — cross-compile ZeroClaw for Raspberry Pi and deploy via SSH.
|
||||
#
|
||||
# Requirements (macOS/Linux host):
|
||||
# cargo install cross (Docker-based cross-compiler)
|
||||
# Docker running
|
||||
#
|
||||
# Usage:
|
||||
# RPI_HOST=raspberrypi.local RPI_USER=pi ./scripts/deploy-rpi.sh
|
||||
#
|
||||
# Optional env vars:
|
||||
# RPI_HOST — hostname or IP of the Pi (default: raspberrypi.local)
|
||||
# RPI_USER — SSH user on the Pi (default: pi)
|
||||
# RPI_PORT — SSH port (default: 22)
|
||||
# RPI_DIR — remote deployment dir (default: /home/$RPI_USER/zeroclaw)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RPI_HOST="${RPI_HOST:-raspberrypi.local}"
|
||||
RPI_USER="${RPI_USER:-pi}"
|
||||
RPI_PORT="${RPI_PORT:-22}"
|
||||
RPI_DIR="${RPI_DIR:-/home/${RPI_USER}/zeroclaw}"
|
||||
TARGET="aarch64-unknown-linux-gnu"
|
||||
FEATURES="hardware,peripheral-rpi"
|
||||
BINARY="target/${TARGET}/release/zeroclaw"
|
||||
SSH_OPTS="-p ${RPI_PORT} -o StrictHostKeyChecking=no -o ConnectTimeout=10"
|
||||
|
||||
echo "==> Building ZeroClaw for Raspberry Pi (${TARGET})"
|
||||
echo " Features: ${FEATURES}"
|
||||
echo " Target host: ${RPI_USER}@${RPI_HOST}:${RPI_PORT}"
|
||||
echo ""
|
||||
|
||||
# ── 1. Cross-compile ──────────────────────────────────────────────────────────
|
||||
cross build \
|
||||
--target "${TARGET}" \
|
||||
--features "${FEATURES}" \
|
||||
--release
|
||||
|
||||
echo ""
|
||||
echo "==> Build complete: ${BINARY}"
|
||||
ls -lh "${BINARY}"
|
||||
|
||||
# ── 2. Create remote directory ────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "==> Creating remote directory ${RPI_DIR}"
|
||||
# shellcheck disable=SC2029
|
||||
ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "mkdir -p ${RPI_DIR}"
|
||||
|
||||
# ── 3. Deploy binary ──────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "==> Deploying binary to ${RPI_USER}@${RPI_HOST}:${RPI_DIR}/zeroclaw"
|
||||
scp ${SSH_OPTS} "${BINARY}" "${RPI_USER}@${RPI_HOST}:${RPI_DIR}/zeroclaw"
|
||||
|
||||
# ── 4. Create .env skeleton (if it doesn't exist) ────────────────────────────
|
||||
ENV_DEST="${RPI_DIR}/.env"
|
||||
echo ""
|
||||
echo "==> Checking for ${ENV_DEST}"
|
||||
# shellcheck disable=SC2029
|
||||
if ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "[ -f ${ENV_DEST} ]"; then
|
||||
echo " .env already exists — skipping"
|
||||
else
|
||||
echo " Creating .env skeleton with 600 permissions"
|
||||
# shellcheck disable=SC2029
|
||||
ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \
|
||||
"mkdir -p ${RPI_DIR} && \
|
||||
printf '# Set your API key here\nANTHROPIC_API_KEY=sk-ant-\n' > ${ENV_DEST} && \
|
||||
chmod 600 ${ENV_DEST}"
|
||||
echo " IMPORTANT: edit ${ENV_DEST} on the Pi and set ANTHROPIC_API_KEY"
|
||||
fi
|
||||
|
||||
# ── 5. Deploy config (if it doesn't already exist remotely) ──────────────────
|
||||
CONFIG_DEST="/home/${RPI_USER}/.zeroclaw/config.toml"
|
||||
echo ""
|
||||
echo "==> Checking for existing config at ${CONFIG_DEST}"
|
||||
# shellcheck disable=SC2029
|
||||
if ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "[ -f ${CONFIG_DEST} ]"; then
|
||||
echo " Config already exists — skipping (edit manually if needed)"
|
||||
else
|
||||
echo " Deploying starter config to ${CONFIG_DEST}"
|
||||
# shellcheck disable=SC2029
|
||||
ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" "mkdir -p /home/${RPI_USER}/.zeroclaw"
|
||||
scp ${SSH_OPTS} "scripts/rpi-config.toml" "${RPI_USER}@${RPI_HOST}:${CONFIG_DEST}"
|
||||
fi
|
||||
|
||||
# ── 6. Deploy and enable systemd service ─────────────────────────────────────
|
||||
SERVICE_DEST="/etc/systemd/system/zeroclaw.service"
|
||||
echo ""
|
||||
echo "==> Installing systemd service (requires sudo on the Pi)"
|
||||
scp ${SSH_OPTS} "scripts/zeroclaw.service" "${RPI_USER}@${RPI_HOST}:/tmp/zeroclaw.service"
|
||||
# shellcheck disable=SC2029
|
||||
ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \
|
||||
"sudo mv /tmp/zeroclaw.service ${SERVICE_DEST} && \
|
||||
sudo systemctl daemon-reload && \
|
||||
sudo systemctl enable zeroclaw && \
|
||||
sudo systemctl restart zeroclaw && \
|
||||
sudo systemctl status zeroclaw --no-pager || true"
|
||||
|
||||
# ── 7. Runtime permissions ───────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "==> Granting ${RPI_USER} access to GPIO group"
|
||||
# shellcheck disable=SC2029
|
||||
ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \
|
||||
"sudo usermod -aG gpio ${RPI_USER} || true"
|
||||
|
||||
# ── 8. Reset ACT LED trigger so ZeroClaw can control it ──────────────────────
|
||||
echo ""
|
||||
echo "==> Resetting ACT LED trigger (none)"
|
||||
# shellcheck disable=SC2029
|
||||
ssh ${SSH_OPTS} "${RPI_USER}@${RPI_HOST}" \
|
||||
"echo none | sudo tee /sys/class/leds/ACT/trigger > /dev/null 2>&1 || true"
|
||||
|
||||
echo ""
|
||||
echo "==> Deployment complete!"
|
||||
echo ""
|
||||
echo " ZeroClaw is running at http://${RPI_HOST}:8080"
|
||||
echo " POST /api/chat — chat with the agent"
|
||||
echo " GET /health — health check"
|
||||
echo ""
|
||||
echo " To check logs: ssh ${RPI_USER}@${RPI_HOST} 'journalctl -u zeroclaw -f'"
|
||||
46
scripts/rpi-config.toml
Normal file
46
scripts/rpi-config.toml
Normal file
@ -0,0 +1,46 @@
|
||||
# ZeroClaw — Raspberry Pi starter configuration
|
||||
#
|
||||
# Copy this to ~/.zeroclaw/config.toml on the Pi.
|
||||
# deploy-rpi.sh does this automatically on first deploy.
|
||||
#
|
||||
# Required: set your API key for the chosen provider in the environment:
|
||||
# export ANTHROPIC_API_KEY=sk-ant-...
|
||||
# export OPENAI_API_KEY=sk-...
|
||||
|
||||
default_provider = "anthropic"
|
||||
default_model = "claude-sonnet-4-20250514"
|
||||
|
||||
[agent]
|
||||
system_prompt = "You are ZeroClaw, an autonomous AI hardware agent running directly on a Raspberry Pi. You have native GPIO access via rppal — no serial cable, no laptop. Use gpio_rpi_write / gpio_rpi_read / gpio_rpi_blink for all GPIO operations."
|
||||
max_tokens = 4096
|
||||
|
||||
[gateway]
|
||||
enabled = true
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
require_pairing = false
|
||||
allow_public_bind = true
|
||||
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
# The RPi GPIO tools are auto-discovered at boot when running with the
|
||||
# peripheral-rpi feature — no board entry is required here.
|
||||
# Add serial boards (Pico, Arduino) here if you attach them later:
|
||||
# [[peripherals.boards]]
|
||||
# board = "pico"
|
||||
# transport = "serial"
|
||||
# path = "/dev/ttyACM0"
|
||||
# baud = 115200
|
||||
|
||||
[autonomy]
|
||||
level = "autonomous"
|
||||
require_approval_for_medium_risk = false
|
||||
block_high_risk_commands = true
|
||||
|
||||
[memory]
|
||||
backend = "markdown"
|
||||
path = "~/.zeroclaw/memory"
|
||||
|
||||
[logging]
|
||||
level = "info"
|
||||
21
scripts/zeroclaw.service
Normal file
21
scripts/zeroclaw.service
Normal file
@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=ZeroClaw AI Hardware Agent
|
||||
Documentation=https://github.com/zeroclaw/zeroclaw
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi/zeroclaw
|
||||
ExecStart=/home/pi/zeroclaw/zeroclaw gateway --host 0.0.0.0 --port 8080
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=/home/pi/zeroclaw/.env
|
||||
Environment=RUST_LOG=info
|
||||
|
||||
# Expand ~ in config path
|
||||
Environment=HOME=/home/pi
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
427
src/gateway/hardware_context.rs
Normal file
427
src/gateway/hardware_context.rs
Normal file
@ -0,0 +1,427 @@
|
||||
//! Hardware context management endpoints.
|
||||
//!
|
||||
//! These endpoints let remote callers (phone, laptop) register GPIO pins and
|
||||
//! append context to the running agent's hardware knowledge base without SSH.
|
||||
//!
|
||||
//! ## Endpoints
|
||||
//!
|
||||
//! - `POST /api/hardware/pin` — register a single GPIO pin assignment
|
||||
//! - `POST /api/hardware/context` — append raw markdown to a device file
|
||||
//! - `GET /api/hardware/context` — read all current hardware context files
|
||||
//! - `POST /api/hardware/reload` — verify on-disk context; report what will be
|
||||
//! used on the next chat request
|
||||
//!
|
||||
//! ## Live update semantics
|
||||
//!
|
||||
//! ZeroClaw's agent loop calls [`crate::hardware::boot`] on **every** request,
|
||||
//! which re-reads `~/.zeroclaw/hardware/` from disk. Writing to those files
|
||||
//! therefore takes effect on the very next `/api/chat` call — no daemon restart
|
||||
//! needed. The `/api/hardware/reload` endpoint verifies what is on disk and
|
||||
//! reports what will be injected into the system prompt next time.
|
||||
//!
|
||||
//! ## Security
|
||||
//!
|
||||
//! - **Auth**: same `require_auth` helper used by all `/api/*` routes.
|
||||
//! - **Path traversal**: device aliases are validated to be alphanumeric +
|
||||
//! hyphens/underscores only; they are never used as raw path components.
|
||||
//! - **Append-only**: all writes use `OpenOptions::append(true)` — existing
|
||||
//! content cannot be truncated or overwritten through these endpoints.
|
||||
//! - **Size limit**: individual append payloads are capped at 32 KB.
|
||||
|
||||
use super::AppState;
|
||||
use axum::{
|
||||
extract::{State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Json},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
|
||||
/// Maximum bytes allowed in a single append payload.
|
||||
const MAX_APPEND_BYTES: usize = 32_768; // 32 KB
|
||||
|
||||
// ── Auth helper (re-uses the pattern from api.rs) ─────────────────────────────
|
||||
|
||||
fn require_auth(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
if !state.pairing.require_pairing() {
|
||||
return Ok(());
|
||||
}
|
||||
let token = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if state.pairing.is_authenticated(token) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer <token>"
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Return `~/.zeroclaw/hardware/` or an error string.
|
||||
fn hardware_dir() -> Result<PathBuf, String> {
|
||||
directories::BaseDirs::new()
|
||||
.map(|b| b.home_dir().join(".zeroclaw").join("hardware"))
|
||||
.ok_or_else(|| "Cannot determine home directory".to_string())
|
||||
}
|
||||
|
||||
/// Validate a device alias: must be non-empty, ≤64 chars, and consist only of
|
||||
/// alphanumerics, hyphens, and underscores. Returns an error message on failure.
|
||||
fn validate_device_alias(alias: &str) -> Result<(), &'static str> {
|
||||
if alias.is_empty() || alias.len() > 64 {
|
||||
return Err("Device alias must be 1–64 characters");
|
||||
}
|
||||
if !alias.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err("Device alias must contain only alphanumerics, hyphens, and underscores");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the path to a device context file, after validating the alias.
|
||||
fn device_file_path(hw_dir: &std::path::Path, alias: &str) -> Result<PathBuf, &'static str> {
|
||||
validate_device_alias(alias)?;
|
||||
Ok(hw_dir.join("devices").join(format!("{alias}.md")))
|
||||
}
|
||||
|
||||
// ── POST /api/hardware/pin ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PinRegistrationBody {
|
||||
/// Device alias (default: "rpi0").
|
||||
#[serde(default = "default_device")]
|
||||
pub device: String,
|
||||
/// BCM GPIO number.
|
||||
pub pin: u32,
|
||||
/// Component type/name, e.g. "LED", "Button", "Servo".
|
||||
pub component: String,
|
||||
/// Optional human notes about this pin, e.g. "red LED, active HIGH".
|
||||
#[serde(default)]
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
fn default_device() -> String {
|
||||
"rpi0".to_string()
|
||||
}
|
||||
|
||||
/// `POST /api/hardware/pin` — register a single GPIO pin assignment.
|
||||
///
|
||||
/// Appends one line to `~/.zeroclaw/hardware/devices/<device>.md`:
|
||||
/// ```text
|
||||
/// - GPIO <pin>: <component> — <notes>
|
||||
/// ```
|
||||
pub async fn handle_hardware_pin(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Result<Json<PinRegistrationBody>, axum::extract::rejection::JsonRejection>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let Json(req) = match body {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if req.component.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "\"component\" must not be empty" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
// Sanitize component + notes: strip newlines to prevent line-injection.
|
||||
let component = req.component.replace(['\n', '\r'], " ");
|
||||
let notes = req.notes.replace(['\n', '\r'], " ");
|
||||
|
||||
let hw_dir = match hardware_dir() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let device_path = match device_file_path(&hw_dir, &req.device) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// Create devices dir + file if missing, then append.
|
||||
if let Some(parent) = device_path.parent() {
|
||||
if let Err(e) = fs::create_dir_all(parent).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let line = if notes.is_empty() {
|
||||
format!("- GPIO {}: {}\n", req.pin, component)
|
||||
} else {
|
||||
format!("- GPIO {}: {} — {}\n", req.pin, component, notes)
|
||||
};
|
||||
|
||||
match append_to_file(&device_path, &line).await {
|
||||
Ok(()) => {
|
||||
let message = format!(
|
||||
"GPIO {} registered as {} on {}",
|
||||
req.pin, component, req.device
|
||||
);
|
||||
tracing::info!(device = %req.device, pin = req.pin, component = %component, "{}", message);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "ok": true, "message": message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/hardware/context ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ContextAppendBody {
|
||||
/// Device alias (default: "rpi0").
|
||||
#[serde(default = "default_device")]
|
||||
pub device: String,
|
||||
/// Raw markdown string to append to the device file.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// `POST /api/hardware/context` — append raw markdown to a device file.
|
||||
pub async fn handle_hardware_context_post(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Result<Json<ContextAppendBody>, axum::extract::rejection::JsonRejection>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let Json(req) = match body {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": format!("Invalid JSON: {e}") })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if req.content.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "\"content\" must not be empty" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if req.content.len() > MAX_APPEND_BYTES {
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("Content too large — max {} bytes", MAX_APPEND_BYTES)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let hw_dir = match hardware_dir() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let device_path = match device_file_path(&hw_dir, &req.device) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(parent) = device_path.parent() {
|
||||
if let Err(e) = fs::create_dir_all(parent).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Failed to create directory: {e}") })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure content ends with a newline so successive appends don't merge lines.
|
||||
let mut content = req.content.clone();
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
match append_to_file(&device_path, &content).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(device = %req.device, bytes = content.len(), "Hardware context appended");
|
||||
(StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("Failed to write: {e}") })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /api/hardware/context ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HardwareContextResponse {
|
||||
hardware_md: String,
|
||||
devices: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// `GET /api/hardware/context` — return all current hardware context file contents.
|
||||
pub async fn handle_hardware_context_get(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let hw_dir = match hardware_dir() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": e })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// Read HARDWARE.md
|
||||
let hardware_md = fs::read_to_string(hw_dir.join("HARDWARE.md"))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Read all device files
|
||||
let devices_dir = hw_dir.join("devices");
|
||||
let mut devices = std::collections::HashMap::new();
|
||||
if let Ok(mut entries) = fs::read_dir(&devices_dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
let alias = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !alias.is_empty() {
|
||||
let content = fs::read_to_string(&path).await.unwrap_or_default();
|
||||
devices.insert(alias, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp = HardwareContextResponse {
|
||||
hardware_md,
|
||||
devices,
|
||||
};
|
||||
(StatusCode::OK, Json(resp)).into_response()
|
||||
}
|
||||
|
||||
// ── POST /api/hardware/reload ─────────────────────────────────────────────────
|
||||
|
||||
/// `POST /api/hardware/reload` — verify on-disk hardware context and report what
|
||||
/// will be loaded on the next chat request.
|
||||
///
|
||||
/// Since [`crate::hardware::boot`] re-reads from disk on every agent invocation,
|
||||
/// writing to the hardware files via the other endpoints already takes effect on
|
||||
/// the next `/api/chat` call. This endpoint reads the same files and reports
|
||||
/// the current state so callers can confirm the update landed.
|
||||
pub async fn handle_hardware_reload(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
// Count currently-registered tools in the gateway state
|
||||
let tool_count = state.tools_registry.len();
|
||||
|
||||
// Reload hardware context from disk (same function used by the agent loop)
|
||||
let context = crate::hardware::load_hardware_context_prompt(&[]);
|
||||
let context_length = context.len();
|
||||
|
||||
tracing::info!(
|
||||
context_length,
|
||||
tool_count,
|
||||
"Hardware context reloaded (on-disk read)"
|
||||
);
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"tools": tool_count,
|
||||
"context_length": context_length,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── File I/O helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn append_to_file(path: &std::path::Path, content: &str) -> std::io::Result<()> {
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await?;
|
||||
file.write_all(content.as_bytes()).await?;
|
||||
file.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
//! - Header sanitization (handled by axum/hyper)
|
||||
|
||||
pub mod api;
|
||||
mod hardware_context;
|
||||
mod openai_compat;
|
||||
mod openclaw_compat;
|
||||
pub mod sse;
|
||||
@ -837,6 +838,14 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/qq", post(handle_qq_webhook))
|
||||
// ── OpenClaw migration: tools-enabled chat endpoint ──
|
||||
.route("/api/chat", post(openclaw_compat::handle_api_chat))
|
||||
// ── Hardware context management endpoints ──
|
||||
.route("/api/hardware/pin", post(hardware_context::handle_hardware_pin))
|
||||
.route(
|
||||
"/api/hardware/context",
|
||||
get(hardware_context::handle_hardware_context_get)
|
||||
.post(hardware_context::handle_hardware_context_post),
|
||||
)
|
||||
.route("/api/hardware/reload", post(hardware_context::handle_hardware_reload))
|
||||
// ── OpenAI-compatible endpoints ──
|
||||
.route("/v1/models", get(openai_compat::handle_v1_models))
|
||||
.merge(openai_compat_routes)
|
||||
|
||||
@ -47,6 +47,11 @@ pub mod datasheet;
|
||||
|
||||
pub mod gpio;
|
||||
|
||||
/// Raspberry Pi self-discovery and native GPIO tools.
|
||||
/// Only compiled on Linux with the `peripheral-rpi` feature.
|
||||
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
|
||||
pub mod rpi;
|
||||
|
||||
// ── Phase 4: ToolRegistry + plugin system ─────────────────────────────────────
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
@ -191,6 +196,43 @@ fn load_hardware_context_from_dir(hw_dir: &std::path::Path, aliases: &[&str]) ->
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
/// Inject RPi self-discovery tools and system prompt context into the boot result.
|
||||
///
|
||||
/// Called from both `boot()` variants when the `peripheral-rpi` feature is active
|
||||
/// and the binary is running on Linux. If `/proc/device-tree/model` (or
|
||||
/// `/proc/cpuinfo`) identifies a Raspberry Pi, the four built-in GPIO/info
|
||||
/// tools are added to `tools` and the board description is appended to
|
||||
/// `context_files_prompt` so the LLM knows it is running on the device.
|
||||
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
|
||||
fn inject_rpi_context(
|
||||
tools: &mut Vec<Box<dyn crate::tools::Tool>>,
|
||||
context_files_prompt: &mut String,
|
||||
) {
|
||||
if let Some(ctx) = rpi::RpiSystemContext::discover() {
|
||||
tracing::info!(board = %ctx.model.display_name(), ip = %ctx.ip_address, "RPi self-discovery complete");
|
||||
if let Some(led) = ctx.model.onboard_led_gpio() {
|
||||
tracing::info!(gpio = led, "Onboard ACT LED");
|
||||
}
|
||||
println!("[registry] rpi0 ready \u{2192} /dev/gpiomem");
|
||||
if ctx.gpio_available {
|
||||
tools.push(Box::new(rpi::GpioRpiWriteTool));
|
||||
tools.push(Box::new(rpi::GpioRpiReadTool));
|
||||
tools.push(Box::new(rpi::GpioRpiBlinkTool));
|
||||
println!("[registry] loaded built-in: gpio_rpi_write");
|
||||
println!("[registry] loaded built-in: gpio_rpi_read");
|
||||
println!("[registry] loaded built-in: gpio_rpi_blink");
|
||||
}
|
||||
tools.push(Box::new(rpi::RpiSystemInfoTool));
|
||||
println!("[registry] loaded built-in: rpi_system_info");
|
||||
ctx.write_hardware_context_file();
|
||||
let rpi_prompt = ctx.to_system_prompt();
|
||||
if !context_files_prompt.is_empty() {
|
||||
context_files_prompt.push_str("\n\n");
|
||||
}
|
||||
context_files_prompt.push_str(&rpi_prompt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot the hardware subsystem: discover devices + load tool registry.
|
||||
///
|
||||
/// With the `hardware` feature: enumerates USB-serial devices, then
|
||||
@ -202,6 +244,7 @@ fn load_hardware_context_from_dir(hw_dir: &std::path::Path, aliases: &[&str]) ->
|
||||
/// with an empty device registry (GPIO tools will report "no device found"
|
||||
/// if called, which is correct).
|
||||
#[cfg(feature = "hardware")]
|
||||
#[allow(unused_mut)] // tools and context_files_prompt are mutated on Linux+peripheral-rpi
|
||||
pub async fn boot(
|
||||
peripherals: &crate::config::PeripheralsConfig,
|
||||
) -> anyhow::Result<HardwareBootResult> {
|
||||
@ -298,7 +341,7 @@ pub async fn boot(
|
||||
let reg = devices.read().await;
|
||||
reg.prompt_summary()
|
||||
};
|
||||
let tools = registry.into_tools();
|
||||
let mut tools = registry.into_tools();
|
||||
if !tools.is_empty() {
|
||||
tracing::info!(count = tools.len(), "Hardware registry tools loaded");
|
||||
}
|
||||
@ -310,10 +353,13 @@ pub async fn boot(
|
||||
.collect()
|
||||
};
|
||||
let alias_refs: Vec<&str> = alias_strings.iter().map(|s: &String| s.as_str()).collect();
|
||||
let context_files_prompt = load_hardware_context_prompt(&alias_refs);
|
||||
let mut context_files_prompt = load_hardware_context_prompt(&alias_refs);
|
||||
if !context_files_prompt.is_empty() {
|
||||
tracing::info!("Hardware context files loaded");
|
||||
}
|
||||
// RPi self-discovery: detect board model and inject GPIO tools + prompt context.
|
||||
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
|
||||
inject_rpi_context(&mut tools, &mut context_files_prompt);
|
||||
Ok(HardwareBootResult {
|
||||
tools,
|
||||
device_summary,
|
||||
@ -323,6 +369,7 @@ pub async fn boot(
|
||||
|
||||
/// Fallback when the `hardware` feature is disabled — plugins only.
|
||||
#[cfg(not(feature = "hardware"))]
|
||||
#[allow(unused_mut)] // tools and context_files_prompt are mutated on Linux+peripheral-rpi
|
||||
pub async fn boot(
|
||||
_peripherals: &crate::config::PeripheralsConfig,
|
||||
) -> anyhow::Result<HardwareBootResult> {
|
||||
@ -332,7 +379,7 @@ pub async fn boot(
|
||||
let reg = devices.read().await;
|
||||
reg.prompt_summary()
|
||||
};
|
||||
let tools = registry.into_tools();
|
||||
let mut tools = registry.into_tools();
|
||||
if !tools.is_empty() {
|
||||
tracing::info!(
|
||||
count = tools.len(),
|
||||
@ -340,7 +387,10 @@ pub async fn boot(
|
||||
);
|
||||
}
|
||||
// No discovered devices in no-hardware fallback; still load global files.
|
||||
let context_files_prompt = load_hardware_context_prompt(&[]);
|
||||
let mut context_files_prompt = load_hardware_context_prompt(&[]);
|
||||
// RPi self-discovery: detect board model and inject GPIO tools + prompt context.
|
||||
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
|
||||
inject_rpi_context(&mut tools, &mut context_files_prompt);
|
||||
Ok(HardwareBootResult {
|
||||
tools,
|
||||
device_summary,
|
||||
|
||||
558
src/hardware/rpi.rs
Normal file
558
src/hardware/rpi.rs
Normal file
@ -0,0 +1,558 @@
|
||||
//! Raspberry Pi self-discovery and native GPIO tools.
|
||||
//!
|
||||
//! Only compiled on Linux with the `peripheral-rpi` feature enabled.
|
||||
//!
|
||||
//! Provides two capabilities:
|
||||
//!
|
||||
//! 1. **Board detection** — `RpiModel` / `RpiSystemContext` detect which Pi model
|
||||
//! is running, its IP address, temperature, and GPIO availability. The result is
|
||||
//! injected into the system prompt so the LLM knows it is running *on* the device.
|
||||
//!
|
||||
//! 2. **Tool registration** — Four tools are auto-registered when an RPi board is
|
||||
//! detected at boot (no `[[peripherals.boards]]` config entry required):
|
||||
//! - `gpio_rpi_write` — set a GPIO pin HIGH / LOW
|
||||
//! - `gpio_rpi_read` — read a GPIO pin value
|
||||
//! - `gpio_rpi_blink` — blink a GPIO pin N times
|
||||
//! - `rpi_system_info` — return board model, RAM, temp, IP
|
||||
|
||||
use crate::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
// ─── Board model ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Detected Raspberry Pi model variant.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RpiModel {
|
||||
Rpi3B,
|
||||
Rpi3BPlus,
|
||||
Rpi4B,
|
||||
Rpi5,
|
||||
RpiZero2W,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl RpiModel {
|
||||
/// Detect RPi model from device-tree or /proc/cpuinfo.
|
||||
pub fn detect() -> Option<Self> {
|
||||
// Device tree model string is the most reliable source.
|
||||
if let Ok(raw) = fs::read_to_string("/proc/device-tree/model") {
|
||||
let model = raw.trim_end_matches('\0');
|
||||
return Some(Self::from_model_string(model));
|
||||
}
|
||||
// Fallback: scan /proc/cpuinfo for a "Model" line.
|
||||
if let Ok(cpuinfo) = fs::read_to_string("/proc/cpuinfo") {
|
||||
if cpuinfo.contains("Raspberry Pi") {
|
||||
for line in cpuinfo.lines() {
|
||||
if let Some(rest) = line.strip_prefix("Model") {
|
||||
let model = rest.trim_start_matches(':').trim();
|
||||
return Some(Self::from_model_string(model));
|
||||
}
|
||||
}
|
||||
return Some(Self::Unknown("Raspberry Pi (unknown model)".into()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn from_model_string(s: &str) -> Self {
|
||||
let lower = s.to_lowercase();
|
||||
if lower.contains("3 model b plus") || lower.contains("3b+") {
|
||||
Self::Rpi3BPlus
|
||||
} else if lower.contains("3 model b") || lower.contains("3b") {
|
||||
Self::Rpi3B
|
||||
} else if lower.contains("4 model b") || lower.contains("4b") {
|
||||
Self::Rpi4B
|
||||
} else if lower.contains("raspberry pi 5") || lower.contains(" 5 ") {
|
||||
Self::Rpi5
|
||||
} else if lower.contains("zero 2") {
|
||||
Self::RpiZero2W
|
||||
} else {
|
||||
Self::Unknown(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// BCM GPIO number of the on-board activity LED, if known.
|
||||
pub fn onboard_led_gpio(&self) -> Option<u8> {
|
||||
match self {
|
||||
Self::Rpi3B | Self::Rpi3BPlus => Some(47),
|
||||
Self::Rpi4B => Some(42),
|
||||
Self::Rpi5 => Some(9),
|
||||
Self::RpiZero2W => Some(29),
|
||||
Self::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable display name.
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Rpi3B => "Raspberry Pi 3 Model B",
|
||||
Self::Rpi3BPlus => "Raspberry Pi 3 Model B+",
|
||||
Self::Rpi4B => "Raspberry Pi 4 Model B",
|
||||
Self::Rpi5 => "Raspberry Pi 5",
|
||||
Self::RpiZero2W => "Raspberry Pi Zero 2 W",
|
||||
Self::Unknown(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── System context ──────────────────────────────────────────────────────────
|
||||
|
||||
/// System information discovered at boot when running on a Raspberry Pi.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RpiSystemContext {
|
||||
pub model: RpiModel,
|
||||
pub hostname: String,
|
||||
pub ip_address: String,
|
||||
pub wifi_interface: Option<String>,
|
||||
pub total_ram_mb: u64,
|
||||
pub free_ram_mb: u64,
|
||||
pub cpu_temp_celsius: Option<f32>,
|
||||
pub gpio_available: bool,
|
||||
}
|
||||
|
||||
impl RpiSystemContext {
|
||||
/// Attempt to detect the current board and collect system info.
|
||||
/// Returns `None` when not running on a Raspberry Pi.
|
||||
pub fn discover() -> Option<Self> {
|
||||
let model = RpiModel::detect()?;
|
||||
|
||||
let hostname = fs::read_to_string("/etc/hostname")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let ip_address = Self::get_ip_address();
|
||||
let wifi_interface = Self::get_wifi_interface();
|
||||
let (total_ram_mb, free_ram_mb) = Self::get_memory_info();
|
||||
let cpu_temp_celsius = Self::get_cpu_temp();
|
||||
let gpio_available = std::path::Path::new("/dev/gpiomem").exists();
|
||||
|
||||
Some(Self {
|
||||
model,
|
||||
hostname,
|
||||
ip_address,
|
||||
wifi_interface,
|
||||
total_ram_mb,
|
||||
free_ram_mb,
|
||||
cpu_temp_celsius,
|
||||
gpio_available,
|
||||
})
|
||||
}
|
||||
|
||||
/// Determine the primary non-loopback IPv4 address using a UDP routing trick.
|
||||
/// No packet is ever sent — we just resolve the outbound route.
|
||||
fn get_ip_address() -> String {
|
||||
use std::net::UdpSocket;
|
||||
UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|s| {
|
||||
s.connect("8.8.8.8:80")?;
|
||||
s.local_addr()
|
||||
})
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Returns the first wireless interface name listed in /proc/net/wireless, if any.
|
||||
fn get_wifi_interface() -> Option<String> {
|
||||
let text = fs::read_to_string("/proc/net/wireless").ok()?;
|
||||
text.lines()
|
||||
.skip(2) // header rows
|
||||
.find(|l| l.contains(':'))
|
||||
.map(|l| {
|
||||
l.split(':')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Read MemTotal and MemAvailable from /proc/meminfo and return (total_mb, free_mb).
|
||||
fn get_memory_info() -> (u64, u64) {
|
||||
let meminfo = fs::read_to_string("/proc/meminfo").unwrap_or_default();
|
||||
let mut total = 0u64;
|
||||
let mut available = 0u64;
|
||||
for line in meminfo.lines() {
|
||||
if line.starts_with("MemTotal:") {
|
||||
total = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0)
|
||||
/ 1024;
|
||||
}
|
||||
if line.starts_with("MemAvailable:") {
|
||||
available = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0)
|
||||
/ 1024;
|
||||
}
|
||||
}
|
||||
(total, available)
|
||||
}
|
||||
|
||||
/// Read CPU temperature from the thermal zone sysfs file (millidegrees → °C).
|
||||
fn get_cpu_temp() -> Option<f32> {
|
||||
fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<f32>().ok())
|
||||
.map(|t| t / 1000.0)
|
||||
}
|
||||
|
||||
/// Generate the system prompt section that describes this device to the LLM.
|
||||
pub fn to_system_prompt(&self) -> String {
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "## Running On Device (Raspberry Pi)");
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "- Board: {}", self.model.display_name());
|
||||
let _ = writeln!(s, "- Hostname: {}", self.hostname);
|
||||
let _ = writeln!(s, "- IP Address: {}", self.ip_address);
|
||||
if let Some(ref iface) = self.wifi_interface {
|
||||
let _ = writeln!(s, "- WiFi interface: {}", iface);
|
||||
}
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"- RAM: {}MB total, {}MB available",
|
||||
self.total_ram_mb, self.free_ram_mb
|
||||
);
|
||||
if let Some(temp) = self.cpu_temp_celsius {
|
||||
let _ = writeln!(s, "- CPU Temperature: {:.1}°C", temp);
|
||||
}
|
||||
if let Some(led_pin) = self.model.onboard_led_gpio() {
|
||||
let _ = writeln!(s, "- Onboard ACT LED: BCM GPIO {}", led_pin);
|
||||
}
|
||||
if self.gpio_available {
|
||||
let _ = writeln!(s, "- GPIO: available via rppal (/dev/gpiomem)");
|
||||
let _ = writeln!(s);
|
||||
s.push_str(
|
||||
"Use `gpio_rpi_write`, `gpio_rpi_read`, and `gpio_rpi_blink` for all GPIO \
|
||||
operations — they access /dev/gpiomem directly, no serial port or mpremote needed.\n",
|
||||
);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Write an `rpi0.md` hardware context file to `~/.zeroclaw/hardware/devices/`.
|
||||
/// Silently skips on failure so boot is never blocked.
|
||||
pub fn write_hardware_context_file(&self) {
|
||||
let Some(home) = directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()) else {
|
||||
return;
|
||||
};
|
||||
let devices_dir = home.join(".zeroclaw").join("hardware").join("devices");
|
||||
if let Err(e) = fs::create_dir_all(&devices_dir) {
|
||||
tracing::warn!("Failed to create hardware devices dir: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let path = devices_dir.join("rpi0.md");
|
||||
let content = self.device_profile_markdown();
|
||||
if let Err(e) = fs::write(&path, &content) {
|
||||
tracing::warn!("Failed to write rpi0.md: {e}");
|
||||
} else {
|
||||
tracing::debug!(path = %path.display(), "Wrote rpi0.md hardware context file");
|
||||
}
|
||||
}
|
||||
|
||||
fn device_profile_markdown(&self) -> String {
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "# rpi0 — {}", self.model.display_name());
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "## System");
|
||||
let _ = writeln!(s, "- Hostname: {}", self.hostname);
|
||||
let _ = writeln!(s, "- IP: {} (at last boot)", self.ip_address);
|
||||
let _ = writeln!(s, "- RAM: {}MB total", self.total_ram_mb);
|
||||
let _ = writeln!(s, "- Runtime: ZeroClaw native (rppal — no serial, no mpremote)");
|
||||
if let Some(ref iface) = self.wifi_interface {
|
||||
let _ = writeln!(s, "- WiFi interface: {}", iface);
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "## GPIO — BCM numbering");
|
||||
if let Some(led_pin) = self.model.onboard_led_gpio() {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"- GPIO {led_pin}: ACT LED (onboard green LED) — use gpio_rpi_write/blink"
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s, "- GPIO 2/3: I2C SDA/SCL");
|
||||
let _ = writeln!(s, "- GPIO 7-11: SPI");
|
||||
let _ = writeln!(s, "- All other BCM pins: general purpose");
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "## Tool Usage Rules");
|
||||
let _ = writeln!(s, "- Single pin on/off → `gpio_rpi_write(pin, value)`");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"- Blink/repeat → `gpio_rpi_blink(pin, times, on_ms, off_ms)`"
|
||||
);
|
||||
let _ = writeln!(s, "- Read pin → `gpio_rpi_read(pin)`");
|
||||
let _ = writeln!(s, "- System stats → `rpi_system_info()`");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"- DO NOT use `device_exec` or `mpremote` — not available on this board"
|
||||
);
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"- DO NOT use `gpio_write` (serial JSON) — use `gpio_rpi_write` instead"
|
||||
);
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool: gpio_rpi_write ────────────────────────────────────────────────────
|
||||
|
||||
/// Set a GPIO pin HIGH or LOW directly on this Raspberry Pi via rppal.
|
||||
pub struct GpioRpiWriteTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GpioRpiWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_rpi_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Set a GPIO pin HIGH (1) or LOW (0) directly on this Raspberry Pi. \
|
||||
Uses BCM pin numbers (e.g. 47 for the ACT LED on RPi 3B). \
|
||||
No serial port needed — accesses /dev/gpiomem via rppal."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "BCM GPIO number (e.g. 47 for ACT LED on RPi 3B)"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"description": "1 for HIGH, 0 for LOW"
|
||||
}
|
||||
},
|
||||
"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"))?
|
||||
as u8;
|
||||
let value = args
|
||||
.get("value")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
|
||||
let level = if value == 0 {
|
||||
rppal::gpio::Level::Low
|
||||
} else {
|
||||
rppal::gpio::Level::High
|
||||
};
|
||||
let state = if value == 0 { "LOW" } else { "HIGH" };
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let gpio = rppal::gpio::Gpio::new()?;
|
||||
let mut pin = gpio.get(pin)?.into_output();
|
||||
pin.write(level);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("GPIO {} → {}", pin, state),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool: gpio_rpi_read ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read a GPIO pin value on this Raspberry Pi via rppal.
|
||||
pub struct GpioRpiReadTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GpioRpiReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_rpi_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the current state (0 or 1) of a GPIO pin on this Raspberry Pi. \
|
||||
Uses BCM pin numbers."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "BCM GPIO number"
|
||||
}
|
||||
},
|
||||
"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"))?
|
||||
as u8;
|
||||
|
||||
let value = tokio::task::spawn_blocking(move || {
|
||||
let gpio = rppal::gpio::Gpio::new()?;
|
||||
let p = gpio.get(pin)?.into_input();
|
||||
Ok::<_, anyhow::Error>(match p.read() {
|
||||
rppal::gpio::Level::Low => 0u8,
|
||||
rppal::gpio::Level::High => 1u8,
|
||||
})
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: json!({ "pin": pin, "value": value, "state": if value == 0 { "LOW" } else { "HIGH" } }).to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool: gpio_rpi_blink ────────────────────────────────────────────────────
|
||||
|
||||
/// Blink a GPIO pin N times with configurable on/off timing via rppal.
|
||||
pub struct GpioRpiBlinkTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GpioRpiBlinkTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_rpi_blink"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Blink a GPIO pin N times with configurable on/off durations on this Raspberry Pi. \
|
||||
Suitable for LEDs, buzzers, or any repeated toggle. Uses BCM pin numbers."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "BCM GPIO number (e.g. 47 for ACT LED on RPi 3B)"
|
||||
},
|
||||
"times": {
|
||||
"type": "integer",
|
||||
"description": "Number of blink cycles (default 3)"
|
||||
},
|
||||
"on_ms": {
|
||||
"type": "integer",
|
||||
"description": "Milliseconds pin stays HIGH per cycle (default 500)"
|
||||
},
|
||||
"off_ms": {
|
||||
"type": "integer",
|
||||
"description": "Milliseconds pin stays LOW between cycles (default 500)"
|
||||
}
|
||||
},
|
||||
"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"))?
|
||||
as u8;
|
||||
let times = args
|
||||
.get("times")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(3)
|
||||
.min(100); // cap at 100 blinks to prevent runaway
|
||||
let on_ms = args
|
||||
.get("on_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(500)
|
||||
.min(10_000); // cap at 10s
|
||||
let off_ms = args
|
||||
.get("off_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(500)
|
||||
.min(10_000);
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let gpio = rppal::gpio::Gpio::new()?;
|
||||
let mut p = gpio.get(pin)?.into_output();
|
||||
for _ in 0..times {
|
||||
p.set_high();
|
||||
std::thread::sleep(Duration::from_millis(on_ms));
|
||||
p.set_low();
|
||||
std::thread::sleep(Duration::from_millis(off_ms));
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Blinked GPIO {} × {} ({}/{}ms)", pin, times, on_ms, off_ms),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool: rpi_system_info ───────────────────────────────────────────────────
|
||||
|
||||
/// Return current Raspberry Pi system information as JSON.
|
||||
pub struct RpiSystemInfoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RpiSystemInfoTool {
|
||||
fn name(&self) -> &str {
|
||||
"rpi_system_info"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Get current system information for this Raspberry Pi: model, RAM, \
|
||||
CPU temperature, IP address, and WiFi interface."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
|
||||
let ctx = RpiSystemContext::discover()
|
||||
.ok_or_else(|| anyhow::anyhow!("Not running on a Raspberry Pi"))?;
|
||||
|
||||
let info = json!({
|
||||
"model": ctx.model.display_name(),
|
||||
"hostname": ctx.hostname,
|
||||
"ip_address": ctx.ip_address,
|
||||
"wifi_interface": ctx.wifi_interface,
|
||||
"ram_total_mb": ctx.total_ram_mb,
|
||||
"ram_free_mb": ctx.free_ram_mb,
|
||||
"cpu_temp_celsius": ctx.cpu_temp_celsius,
|
||||
"gpio_available": ctx.gpio_available,
|
||||
"onboard_led_gpio": ctx.model.onboard_led_gpio(),
|
||||
});
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: info.to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user