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:
ehushubhamshaw 2026-03-08 19:36:38 -04:00
parent bdecd6bbb3
commit 15816dfaa0
10 changed files with 1409 additions and 4 deletions

View File

@ -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
View 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 |
| GP2628 | ADC02 | 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 (GP11GP14 read)
- Internal temperature sensor demo (ADC4)
- Commit current working state to branch

View File

@ -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
View 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
View 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
View 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

View 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 164 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(())
}

View File

@ -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)

View File

@ -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
View 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,
})
}
}