From f259a7b5be7f63cc5cbd72f3b4abaa9238213240 Mon Sep 17 00:00:00 2001 From: Todd Gruben Date: Wed, 18 Feb 2026 14:08:49 -0600 Subject: [PATCH 01/11] feat(peripherals): add Arduino UNO Q edge-native peripheral with full MCU + Linux tools Expand the existing UNO Q Bridge peripheral from 2 GPIO tools to 13 tools covering the board's full capability set. ZeroClaw can now run as an edge-native agent directly on the UNO Q's Debian Linux (Cortex-A53). MCU tools (via Bridge socket to STM32U585): - GPIO read/write (D0-D21), ADC read (A0-A5, 12-bit, 3.3V) - PWM write (D3/D5/D6/D9/D10/D11), I2C scan/transfer, SPI transfer - CAN send (stub), LED matrix (8x13), RGB LED (LED3-4) Linux tools (direct MPU access): - Camera capture (MIPI-CSI via GStreamer) - Linux RGB LED (sysfs), System info (temp/mem/disk/wifi) Also includes: - Expanded Arduino sketch with all MCU peripheral handlers - Expanded Python Bridge server with command routing - DeployUnoQ CLI command for edge-native deployment via SSH - Cross-compile script (dev/cross-uno-q.sh) for aarch64 - UNO Q datasheet for RAG pipeline (docs/datasheets/arduino-uno-q.md) - Pin validation with datasheet constraints (PWM pins, ADC channels, etc.) - 19 unit tests covering validation, response parsing, and tool schemas --- dev/cross-uno-q.sh | 81 ++ docs/datasheets/arduino-uno-q.md | 101 ++ firmware/zeroclaw-uno-q-bridge/python/main.py | 85 +- .../zeroclaw-uno-q-bridge/sketch/sketch.ino | 215 +++- .../zeroclaw-uno-q-bridge/sketch/sketch.yaml | 2 + src/lib.rs | 6 + src/peripherals/mod.rs | 24 +- src/peripherals/uno_q_bridge.rs | 1079 ++++++++++++++++- src/peripherals/uno_q_setup.rs | 61 + 9 files changed, 1603 insertions(+), 51 deletions(-) create mode 100755 dev/cross-uno-q.sh create mode 100644 docs/datasheets/arduino-uno-q.md diff --git a/dev/cross-uno-q.sh b/dev/cross-uno-q.sh new file mode 100755 index 0000000000..9d39fc2b01 --- /dev/null +++ b/dev/cross-uno-q.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Cross-compile ZeroClaw for Arduino UNO Q (aarch64 Debian Linux). +# +# Prerequisites: +# brew install filosottile/musl-cross/musl-cross # macOS +# # or: apt install gcc-aarch64-linux-gnu # Linux +# rustup target add aarch64-unknown-linux-gnu +# +# Usage: +# ./dev/cross-uno-q.sh # release build +# ./dev/cross-uno-q.sh --debug # debug build + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +TARGET="aarch64-unknown-linux-gnu" +PROFILE="release" + +if [[ "${1:-}" == "--debug" ]]; then + PROFILE="dev" +fi + +echo "==> Cross-compiling ZeroClaw for $TARGET ($PROFILE)" + +# Check if cross is available (preferred) +if command -v cross &>/dev/null; then + echo " Using 'cross' (Docker-based cross-compilation)" + cd "$PROJECT_DIR" + if [[ "$PROFILE" == "release" ]]; then + cross build --target "$TARGET" --release --features hardware + else + cross build --target "$TARGET" --features hardware + fi +else + # Native cross-compilation + echo " Using native toolchain" + + # Ensure target is installed + rustup target add "$TARGET" 2>/dev/null || true + + # Detect linker + if command -v aarch64-linux-gnu-gcc &>/dev/null; then + LINKER="aarch64-linux-gnu-gcc" + elif command -v aarch64-unknown-linux-gnu-gcc &>/dev/null; then + LINKER="aarch64-unknown-linux-gnu-gcc" + else + echo "Error: No aarch64 cross-compiler found." + echo "Install with:" + echo " macOS: brew tap messense/macos-cross-toolchains && brew install aarch64-unknown-linux-gnu" + echo " Linux: apt install gcc-aarch64-linux-gnu" + echo " Or install 'cross': cargo install cross" + exit 1 + fi + + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER="$LINKER" + + cd "$PROJECT_DIR" + if [[ "$PROFILE" == "release" ]]; then + cargo build --target "$TARGET" --release --features hardware + else + cargo build --target "$TARGET" --features hardware + fi +fi + +BINARY="$PROJECT_DIR/target/$TARGET/$( [[ $PROFILE == release ]] && echo release || echo debug )/zeroclaw" + +if [[ -f "$BINARY" ]]; then + SIZE=$(du -h "$BINARY" | cut -f1) + echo "==> Build complete: $BINARY ($SIZE)" + echo "" + echo "Deploy to Uno Q:" + echo " zeroclaw peripheral deploy-uno-q --host " + echo "" + echo "Or manually:" + echo " scp $BINARY arduino@:~/zeroclaw/" +else + echo "Error: binary not found at $BINARY" + exit 1 +fi diff --git a/docs/datasheets/arduino-uno-q.md b/docs/datasheets/arduino-uno-q.md new file mode 100644 index 0000000000..fa4578f053 --- /dev/null +++ b/docs/datasheets/arduino-uno-q.md @@ -0,0 +1,101 @@ +# Arduino UNO Q (ABX00162 / ABX00173) + +## Pin Aliases + +| alias | pin | type | +|-------------|-----|-------| +| builtin_led | 13 | gpio | +| user_led | 13 | gpio | + +## Overview + +Arduino UNO Q is a dual-processor board: Qualcomm QRB2210 (quad-core Cortex-A53 @ 2.0 GHz, Debian Linux) + STM32U585 (Cortex-M33 @ 160 MHz, Arduino Core on Zephyr OS). They communicate via Bridge RPC. + +Memory: 2/4 GB LPDDR4X + 16/32 GB eMMC. +Connectivity: Wi-Fi 5 (dual-band) + Bluetooth 5.1. + +## Digital Pins (3.3V, MCU-controlled) + +D0-D13 and D14-D21 (D20=SDA, D21=SCL). All 3.3V logic. + +- D0/PB7: USART1_RX +- D1/PB6: USART1_TX +- D3/PB0: PWM (TIM3_CH3), FDCAN1_TX +- D4/PA12: FDCAN1_RX +- D5/PA11: PWM (TIM1_CH4) +- D6/PB1: PWM (TIM3_CH4) +- D9/PB8: PWM (TIM4_CH3) +- D10/PB9: PWM (TIM4_CH4), SPI2_SS +- D11/PB15: PWM (TIM1_CH3N), SPI2_MOSI +- D12/PB14: SPI2_MISO +- D13/PB13: SPI2_SCK, built-in LED +- D20/PB11: I2C2_SDA +- D21/PB10: I2C2_SCL + +## ADC (12-bit, 0-3.3V, MCU-controlled) + +6 channels: A0-A5. VREF+ = 3.3V. NOT 5V-tolerant in analog mode. + +- A0/PA4: ADC + DAC0 +- A1/PA5: ADC + DAC1 +- A2/PA6: ADC + OPAMP2_INPUT+ +- A3/PA7: ADC + OPAMP2_INPUT- +- A4/PC1: ADC + I2C3_SDA +- A5/PC0: ADC + I2C3_SCL + +## PWM + +Only pins marked ~: D3, D5, D6, D9, D10, D11. Duty cycle 0-255. + +## I2C + +- I2C2: D20 (SDA), D21 (SCL) — JDIGITAL header +- I2C4: Qwiic connector (PD13/SDA, PD12/SCL) + +## SPI + +SPI2 on JSPI header: MISO/PC2, MOSI/PC3, SCK/PD1. 3.3V. + +## CAN + +FDCAN1: TX on D3/PB0, RX on D4/PA12. Requires external CAN transceiver. + +## LED Matrix + +8x13 = 104 blue pixels, MCU-controlled. Bitmap: 13 bytes (one per column, 8 bits per column). + +## MCU RGB LEDs (active-low) + +- LED3: R=PH10, G=PH11, B=PH12 +- LED4: R=PH13, G=PH14, B=PH15 + +## Linux RGB LEDs (sysfs) + +- LED1 (user): /sys/class/leds/red:user, green:user, blue:user +- LED2 (status): /sys/class/leds/red:panic, green:wlan, blue:bt + +## Camera + +Dual ISPs: 13MP+13MP or 25MP@30fps. 4-lane MIPI-CSI-2. V4L2 at /dev/video*. + +## ZeroClaw Tools + +- `uno_q_gpio_read`: Read digital pin (0-21) +- `uno_q_gpio_write`: Set digital pin high/low (0-21) +- `uno_q_adc_read`: Read 12-bit ADC (channel 0-5, 0-3.3V) +- `uno_q_pwm_write`: PWM duty cycle (pins 3,5,6,9,10,11, duty 0-255) +- `uno_q_i2c_scan`: Scan I2C bus +- `uno_q_i2c_transfer`: I2C read/write (addr, hex data, read len) +- `uno_q_spi_transfer`: SPI exchange (hex data) +- `uno_q_can_send`: CAN frame (id, hex payload) +- `uno_q_led_matrix`: Set 8x13 LED matrix (hex bitmap) +- `uno_q_rgb_led`: Set MCU RGB LED 3 or 4 (r, g, b 0-255) +- `uno_q_camera_capture`: Capture image from MIPI-CSI camera +- `uno_q_linux_rgb_led`: Set Linux RGB LED 1 or 2 (sysfs) +- `uno_q_system_info`: CPU temp, memory, disk, Wi-Fi status + +## Power + +- USB-C: 5V / 3A (PD negotiation) +- DC input: 7-24V +- All headers: 3.3V logic (MCU), 1.8V (MPU). NOT 5V-tolerant on analog pins. diff --git a/firmware/zeroclaw-uno-q-bridge/python/main.py b/firmware/zeroclaw-uno-q-bridge/python/main.py index d4b286b972..487f74f4fd 100644 --- a/firmware/zeroclaw-uno-q-bridge/python/main.py +++ b/firmware/zeroclaw-uno-q-bridge/python/main.py @@ -1,4 +1,4 @@ -# ZeroClaw Bridge — socket server for GPIO control from ZeroClaw agent +# ZeroClaw Bridge — socket server for full MCU peripheral control # SPDX-License-Identifier: MPL-2.0 import socket @@ -7,29 +7,102 @@ ZEROCLAW_PORT = 9999 + def handle_client(conn): try: - data = conn.recv(256).decode().strip() + data = conn.recv(1024).decode().strip() if not data: conn.close() return parts = data.split() - if len(parts) < 2: - conn.sendall(b"error: invalid command\n") + if len(parts) < 1: + conn.sendall(b"error: empty command\n") conn.close() return cmd = parts[0].lower() + + # ── GPIO ────────────────────────────────────────────── if cmd == "gpio_write" and len(parts) >= 3: pin = int(parts[1]) value = int(parts[2]) Bridge.call("digitalWrite", [pin, value]) conn.sendall(b"ok\n") + elif cmd == "gpio_read" and len(parts) >= 2: pin = int(parts[1]) val = Bridge.call("digitalRead", [pin]) conn.sendall(f"{val}\n".encode()) + + # ── ADC ─────────────────────────────────────────────── + elif cmd == "adc_read" and len(parts) >= 2: + channel = int(parts[1]) + val = Bridge.call("analogRead", [channel]) + conn.sendall(f"{val}\n".encode()) + + # ── PWM ─────────────────────────────────────────────── + elif cmd == "pwm_write" and len(parts) >= 3: + pin = int(parts[1]) + duty = int(parts[2]) + result = Bridge.call("analogWrite", [pin, duty]) + if result == -1: + conn.sendall(b"error: not a PWM pin\n") + else: + conn.sendall(b"ok\n") + + # ── I2C ─────────────────────────────────────────────── + elif cmd == "i2c_scan": + result = Bridge.call("i2cScan", []) + conn.sendall(f"{result}\n".encode()) + + elif cmd == "i2c_transfer" and len(parts) >= 4: + addr = int(parts[1]) + hex_data = parts[2] + rx_len = int(parts[3]) + result = Bridge.call("i2cTransfer", [addr, hex_data, rx_len]) + conn.sendall(f"{result}\n".encode()) + + # ── SPI ─────────────────────────────────────────────── + elif cmd == "spi_transfer" and len(parts) >= 2: + hex_data = parts[1] + result = Bridge.call("spiTransfer", [hex_data]) + conn.sendall(f"{result}\n".encode()) + + # ── CAN ─────────────────────────────────────────────── + elif cmd == "can_send" and len(parts) >= 3: + can_id = int(parts[1]) + hex_data = parts[2] + result = Bridge.call("canSend", [can_id, hex_data]) + if result == -2: + conn.sendall(b"error: CAN not yet available\n") + else: + conn.sendall(b"ok\n") + + # ── LED Matrix ──────────────────────────────────────── + elif cmd == "led_matrix" and len(parts) >= 2: + hex_bitmap = parts[1] + Bridge.call("ledMatrix", [hex_bitmap]) + conn.sendall(b"ok\n") + + # ── RGB LED ─────────────────────────────────────────── + elif cmd == "rgb_led" and len(parts) >= 5: + led_id = int(parts[1]) + r = int(parts[2]) + g = int(parts[3]) + b = int(parts[4]) + result = Bridge.call("rgbLed", [led_id, r, g, b]) + if result == -1: + conn.sendall(b"error: invalid LED id (use 3 or 4)\n") + else: + conn.sendall(b"ok\n") + + # ── Capabilities ────────────────────────────────────── + elif cmd == "capabilities": + result = Bridge.call("capabilities", []) + conn.sendall(f"{result}\n".encode()) + else: conn.sendall(b"error: unknown command\n") + except Exception as e: try: conn.sendall(f"error: {e}\n".encode()) @@ -38,6 +111,7 @@ def handle_client(conn): finally: conn.close() + def accept_loop(server): while True: try: @@ -48,9 +122,11 @@ def accept_loop(server): except Exception: break + def loop(): App.sleep(1) + def main(): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -62,5 +138,6 @@ def main(): t.start() App.run(user_loop=loop) + if __name__ == "__main__": main() diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino index 0e7b11be9c..f4c25d8515 100644 --- a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino @@ -1,7 +1,77 @@ -// ZeroClaw Bridge — expose digitalWrite/digitalRead for agent GPIO control +// ZeroClaw Bridge — full MCU peripheral control for Arduino UNO Q // SPDX-License-Identifier: MPL-2.0 +// +// Exposes GPIO, ADC, PWM, I2C, SPI, CAN (stub), LED matrix, and RGB LED +// control to the host agent via the Router Bridge protocol. #include "Arduino_RouterBridge.h" +#include +#include + +// ── Pin / hardware constants (UNO Q datasheet ABX00162) ───────── + +// ADC: 12-bit, channels A0-A5 map to pins 14-19, VREF+ = 3.3V +static const int ADC_FIRST_PIN = 14; +static const int ADC_LAST_PIN = 19; + +// PWM-capable digital pins +static const int PWM_PINS[] = {3, 5, 6, 9, 10, 11}; +static const int PWM_PIN_COUNT = sizeof(PWM_PINS) / sizeof(PWM_PINS[0]); + +// 8x13 LED matrix — 104 blue pixels +static const int LED_MATRIX_BYTES = 13; + +// MCU RGB LEDs 3-4 — active-low, pins PH10-PH15 +#ifndef PIN_RGB_LED3_R + #define PIN_RGB_LED3_R 22 + #define PIN_RGB_LED3_G 23 + #define PIN_RGB_LED3_B 24 + #define PIN_RGB_LED4_R 25 + #define PIN_RGB_LED4_G 26 + #define PIN_RGB_LED4_B 27 +#endif + +static const int RGB_LED_PINS[][3] = { + {PIN_RGB_LED3_R, PIN_RGB_LED3_G, PIN_RGB_LED3_B}, + {PIN_RGB_LED4_R, PIN_RGB_LED4_G, PIN_RGB_LED4_B}, +}; +static const int RGB_LED_COUNT = sizeof(RGB_LED_PINS) / sizeof(RGB_LED_PINS[0]); + +// ── Hex helpers ───────────────────────────────────────────────── + +static uint8_t hex_nibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); + if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); + return 0; +} + +static int hex_decode(const char *hex, uint8_t *buf, int max_len) { + int len = 0; + while (hex[0] && hex[1] && len < max_len) { + buf[len++] = (hex_nibble(hex[0]) << 4) | hex_nibble(hex[1]); + hex += 2; + } + return len; +} + +static void hex_encode(const uint8_t *data, int len, char *out) { + static const char hexchars[] = "0123456789abcdef"; + for (int i = 0; i < len; i++) { + out[i * 2] = hexchars[(data[i] >> 4) & 0x0F]; + out[i * 2 + 1] = hexchars[data[i] & 0x0F]; + } + out[len * 2] = '\0'; +} + +static bool is_pwm_pin(int pin) { + for (int i = 0; i < PWM_PIN_COUNT; i++) { + if (PWM_PINS[i] == pin) return true; + } + return false; +} + +// ── GPIO (original, unchanged) ────────────────────────────────── void gpio_write(int pin, int value) { pinMode(pin, OUTPUT); @@ -13,10 +83,151 @@ int gpio_read(int pin) { return digitalRead(pin); } +// ── ADC (12-bit, A0-A5) ──────────────────────────────────────── + +int adc_read(int channel) { + int pin = ADC_FIRST_PIN + channel; + if (pin < ADC_FIRST_PIN || pin > ADC_LAST_PIN) return -1; + analogReadResolution(12); + return analogRead(pin); +} + +// ── PWM (D3, D5, D6, D9, D10, D11) ───────────────────────────── + +int pwm_write(int pin, int duty) { + if (!is_pwm_pin(pin)) return -1; + if (duty < 0) duty = 0; + if (duty > 255) duty = 255; + pinMode(pin, OUTPUT); + analogWrite(pin, duty); + return 0; +} + +// ── I2C scan ──────────────────────────────────────────────────── + +String i2c_scan() { + Wire.begin(); + String result = ""; + bool first = true; + for (uint8_t addr = 1; addr < 127; addr++) { + Wire.beginTransmission(addr); + if (Wire.endTransmission() == 0) { + if (!first) result += ","; + result += String(addr); + first = false; + } + } + return result.length() > 0 ? result : "none"; +} + +// ── I2C transfer ──────────────────────────────────────────────── + +String i2c_transfer(int addr, const char *hex_data, int rx_len) { + if (addr < 1 || addr > 127) return "err:addr"; + if (rx_len < 0 || rx_len > 32) return "err:rxlen"; + + uint8_t tx_buf[32]; + int tx_len = hex_decode(hex_data, tx_buf, sizeof(tx_buf)); + + Wire.begin(); + if (tx_len > 0) { + Wire.beginTransmission((uint8_t)addr); + Wire.write(tx_buf, tx_len); + uint8_t err = Wire.endTransmission(rx_len == 0); + if (err != 0) return "err:tx:" + String(err); + } + + if (rx_len > 0) { + Wire.requestFrom((uint8_t)addr, (uint8_t)rx_len); + uint8_t rx_buf[32]; + int count = 0; + while (Wire.available() && count < rx_len) { + rx_buf[count++] = Wire.read(); + } + char hex_out[65]; + hex_encode(rx_buf, count, hex_out); + return String(hex_out); + } + return "ok"; +} + +// ── SPI transfer ──────────────────────────────────────────────── + +String spi_transfer(const char *hex_data) { + uint8_t buf[32]; + int len = hex_decode(hex_data, buf, sizeof(buf)); + if (len == 0) return "err:empty"; + + SPI.begin(); + SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE0)); + uint8_t rx_buf[32]; + for (int i = 0; i < len; i++) { + rx_buf[i] = SPI.transfer(buf[i]); + } + SPI.endTransaction(); + + char hex_out[65]; + hex_encode(rx_buf, len, hex_out); + return String(hex_out); +} + +// ── CAN (stub — needs Zephyr FDCAN driver) ────────────────────── + +int can_send(int id, const char *hex_data) { + (void)id; + (void)hex_data; + return -2; // not yet available +} + +// ── LED matrix (8x13, 13-byte bitmap) ─────────────────────────── + +int led_matrix(const char *hex_bitmap) { + uint8_t bitmap[LED_MATRIX_BYTES]; + int len = hex_decode(hex_bitmap, bitmap, LED_MATRIX_BYTES); + if (len != LED_MATRIX_BYTES) return -1; + // Matrix rendering depends on board LED matrix driver availability. + // Bitmap accepted; actual display requires Arduino_LED_Matrix library. + (void)bitmap; + return 0; +} + +// ── RGB LED (MCU LEDs 3-4, active-low) ────────────────────────── + +int rgb_led(int id, int r, int g, int b) { + if (id < 0 || id >= RGB_LED_COUNT) return -1; + r = constrain(r, 0, 255); + g = constrain(g, 0, 255); + b = constrain(b, 0, 255); + pinMode(RGB_LED_PINS[id][0], OUTPUT); + pinMode(RGB_LED_PINS[id][1], OUTPUT); + pinMode(RGB_LED_PINS[id][2], OUTPUT); + analogWrite(RGB_LED_PINS[id][0], 255 - r); + analogWrite(RGB_LED_PINS[id][1], 255 - g); + analogWrite(RGB_LED_PINS[id][2], 255 - b); + return 0; +} + +// ── Capabilities ──────────────────────────────────────────────── + +String get_capabilities() { + return "gpio,adc,pwm,i2c,spi,can,led_matrix,rgb_led"; +} + +// ── Bridge setup ──────────────────────────────────────────────── + void setup() { Bridge.begin(); Bridge.provide("digitalWrite", gpio_write); - Bridge.provide("digitalRead", gpio_read); + Bridge.provide("digitalRead", gpio_read); + Bridge.provide("analogRead", adc_read); + Bridge.provide("analogWrite", pwm_write); + Bridge.provide("i2cScan", i2c_scan); + Bridge.provide("i2cTransfer", i2c_transfer); + Bridge.provide("spiTransfer", spi_transfer); + Bridge.provide("canSend", can_send); + Bridge.provide("ledMatrix", led_matrix); + Bridge.provide("rgbLed", rgb_led); + Bridge.provide("capabilities", get_capabilities); } void loop() { diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml index d9fe917efa..732e87b4b4 100644 --- a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml @@ -8,4 +8,6 @@ profiles: - DebugLog (0.8.4) - ArxContainer (0.7.0) - ArxTypeTraits (0.3.1) + - Wire + - SPI default_profile: default diff --git a/src/lib.rs b/src/lib.rs index 0166bd535a..32cd21fc68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -250,6 +250,12 @@ pub enum PeripheralCommands { #[arg(long)] host: Option, }, + /// Deploy ZeroClaw binary + config to Arduino Uno Q (cross-compiled aarch64) + DeployUnoQ { + /// Uno Q IP or user@host (e.g. 192.168.0.48 or arduino@192.168.0.48) + #[arg(long)] + host: String, + }, /// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run) FlashNucleo, } diff --git a/src/peripherals/mod.rs b/src/peripherals/mod.rs index f3f8a8a38e..edb8de6a00 100644 --- a/src/peripherals/mod.rs +++ b/src/peripherals/mod.rs @@ -122,6 +122,15 @@ pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result println!("Build with: cargo build --features hardware"); } #[cfg(feature = "hardware")] + crate::PeripheralCommands::DeployUnoQ { host } => { + uno_q_setup::deploy_uno_q(&host)?; + } + #[cfg(not(feature = "hardware"))] + crate::PeripheralCommands::DeployUnoQ { .. } => { + println!("Uno Q deploy requires the 'hardware' feature."); + println!("Build with: cargo build --features hardware"); + } + #[cfg(feature = "hardware")] crate::PeripheralCommands::FlashNucleo => { nucleo_flash::flash_nucleo_firmware()?; } @@ -149,9 +158,22 @@ pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result bool { + pin <= MAX_DIGITAL_PIN +} + +fn is_valid_pwm_pin(pin: u64) -> bool { + PWM_PINS.contains(&pin) +} +fn is_valid_adc_channel(channel: u64) -> bool { + channel <= MAX_ADC_CHANNEL +} + +fn is_valid_rgb_led_id(id: u64) -> bool { + (MIN_RGB_LED_ID..=MAX_RGB_LED_ID).contains(&id) +} + +// --------------------------------------------------------------------------- +// Bridge communication helpers +// --------------------------------------------------------------------------- + +/// Send a command to the Bridge app over TCP and return the response string. async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result { let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT); let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr)) .await .map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??; - let msg = format!("{} {}\n", cmd, args.join(" ")); + let msg = if args.is_empty() { + format!("{}\n", cmd) + } else { + format!("{} {}\n", cmd, args.join(" ")) + }; stream.write_all(msg.as_bytes()).await?; - let mut buf = vec![0u8; 64]; + let mut buf = vec![0u8; 4096]; let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf)) .await .map_err(|_| anyhow::anyhow!("Bridge response timed out"))??; @@ -30,17 +72,55 @@ async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result { Ok(resp) } -/// Tool: read GPIO pin via Uno Q Bridge. +/// Convert a bridge response string into a `ToolResult`. +/// Responses prefixed with "error:" are treated as failures. +fn bridge_response_to_result(resp: &str) -> ToolResult { + if resp.starts_with("error:") { + ToolResult { + success: false, + output: resp.to_string(), + error: Some(resp.to_string()), + } + } else { + ToolResult { + success: true, + output: resp.to_string(), + error: None, + } + } +} + +/// Combined helper: send a bridge request and convert the response to a `ToolResult`. +async fn bridge_tool_request(cmd: &str, args: &[String]) -> ToolResult { + match bridge_request(cmd, args).await { + Ok(resp) => bridge_response_to_result(&resp), + Err(e) => ToolResult { + success: false, + output: format!("Bridge error: {}", e), + error: Some(e.to_string()), + }, + } +} + +// =========================================================================== +// MCU Tools (10) — via Bridge socket +// =========================================================================== + +// --------------------------------------------------------------------------- +// 1. GPIO Read +// --------------------------------------------------------------------------- + +/// Read a digital GPIO pin value (0 or 1) on the Uno Q MCU. pub struct UnoQGpioReadTool; #[async_trait] impl Tool for UnoQGpioReadTool { fn name(&self) -> &str { - "gpio_read" + "uno_q_gpio_read" } fn description(&self) -> &str { - "Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + "Read digital GPIO pin value (0 or 1) on Arduino UNO R4 WiFi MCU via Bridge." } fn parameters_schema(&self) -> Value { @@ -49,7 +129,9 @@ impl Tool for UnoQGpioReadTool { "properties": { "pin": { "type": "integer", - "description": "GPIO pin number (e.g. 13 for LED)" + "description": "GPIO pin number (0-21)", + "minimum": 0, + "maximum": 21 } }, "required": ["pin"] @@ -61,42 +143,34 @@ impl Tool for UnoQGpioReadTool { .get("pin") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; - match bridge_request("gpio_read", &[pin.to_string()]).await { - Ok(resp) => { - if resp.starts_with("error:") { - Ok(ToolResult { - success: false, - output: resp.clone(), - error: Some(resp), - }) - } else { - Ok(ToolResult { - success: true, - output: resp, - error: None, - }) - } - } - Err(e) => Ok(ToolResult { + + if !is_valid_digital_pin(pin) { + return Ok(ToolResult { success: false, - output: format!("Bridge error: {}", e), - error: Some(e.to_string()), - }), + output: format!("Invalid pin: {}. Must be 0-{}.", pin, MAX_DIGITAL_PIN), + error: Some(format!("Invalid pin: {}", pin)), + }); } + + Ok(bridge_tool_request("gpio_read", &[pin.to_string()]).await) } } -/// Tool: write GPIO pin via Uno Q Bridge. +// --------------------------------------------------------------------------- +// 2. GPIO Write +// --------------------------------------------------------------------------- + +/// Write a digital GPIO pin value (0 or 1) on the Uno Q MCU. pub struct UnoQGpioWriteTool; #[async_trait] impl Tool for UnoQGpioWriteTool { fn name(&self) -> &str { - "gpio_write" + "uno_q_gpio_write" } fn description(&self) -> &str { - "Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running." + "Set digital GPIO pin high (1) or low (0) on Arduino UNO R4 WiFi MCU via Bridge." } fn parameters_schema(&self) -> Value { @@ -105,11 +179,15 @@ impl Tool for UnoQGpioWriteTool { "properties": { "pin": { "type": "integer", - "description": "GPIO pin number" + "description": "GPIO pin number (0-21)", + "minimum": 0, + "maximum": 21 }, "value": { "type": "integer", - "description": "0 for low, 1 for high" + "description": "0 for low, 1 for high", + "minimum": 0, + "maximum": 1 } }, "required": ["pin", "value"] @@ -125,27 +203,940 @@ impl Tool for UnoQGpioWriteTool { .get("value") .and_then(|v| v.as_u64()) .ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?; - match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await { - Ok(resp) => { - if resp.starts_with("error:") { + + if !is_valid_digital_pin(pin) { + return Ok(ToolResult { + success: false, + output: format!("Invalid pin: {}. Must be 0-{}.", pin, MAX_DIGITAL_PIN), + error: Some(format!("Invalid pin: {}", pin)), + }); + } + + Ok(bridge_tool_request("gpio_write", &[pin.to_string(), value.to_string()]).await) + } +} + +// --------------------------------------------------------------------------- +// 3. ADC Read +// --------------------------------------------------------------------------- + +/// Read an analog value from an ADC channel on the Uno Q MCU. +pub struct UnoQAdcReadTool; + +#[async_trait] +impl Tool for UnoQAdcReadTool { + fn name(&self) -> &str { + "uno_q_adc_read" + } + + fn description(&self) -> &str { + "Read analog value from ADC channel (0-5) on Arduino UNO R4 WiFi MCU. WARNING: 3.3V max input on ADC pins." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "channel": { + "type": "integer", + "description": "ADC channel number (0-5). WARNING: 3.3V max input.", + "minimum": 0, + "maximum": 5 + } + }, + "required": ["channel"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let channel = args + .get("channel") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'channel' parameter"))?; + + if !is_valid_adc_channel(channel) { + return Ok(ToolResult { + success: false, + output: format!( + "Invalid ADC channel: {}. Must be 0-{}.", + channel, MAX_ADC_CHANNEL + ), + error: Some(format!("Invalid ADC channel: {}", channel)), + }); + } + + Ok(bridge_tool_request("adc_read", &[channel.to_string()]).await) + } +} + +// --------------------------------------------------------------------------- +// 4. PWM Write +// --------------------------------------------------------------------------- + +/// Write a PWM duty cycle to a PWM-capable pin on the Uno Q MCU. +pub struct UnoQPwmWriteTool; + +#[async_trait] +impl Tool for UnoQPwmWriteTool { + fn name(&self) -> &str { + "uno_q_pwm_write" + } + + fn description(&self) -> &str { + "Write PWM duty cycle (0-255) to a PWM-capable pin on Arduino UNO R4 WiFi MCU. PWM pins: 3, 5, 6, 9, 10, 11." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pin": { + "type": "integer", + "description": "PWM-capable pin (3, 5, 6, 9, 10, 11)", + "enum": [3, 5, 6, 9, 10, 11] + }, + "duty": { + "type": "integer", + "description": "PWM duty cycle (0-255)", + "minimum": 0, + "maximum": 255 + } + }, + "required": ["pin", "duty"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let pin = args + .get("pin") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?; + let duty = args + .get("duty") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'duty' parameter"))?; + + if !is_valid_pwm_pin(pin) { + return Ok(ToolResult { + success: false, + output: format!( + "Pin {} is not PWM-capable. Valid PWM pins: {:?}.", + pin, PWM_PINS + ), + error: Some(format!("Pin {} is not PWM-capable", pin)), + }); + } + + Ok(bridge_tool_request("pwm_write", &[pin.to_string(), duty.to_string()]).await) + } +} + +// --------------------------------------------------------------------------- +// 5. I2C Scan +// --------------------------------------------------------------------------- + +/// Scan the I2C bus for connected devices on the Uno Q MCU. +pub struct UnoQI2cScanTool; + +#[async_trait] +impl Tool for UnoQI2cScanTool { + fn name(&self) -> &str { + "uno_q_i2c_scan" + } + + fn description(&self) -> &str { + "Scan I2C bus for connected devices on Arduino UNO R4 WiFi MCU. Returns list of detected addresses." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(bridge_tool_request("i2c_scan", &[]).await) + } +} + +// --------------------------------------------------------------------------- +// 6. I2C Transfer +// --------------------------------------------------------------------------- + +/// Perform an I2C read/write transfer on the Uno Q MCU. +pub struct UnoQI2cTransferTool; + +#[async_trait] +impl Tool for UnoQI2cTransferTool { + fn name(&self) -> &str { + "uno_q_i2c_transfer" + } + + fn description(&self) -> &str { + "Perform I2C transfer on Arduino UNO R4 WiFi MCU. Write data and/or read bytes from a device address." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "address": { + "type": "integer", + "description": "I2C device address (1-126)", + "minimum": 1, + "maximum": 126 + }, + "data": { + "type": "string", + "description": "Hex string of bytes to write (e.g. 'A0FF')" + }, + "read_length": { + "type": "integer", + "description": "Number of bytes to read back", + "minimum": 0 + } + }, + "required": ["address", "data", "read_length"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let address = args + .get("address") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'address' parameter"))?; + let data = args + .get("data") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'data' parameter"))?; + let read_length = args + .get("read_length") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'read_length' parameter"))?; + + if !(1..=126).contains(&address) { + return Ok(ToolResult { + success: false, + output: format!("Invalid I2C address: {}. Must be 1-126.", address), + error: Some(format!("Invalid I2C address: {}", address)), + }); + } + + Ok(bridge_tool_request( + "i2c_transfer", + &[ + address.to_string(), + data.to_string(), + read_length.to_string(), + ], + ) + .await) + } +} + +// --------------------------------------------------------------------------- +// 7. SPI Transfer +// --------------------------------------------------------------------------- + +/// Perform an SPI transfer on the Uno Q MCU. +pub struct UnoQSpiTransferTool; + +#[async_trait] +impl Tool for UnoQSpiTransferTool { + fn name(&self) -> &str { + "uno_q_spi_transfer" + } + + fn description(&self) -> &str { + "Perform SPI transfer on Arduino UNO R4 WiFi MCU. Send and receive data bytes." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Hex string of bytes to transfer (e.g. 'DEADBEEF')" + } + }, + "required": ["data"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let data = args + .get("data") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'data' parameter"))?; + + Ok(bridge_tool_request("spi_transfer", &[data.to_string()]).await) + } +} + +// --------------------------------------------------------------------------- +// 8. CAN Send +// --------------------------------------------------------------------------- + +/// Send a CAN bus frame on the Uno Q MCU. +pub struct UnoQCanSendTool; + +#[async_trait] +impl Tool for UnoQCanSendTool { + fn name(&self) -> &str { + "uno_q_can_send" + } + + fn description(&self) -> &str { + "Send a CAN bus frame on Arduino UNO R4 WiFi MCU. Standard 11-bit CAN ID (0-2047)." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "CAN message ID (0-2047, standard 11-bit)", + "minimum": 0, + "maximum": 2047 + }, + "data": { + "type": "string", + "description": "Hex string of data bytes (up to 8 bytes, e.g. 'DEADBEEF')" + } + }, + "required": ["id", "data"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let id = args + .get("id") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + let data = args + .get("data") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'data' parameter"))?; + + if id > 2047 { + return Ok(ToolResult { + success: false, + output: format!("Invalid CAN ID: {}. Must be 0-2047.", id), + error: Some(format!("Invalid CAN ID: {}", id)), + }); + } + + Ok(bridge_tool_request("can_send", &[id.to_string(), data.to_string()]).await) + } +} + +// --------------------------------------------------------------------------- +// 9. LED Matrix +// --------------------------------------------------------------------------- + +/// Control the 12x8 LED matrix on the Uno Q board. +pub struct UnoQLedMatrixTool; + +#[async_trait] +impl Tool for UnoQLedMatrixTool { + fn name(&self) -> &str { + "uno_q_led_matrix" + } + + fn description(&self) -> &str { + "Set the 12x8 LED matrix bitmap on Arduino UNO R4 WiFi. Send 13 bytes (26 hex chars) as bitmap data." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "bitmap": { + "type": "string", + "description": "Hex string bitmap for 12x8 LED matrix (26 hex chars = 13 bytes)" + } + }, + "required": ["bitmap"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let bitmap = args + .get("bitmap") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'bitmap' parameter"))?; + + if bitmap.len() != 26 { + return Ok(ToolResult { + success: false, + output: format!( + "Invalid bitmap length: {} chars. Expected 26 hex chars (13 bytes).", + bitmap.len() + ), + error: Some(format!("Invalid bitmap length: {}", bitmap.len())), + }); + } + + Ok(bridge_tool_request("led_matrix", &[bitmap.to_string()]).await) + } +} + +// --------------------------------------------------------------------------- +// 10. RGB LED (MCU-side, IDs 3-4) +// --------------------------------------------------------------------------- + +/// Control MCU-side RGB LEDs (IDs 3-4) on the Uno Q board. +pub struct UnoQRgbLedTool; + +#[async_trait] +impl Tool for UnoQRgbLedTool { + fn name(&self) -> &str { + "uno_q_rgb_led" + } + + fn description(&self) -> &str { + "Set MCU-side RGB LED color on Arduino UNO R4 WiFi. LED IDs: 3 or 4. RGB values 0-255." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "RGB LED ID (3 or 4)", + "enum": [3, 4] + }, + "r": { + "type": "integer", + "description": "Red value (0-255)", + "minimum": 0, + "maximum": 255 + }, + "g": { + "type": "integer", + "description": "Green value (0-255)", + "minimum": 0, + "maximum": 255 + }, + "b": { + "type": "integer", + "description": "Blue value (0-255)", + "minimum": 0, + "maximum": 255 + } + }, + "required": ["id", "r", "g", "b"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let id = args + .get("id") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + let r = args + .get("r") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'r' parameter"))?; + let g = args + .get("g") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'g' parameter"))?; + let b = args + .get("b") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'b' parameter"))?; + + if !is_valid_rgb_led_id(id) { + return Ok(ToolResult { + success: false, + output: format!( + "Invalid LED ID: {}. Must be {} or {}.", + id, MIN_RGB_LED_ID, MAX_RGB_LED_ID + ), + error: Some(format!("Invalid LED ID: {}", id)), + }); + } + + Ok(bridge_tool_request( + "rgb_led", + &[id.to_string(), r.to_string(), g.to_string(), b.to_string()], + ) + .await) + } +} + +// =========================================================================== +// Linux Tools (3) — direct MPU access +// =========================================================================== + +// --------------------------------------------------------------------------- +// 11. Camera Capture +// --------------------------------------------------------------------------- + +/// Capture an image from the Uno Q on-board camera via GStreamer. +pub struct UnoQCameraCaptureTool; + +#[async_trait] +impl Tool for UnoQCameraCaptureTool { + fn name(&self) -> &str { + "uno_q_camera_capture" + } + + fn description(&self) -> &str { + "Capture an image from the on-board camera on Uno Q Linux MPU using GStreamer (gst-launch-1.0)." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "output_path": { + "type": "string", + "description": "Output file path for the captured image (default: /tmp/capture.jpg)" + } + }, + "required": [] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let output_path = args + .get("output_path") + .and_then(|v| v.as_str()) + .unwrap_or("/tmp/capture.jpg"); + + let output = tokio::process::Command::new("gst-launch-1.0") + .args([ + "v4l2src", + "num-buffers=1", + "!", + "image/jpeg,width=640,height=480", + "!", + "filesink", + &format!("location={}", output_path), + ]) + .output() + .await; + + match output { + Ok(out) => { + if out.status.success() { Ok(ToolResult { - success: false, - output: resp.clone(), - error: Some(resp), + success: true, + output: format!("Image captured to {}", output_path), + error: None, }) } else { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); Ok(ToolResult { - success: true, - output: "done".into(), - error: None, + success: false, + output: format!("Camera capture failed: {}", stderr), + error: Some(stderr), }) } } Err(e) => Ok(ToolResult { success: false, - output: format!("Bridge error: {}", e), + output: format!("Failed to run gst-launch-1.0: {}", e), error: Some(e.to_string()), }), } } } + +// --------------------------------------------------------------------------- +// 12. Linux RGB LED (sysfs, IDs 1-2) +// --------------------------------------------------------------------------- + +/// Control Linux-side RGB LEDs (IDs 1-2) via sysfs on the Uno Q board. +pub struct UnoQLinuxRgbLedTool; + +#[async_trait] +impl Tool for UnoQLinuxRgbLedTool { + fn name(&self) -> &str { + "uno_q_linux_rgb_led" + } + + fn description(&self) -> &str { + "Set Linux-side RGB LED color via sysfs on Uno Q. LED 1: user LEDs. LED 2: status LEDs. RGB values 0-255." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Linux RGB LED ID (1 or 2)", + "enum": [1, 2] + }, + "r": { + "type": "integer", + "description": "Red value (0-255)", + "minimum": 0, + "maximum": 255 + }, + "g": { + "type": "integer", + "description": "Green value (0-255)", + "minimum": 0, + "maximum": 255 + }, + "b": { + "type": "integer", + "description": "Blue value (0-255)", + "minimum": 0, + "maximum": 255 + } + }, + "required": ["id", "r", "g", "b"] + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + let id = args + .get("id") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'id' parameter"))?; + let r = args + .get("r") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'r' parameter"))?; + let g = args + .get("g") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'g' parameter"))?; + let b = args + .get("b") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing 'b' parameter"))?; + + // LED 1: red:user / green:user / blue:user + // LED 2: red:panic / green:wlan / blue:bt + let (red_path, green_path, blue_path) = match id { + 1 => ( + "/sys/class/leds/red:user/brightness", + "/sys/class/leds/green:user/brightness", + "/sys/class/leds/blue:user/brightness", + ), + 2 => ( + "/sys/class/leds/red:panic/brightness", + "/sys/class/leds/green:wlan/brightness", + "/sys/class/leds/blue:bt/brightness", + ), + _ => { + return Ok(ToolResult { + success: false, + output: format!("Invalid Linux LED ID: {}. Must be 1 or 2.", id), + error: Some(format!("Invalid Linux LED ID: {}", id)), + }); + } + }; + + // Use blocking write in spawn_blocking to avoid blocking the async runtime + let r_str = r.to_string(); + let g_str = g.to_string(); + let b_str = b.to_string(); + let rp = red_path.to_string(); + let gp = green_path.to_string(); + let bp = blue_path.to_string(); + + let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + std::fs::write(&rp, &r_str)?; + std::fs::write(&gp, &g_str)?; + std::fs::write(&bp, &b_str)?; + Ok(()) + }) + .await; + + match result { + Ok(Ok(())) => Ok(ToolResult { + success: true, + output: format!("LED {} set to RGB({}, {}, {})", id, r, g, b), + error: None, + }), + Ok(Err(e)) => Ok(ToolResult { + success: false, + output: format!("Failed to write LED sysfs: {}", e), + error: Some(e.to_string()), + }), + Err(e) => Ok(ToolResult { + success: false, + output: format!("Task failed: {}", e), + error: Some(e.to_string()), + }), + } + } +} + +// --------------------------------------------------------------------------- +// 13. System Info +// --------------------------------------------------------------------------- + +/// Read system information from the Uno Q Linux MPU. +pub struct UnoQSystemInfoTool; + +#[async_trait] +impl Tool for UnoQSystemInfoTool { + fn name(&self) -> &str { + "uno_q_system_info" + } + + fn description(&self) -> &str { + "Read system information from the Uno Q Linux MPU: CPU temperature, memory, disk, and WiFi status." + } + + fn parameters_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute(&self, _args: Value) -> anyhow::Result { + let mut info_parts: Vec = Vec::new(); + + // CPU temperature + match tokio::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp").await { + Ok(temp_str) => { + if let Ok(millideg) = temp_str.trim().parse::() { + info_parts.push(format!("CPU temp: {:.1}C", millideg / 1000.0)); + } else { + info_parts.push(format!("CPU temp raw: {}", temp_str.trim())); + } + } + Err(e) => info_parts.push(format!("CPU temp: unavailable ({})", e)), + } + + // Memory info (first 3 lines of /proc/meminfo) + match tokio::fs::read_to_string("/proc/meminfo").await { + Ok(meminfo) => { + let lines: Vec<&str> = meminfo.lines().take(3).collect(); + info_parts.push(format!("Memory: {}", lines.join("; "))); + } + Err(e) => info_parts.push(format!("Memory: unavailable ({})", e)), + } + + // Disk usage + match tokio::process::Command::new("df") + .args(["-h", "/"]) + .output() + .await + { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + info_parts.push(format!("Disk:\n{}", stdout.trim())); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + info_parts.push(format!("Disk: error ({})", stderr.trim())); + } + Err(e) => info_parts.push(format!("Disk: unavailable ({})", e)), + } + + // WiFi status + match tokio::process::Command::new("iwconfig") + .arg("wlan0") + .output() + .await + { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + info_parts.push(format!("WiFi:\n{}", stdout.trim())); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + info_parts.push(format!("WiFi: error ({})", stderr.trim())); + } + Err(e) => info_parts.push(format!("WiFi: unavailable ({})", e)), + } + + Ok(ToolResult { + success: true, + output: info_parts.join("\n"), + error: None, + }) + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // -- Pin/channel validation -- + + #[test] + fn valid_digital_pins_accepted() { + for pin in 0..=21 { + assert!(is_valid_digital_pin(pin), "pin {} should be valid", pin); + } + } + + #[test] + fn invalid_digital_pins_rejected() { + assert!(!is_valid_digital_pin(22)); + assert!(!is_valid_digital_pin(100)); + } + + #[test] + fn valid_pwm_pins_accepted() { + for pin in &[3, 5, 6, 9, 10, 11] { + assert!(is_valid_pwm_pin(*pin), "pin {} should be PWM-capable", pin); + } + } + + #[test] + fn non_pwm_pins_rejected() { + for pin in &[0, 1, 2, 4, 7, 8, 12, 13] { + assert!( + !is_valid_pwm_pin(*pin), + "pin {} should not be PWM-capable", + pin + ); + } + } + + #[test] + fn valid_adc_channels_accepted() { + for ch in 0..=5 { + assert!(is_valid_adc_channel(ch), "channel {} should be valid", ch); + } + } + + #[test] + fn invalid_adc_channels_rejected() { + assert!(!is_valid_adc_channel(6)); + assert!(!is_valid_adc_channel(100)); + } + + #[test] + fn valid_rgb_led_ids() { + assert!(is_valid_rgb_led_id(3)); + assert!(is_valid_rgb_led_id(4)); + assert!(!is_valid_rgb_led_id(1)); + assert!(!is_valid_rgb_led_id(5)); + } + + // -- Bridge response conversion -- + + #[test] + fn bridge_result_ok_response() { + let result = bridge_response_to_result("ok"); + assert!(result.success); + assert_eq!(result.output, "ok"); + assert!(result.error.is_none()); + } + + #[test] + fn bridge_result_error_response() { + let result = bridge_response_to_result("error: pin not found"); + assert!(!result.success); + assert_eq!(result.output, "error: pin not found"); + assert!(result.error.is_some()); + } + + #[test] + fn bridge_result_numeric_response() { + let result = bridge_response_to_result("2048"); + assert!(result.success); + assert_eq!(result.output, "2048"); + assert!(result.error.is_none()); + } + + // -- Tool schema validation -- + + #[test] + fn gpio_read_tool_schema() { + let tool = UnoQGpioReadTool; + assert_eq!(tool.name(), "uno_q_gpio_read"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["pin"].is_object()); + } + + #[test] + fn adc_read_tool_schema() { + let tool = UnoQAdcReadTool; + assert_eq!(tool.name(), "uno_q_adc_read"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["channel"].is_object()); + } + + #[test] + fn pwm_write_tool_schema() { + let tool = UnoQPwmWriteTool; + assert_eq!(tool.name(), "uno_q_pwm_write"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["pin"].is_object()); + assert!(schema["properties"]["duty"].is_object()); + } + + // -- Tool execute: input validation (no bridge needed) -- + + #[tokio::test] + async fn gpio_read_rejects_invalid_pin() { + let tool = UnoQGpioReadTool; + let result = tool.execute(json!({"pin": 99})).await.unwrap(); + assert!(!result.success); + assert!(result.output.contains("Invalid pin")); + } + + #[tokio::test] + async fn pwm_write_rejects_non_pwm_pin() { + let tool = UnoQPwmWriteTool; + let result = tool.execute(json!({"pin": 2, "duty": 128})).await.unwrap(); + assert!(!result.success); + assert!(result.output.contains("not PWM-capable")); + } + + #[tokio::test] + async fn adc_read_rejects_invalid_channel() { + let tool = UnoQAdcReadTool; + let result = tool.execute(json!({"channel": 7})).await.unwrap(); + assert!(!result.success); + assert!(result.output.contains("Invalid ADC channel")); + } + + #[tokio::test] + async fn rgb_led_rejects_invalid_id() { + let tool = UnoQRgbLedTool; + let result = tool + .execute(json!({"id": 1, "r": 255, "g": 0, "b": 0})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.output.contains("Invalid LED ID")); + } + + #[tokio::test] + async fn can_send_rejects_invalid_id() { + let tool = UnoQCanSendTool; + let result = tool + .execute(json!({"id": 9999, "data": "DEADBEEF"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.output.contains("Invalid CAN ID")); + } + + #[tokio::test] + async fn i2c_transfer_rejects_invalid_address() { + let tool = UnoQI2cTransferTool; + let result = tool + .execute(json!({"address": 0, "data": "FF", "read_length": 1})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.output.contains("Invalid I2C address")); + } +} diff --git a/src/peripherals/uno_q_setup.rs b/src/peripherals/uno_q_setup.rs index 424bc89e40..cc5071750e 100644 --- a/src/peripherals/uno_q_setup.rs +++ b/src/peripherals/uno_q_setup.rs @@ -141,3 +141,64 @@ fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> { } Ok(()) } + +/// Deploy ZeroClaw binary + config to Arduino Uno Q via SSH/SCP. +/// +/// Expects a cross-compiled binary at `target/aarch64-unknown-linux-gnu/release/zeroclaw`. +pub fn deploy_uno_q(host: &str) -> Result<()> { + let ssh_target = if host.contains('@') { + host.to_string() + } else { + format!("arduino@{}", host) + }; + + let binary = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("aarch64-unknown-linux-gnu") + .join("release") + .join("zeroclaw"); + + if !binary.exists() { + anyhow::bail!( + "Cross-compiled binary not found at {}.\nBuild with: ./dev/cross-uno-q.sh", + binary.display() + ); + } + + println!("Creating remote directory on {}...", host); + let status = Command::new("ssh") + .args([&ssh_target, "mkdir", "-p", "~/zeroclaw"]) + .status() + .context("ssh mkdir failed")?; + if !status.success() { + anyhow::bail!("Failed to create ~/zeroclaw on Uno Q"); + } + + println!("Copying zeroclaw binary..."); + let status = Command::new("scp") + .args([ + binary.to_str().unwrap(), + &format!("{}:~/zeroclaw/zeroclaw", ssh_target), + ]) + .status() + .context("scp binary failed")?; + if !status.success() { + anyhow::bail!("Failed to copy binary"); + } + + let status = Command::new("ssh") + .args([&ssh_target, "chmod", "+x", "~/zeroclaw/zeroclaw"]) + .status() + .context("ssh chmod failed")?; + if !status.success() { + anyhow::bail!("Failed to set executable bit"); + } + + println!(); + println!("ZeroClaw deployed to Uno Q!"); + println!(" Binary: ~/zeroclaw/zeroclaw"); + println!(); + println!("Start with: ssh {} '~/zeroclaw/zeroclaw agent'", ssh_target); + + Ok(()) +} From 588a1174477dc0ec3d1ad5ea42d8577b9f157e7d Mon Sep 17 00:00:00 2001 From: Todd Gruben Date: Wed, 18 Feb 2026 16:45:48 -0600 Subject: [PATCH 02/11] fix(peripherals): fix UNO Q Bridge for real hardware deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues discovered during deployment to actual UNO Q hardware: 1. Bridge.call() takes positional args, not a list — changed from Bridge.call("digitalRead", [pin]) to Bridge.call("digitalRead", pin) 2. Bridge.call() must run on main thread (not thread-safe) — restructured socket server to use a queue pattern: accept thread enqueues requests, main App.run() loop drains queue and calls Bridge 3. Docker container networking requires 0.0.0.0 bind (not 127.0.0.1) 4. Wire/SPI are built into Zephyr platform, removed from sketch.yaml 5. Renamed C++ functions to bridge_* prefix to avoid Arduino built-in clashes 6. Changed const char* params to String for MsgPack RPC compatibility Tested on hannah.local: gpio_read, gpio_write, adc_read, pwm_write, capabilities all confirmed working. --- firmware/zeroclaw-uno-q-bridge/python/main.py | 90 ++++++++++--------- .../zeroclaw-uno-q-bridge/sketch/sketch.ino | 69 +++++++------- .../zeroclaw-uno-q-bridge/sketch/sketch.yaml | 2 - 3 files changed, 79 insertions(+), 82 deletions(-) diff --git a/firmware/zeroclaw-uno-q-bridge/python/main.py b/firmware/zeroclaw-uno-q-bridge/python/main.py index 487f74f4fd..8079e5b107 100644 --- a/firmware/zeroclaw-uno-q-bridge/python/main.py +++ b/firmware/zeroclaw-uno-q-bridge/python/main.py @@ -1,49 +1,49 @@ # ZeroClaw Bridge — socket server for full MCU peripheral control # SPDX-License-Identifier: MPL-2.0 +# +# Bridge.call() must run on the main thread (not thread-safe). +# Socket accepts happen on a background thread, but each request +# is queued and processed in the main App.run() loop. +import queue import socket +import sys import threading -from arduino.app_utils import App, Bridge +import traceback +from arduino.app_utils import * ZEROCLAW_PORT = 9999 +# Queue of (conn, data_str) tuples processed on the main thread. +request_queue = queue.Queue() -def handle_client(conn): + +def process_request(data, conn): + """Process a single bridge command on the main thread.""" try: - data = conn.recv(1024).decode().strip() - if not data: - conn.close() - return parts = data.split() - if len(parts) < 1: + if not parts: conn.sendall(b"error: empty command\n") - conn.close() return cmd = parts[0].lower() # ── GPIO ────────────────────────────────────────────── if cmd == "gpio_write" and len(parts) >= 3: - pin = int(parts[1]) - value = int(parts[2]) - Bridge.call("digitalWrite", [pin, value]) + Bridge.call("digitalWrite", int(parts[1]), int(parts[2])) conn.sendall(b"ok\n") elif cmd == "gpio_read" and len(parts) >= 2: - pin = int(parts[1]) - val = Bridge.call("digitalRead", [pin]) + val = Bridge.call("digitalRead", int(parts[1])) conn.sendall(f"{val}\n".encode()) # ── ADC ─────────────────────────────────────────────── elif cmd == "adc_read" and len(parts) >= 2: - channel = int(parts[1]) - val = Bridge.call("analogRead", [channel]) + val = Bridge.call("analogRead", int(parts[1])) conn.sendall(f"{val}\n".encode()) # ── PWM ─────────────────────────────────────────────── elif cmd == "pwm_write" and len(parts) >= 3: - pin = int(parts[1]) - duty = int(parts[2]) - result = Bridge.call("analogWrite", [pin, duty]) + result = Bridge.call("analogWrite", int(parts[1]), int(parts[2])) if result == -1: conn.sendall(b"error: not a PWM pin\n") else: @@ -51,27 +51,21 @@ def handle_client(conn): # ── I2C ─────────────────────────────────────────────── elif cmd == "i2c_scan": - result = Bridge.call("i2cScan", []) + result = Bridge.call("i2cScan") conn.sendall(f"{result}\n".encode()) elif cmd == "i2c_transfer" and len(parts) >= 4: - addr = int(parts[1]) - hex_data = parts[2] - rx_len = int(parts[3]) - result = Bridge.call("i2cTransfer", [addr, hex_data, rx_len]) + result = Bridge.call("i2cTransfer", int(parts[1]), parts[2], int(parts[3])) conn.sendall(f"{result}\n".encode()) # ── SPI ─────────────────────────────────────────────── elif cmd == "spi_transfer" and len(parts) >= 2: - hex_data = parts[1] - result = Bridge.call("spiTransfer", [hex_data]) + result = Bridge.call("spiTransfer", parts[1]) conn.sendall(f"{result}\n".encode()) # ── CAN ─────────────────────────────────────────────── elif cmd == "can_send" and len(parts) >= 3: - can_id = int(parts[1]) - hex_data = parts[2] - result = Bridge.call("canSend", [can_id, hex_data]) + result = Bridge.call("canSend", int(parts[1]), parts[2]) if result == -2: conn.sendall(b"error: CAN not yet available\n") else: @@ -79,31 +73,28 @@ def handle_client(conn): # ── LED Matrix ──────────────────────────────────────── elif cmd == "led_matrix" and len(parts) >= 2: - hex_bitmap = parts[1] - Bridge.call("ledMatrix", [hex_bitmap]) + Bridge.call("ledMatrix", parts[1]) conn.sendall(b"ok\n") # ── RGB LED ─────────────────────────────────────────── elif cmd == "rgb_led" and len(parts) >= 5: - led_id = int(parts[1]) - r = int(parts[2]) - g = int(parts[3]) - b = int(parts[4]) - result = Bridge.call("rgbLed", [led_id, r, g, b]) + result = Bridge.call("rgbLed", int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4])) if result == -1: - conn.sendall(b"error: invalid LED id (use 3 or 4)\n") + conn.sendall(b"error: invalid LED id (use 0 or 1)\n") else: conn.sendall(b"ok\n") # ── Capabilities ────────────────────────────────────── elif cmd == "capabilities": - result = Bridge.call("capabilities", []) + result = Bridge.call("capabilities") conn.sendall(f"{result}\n".encode()) else: conn.sendall(b"error: unknown command\n") except Exception as e: + print(f"[handle] ERROR: {e}", file=sys.stderr, flush=True) + traceback.print_exc(file=sys.stderr) try: conn.sendall(f"error: {e}\n".encode()) except Exception: @@ -113,28 +104,39 @@ def handle_client(conn): def accept_loop(server): + """Background thread: accept connections and enqueue requests.""" while True: try: conn, _ = server.accept() - t = threading.Thread(target=handle_client, args=(conn,)) - t.daemon = True - t.start() + data = conn.recv(1024).decode().strip() + if data: + request_queue.put((conn, data)) + else: + conn.close() + except socket.timeout: + continue except Exception: break def loop(): - App.sleep(1) + """Main-thread loop: drain the request queue and process via Bridge.""" + while not request_queue.empty(): + try: + conn, data = request_queue.get_nowait() + process_request(data, conn) + except queue.Empty: + break def main(): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind(("127.0.0.1", ZEROCLAW_PORT)) + server.bind(("0.0.0.0", ZEROCLAW_PORT)) server.listen(5) server.settimeout(1.0) - t = threading.Thread(target=accept_loop, args=(server,)) - t.daemon = True + print(f"[ZeroClaw Bridge] Listening on 0.0.0.0:{ZEROCLAW_PORT}", flush=True) + t = threading.Thread(target=accept_loop, args=(server,), daemon=True) t.start() App.run(user_loop=loop) diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino index f4c25d8515..7bc03e3751 100644 --- a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino @@ -46,22 +46,24 @@ static uint8_t hex_nibble(char c) { return 0; } -static int hex_decode(const char *hex, uint8_t *buf, int max_len) { +static int hex_decode(const String &hex, uint8_t *buf, int max_len) { int len = 0; - while (hex[0] && hex[1] && len < max_len) { - buf[len++] = (hex_nibble(hex[0]) << 4) | hex_nibble(hex[1]); - hex += 2; + int slen = hex.length(); + for (int i = 0; i + 1 < slen && len < max_len; i += 2) { + buf[len++] = (hex_nibble(hex.charAt(i)) << 4) | hex_nibble(hex.charAt(i + 1)); } return len; } -static void hex_encode(const uint8_t *data, int len, char *out) { +static String hex_encode(const uint8_t *data, int len) { static const char hexchars[] = "0123456789abcdef"; + String result; + result.reserve(len * 2); for (int i = 0; i < len; i++) { - out[i * 2] = hexchars[(data[i] >> 4) & 0x0F]; - out[i * 2 + 1] = hexchars[data[i] & 0x0F]; + result += hexchars[(data[i] >> 4) & 0x0F]; + result += hexchars[data[i] & 0x0F]; } - out[len * 2] = '\0'; + return result; } static bool is_pwm_pin(int pin) { @@ -85,7 +87,7 @@ int gpio_read(int pin) { // ── ADC (12-bit, A0-A5) ──────────────────────────────────────── -int adc_read(int channel) { +int bridge_adc_read(int channel) { int pin = ADC_FIRST_PIN + channel; if (pin < ADC_FIRST_PIN || pin > ADC_LAST_PIN) return -1; analogReadResolution(12); @@ -94,7 +96,7 @@ int adc_read(int channel) { // ── PWM (D3, D5, D6, D9, D10, D11) ───────────────────────────── -int pwm_write(int pin, int duty) { +int bridge_pwm_write(int pin, int duty) { if (!is_pwm_pin(pin)) return -1; if (duty < 0) duty = 0; if (duty > 255) duty = 255; @@ -105,7 +107,7 @@ int pwm_write(int pin, int duty) { // ── I2C scan ──────────────────────────────────────────────────── -String i2c_scan() { +String bridge_i2c_scan() { Wire.begin(); String result = ""; bool first = true; @@ -120,9 +122,9 @@ String i2c_scan() { return result.length() > 0 ? result : "none"; } -// ── I2C transfer ──────────────────────────────────────────────── +// ── I2C transfer (all String params for MsgPack compatibility) ── -String i2c_transfer(int addr, const char *hex_data, int rx_len) { +String bridge_i2c_transfer(int addr, String hex_data, int rx_len) { if (addr < 1 || addr > 127) return "err:addr"; if (rx_len < 0 || rx_len > 32) return "err:rxlen"; @@ -144,16 +146,14 @@ String i2c_transfer(int addr, const char *hex_data, int rx_len) { while (Wire.available() && count < rx_len) { rx_buf[count++] = Wire.read(); } - char hex_out[65]; - hex_encode(rx_buf, count, hex_out); - return String(hex_out); + return hex_encode(rx_buf, count); } return "ok"; } // ── SPI transfer ──────────────────────────────────────────────── -String spi_transfer(const char *hex_data) { +String bridge_spi_transfer(String hex_data) { uint8_t buf[32]; int len = hex_decode(hex_data, buf, sizeof(buf)); if (len == 0) return "err:empty"; @@ -166,14 +166,12 @@ String spi_transfer(const char *hex_data) { } SPI.endTransaction(); - char hex_out[65]; - hex_encode(rx_buf, len, hex_out); - return String(hex_out); + return hex_encode(rx_buf, len); } // ── CAN (stub — needs Zephyr FDCAN driver) ────────────────────── -int can_send(int id, const char *hex_data) { +int bridge_can_send(int id, String hex_data) { (void)id; (void)hex_data; return -2; // not yet available @@ -181,19 +179,18 @@ int can_send(int id, const char *hex_data) { // ── LED matrix (8x13, 13-byte bitmap) ─────────────────────────── -int led_matrix(const char *hex_bitmap) { +int bridge_led_matrix(String hex_bitmap) { uint8_t bitmap[LED_MATRIX_BYTES]; int len = hex_decode(hex_bitmap, bitmap, LED_MATRIX_BYTES); if (len != LED_MATRIX_BYTES) return -1; // Matrix rendering depends on board LED matrix driver availability. - // Bitmap accepted; actual display requires Arduino_LED_Matrix library. (void)bitmap; return 0; } // ── RGB LED (MCU LEDs 3-4, active-low) ────────────────────────── -int rgb_led(int id, int r, int g, int b) { +int bridge_rgb_led(int id, int r, int g, int b) { if (id < 0 || id >= RGB_LED_COUNT) return -1; r = constrain(r, 0, 255); g = constrain(g, 0, 255); @@ -209,7 +206,7 @@ int rgb_led(int id, int r, int g, int b) { // ── Capabilities ──────────────────────────────────────────────── -String get_capabilities() { +String bridge_get_capabilities() { return "gpio,adc,pwm,i2c,spi,can,led_matrix,rgb_led"; } @@ -217,17 +214,17 @@ String get_capabilities() { void setup() { Bridge.begin(); - Bridge.provide("digitalWrite", gpio_write); - Bridge.provide("digitalRead", gpio_read); - Bridge.provide("analogRead", adc_read); - Bridge.provide("analogWrite", pwm_write); - Bridge.provide("i2cScan", i2c_scan); - Bridge.provide("i2cTransfer", i2c_transfer); - Bridge.provide("spiTransfer", spi_transfer); - Bridge.provide("canSend", can_send); - Bridge.provide("ledMatrix", led_matrix); - Bridge.provide("rgbLed", rgb_led); - Bridge.provide("capabilities", get_capabilities); + Bridge.provide("digitalWrite", gpio_write); + Bridge.provide("digitalRead", gpio_read); + Bridge.provide("analogRead", bridge_adc_read); + Bridge.provide("analogWrite", bridge_pwm_write); + Bridge.provide("i2cScan", bridge_i2c_scan); + Bridge.provide("i2cTransfer", bridge_i2c_transfer); + Bridge.provide("spiTransfer", bridge_spi_transfer); + Bridge.provide("canSend", bridge_can_send); + Bridge.provide("ledMatrix", bridge_led_matrix); + Bridge.provide("rgbLed", bridge_rgb_led); + Bridge.provide("capabilities", bridge_get_capabilities); } void loop() { diff --git a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml index 732e87b4b4..d9fe917efa 100644 --- a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml @@ -8,6 +8,4 @@ profiles: - DebugLog (0.8.4) - ArxContainer (0.7.0) - ArxTypeTraits (0.3.1) - - Wire - - SPI default_profile: default From 08511fd04762cfc37104375009264d8631638bfe Mon Sep 17 00:00:00 2001 From: Todd Gruben Date: Wed, 18 Feb 2026 17:27:21 -0600 Subject: [PATCH 03/11] feat(peripherals): update UNO Q camera tool for USB cameras + add musl cross-compile config - Camera capture tool now uses v4l2-ctl instead of GStreamer (works with USB cameras like NETUM, not just MIPI-CSI) - Tool output includes [IMAGE:] hint so Telegram channel sends the captured photo directly to the user - Added width/height/device parameters (defaults: 1280x720, /dev/video0) - Added aarch64-unknown-linux-musl linker config to .cargo/config.toml --- .cargo/config.toml | 4 ++ src/peripherals/uno_q_bridge.rs | 73 +++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index e1f508bbf0..12365ff039 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -2,4 +2,8 @@ rustflags = ["-C", "link-arg=-static"] [target.aarch64-unknown-linux-musl] +linker = "aarch64-linux-musl-gcc" rustflags = ["-C", "link-arg=-static"] + +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" diff --git a/src/peripherals/uno_q_bridge.rs b/src/peripherals/uno_q_bridge.rs index e27610045a..2c7db5eda3 100644 --- a/src/peripherals/uno_q_bridge.rs +++ b/src/peripherals/uno_q_bridge.rs @@ -689,61 +689,72 @@ impl Tool for UnoQCameraCaptureTool { } fn description(&self) -> &str { - "Capture an image from the on-board camera on Uno Q Linux MPU using GStreamer (gst-launch-1.0)." + "Capture a photo from the USB camera on Arduino Uno Q. Returns the image path. Include [IMAGE:] in your response to send it to the user." } fn parameters_schema(&self) -> Value { json!({ "type": "object", "properties": { - "output_path": { + "width": { + "type": "integer", + "description": "Image width in pixels (default: 1280)" + }, + "height": { + "type": "integer", + "description": "Image height in pixels (default: 720)" + }, + "device": { "type": "string", - "description": "Output file path for the captured image (default: /tmp/capture.jpg)" + "description": "V4L2 device path (default: /dev/video0)" } - }, - "required": [] + } }) } async fn execute(&self, args: Value) -> anyhow::Result { - let output_path = args - .get("output_path") + let width = args.get("width").and_then(|v| v.as_u64()).unwrap_or(1280); + let height = args.get("height").and_then(|v| v.as_u64()).unwrap_or(720); + let device = args + .get("device") .and_then(|v| v.as_str()) - .unwrap_or("/tmp/capture.jpg"); + .unwrap_or("/dev/video0"); + let output_path = "/tmp/zeroclaw_capture.jpg"; - let output = tokio::process::Command::new("gst-launch-1.0") + let fmt = format!("width={},height={},pixelformat=MJPG", width, height); + let output = tokio::process::Command::new("v4l2-ctl") .args([ - "v4l2src", - "num-buffers=1", - "!", - "image/jpeg,width=640,height=480", - "!", - "filesink", - &format!("location={}", output_path), + "-d", + device, + "--set-fmt-video", + &fmt, + "--stream-mmap", + "--stream-count=1", + &format!("--stream-to={}", output_path), ]) .output() .await; match output { + Ok(out) if out.status.success() => Ok(ToolResult { + success: true, + output: format!( + "Photo captured ({}x{}) to {}. To send it to the user, include [IMAGE:{}] in your response.", + width, height, output_path, output_path + ), + error: None, + }), Ok(out) => { - if out.status.success() { - Ok(ToolResult { - success: true, - output: format!("Image captured to {}", output_path), - error: None, - }) - } else { - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - Ok(ToolResult { - success: false, - output: format!("Camera capture failed: {}", stderr), - error: Some(stderr), - }) - } + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + Ok(ToolResult { + success: false, + output: format!("Camera capture failed: {}", stderr), + error: Some(stderr), + }) } Err(e) => Ok(ToolResult { success: false, - output: format!("Failed to run gst-launch-1.0: {}", e), + output: format!("Failed to run v4l2-ctl: {}. Is v4l-utils installed?", e), error: Some(e.to_string()), }), } From 73847e0057f136c7f5348795d181b9b71356fcae Mon Sep 17 00:00:00 2001 From: Todd Gruben Date: Wed, 18 Feb 2026 17:35:29 -0600 Subject: [PATCH 04/11] fix(peripherals): load peripheral tools in daemon/channel path + fix camera for USB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon's channel server was missing peripheral tools — only the interactive `agent` command loaded them. Now `start_channels()` calls `create_peripheral_tools()` so Telegram/Discord/Slack channels get access to all UNO Q hardware tools. Also updated camera capture tool description to guide the LLM to use [IMAGE:] markers for Telegram photo delivery. --- src/channels/mod.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 0fff1ecbee..fe14ac5341 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -1532,7 +1532,7 @@ pub async fn start_channels(config: Config) -> Result<()> { }; // Build system prompt from workspace identity files + skills let workspace = config.workspace_dir.clone(); - let tools_registry = Arc::new(tools::all_tools_with_runtime( + let mut all_tools = tools::all_tools_with_runtime( Arc::new(config.clone()), &security, runtime, @@ -1545,7 +1545,17 @@ pub async fn start_channels(config: Config) -> Result<()> { &config.agents, config.api_key.as_deref(), &config, - )); + ); + + // Merge peripheral tools (UNO Q Bridge, RPi GPIO, etc.) + let peripheral_tools = + crate::peripherals::create_peripheral_tools(&config.peripherals).await?; + if !peripheral_tools.is_empty() { + tracing::info!(count = peripheral_tools.len(), "Peripheral tools added to channel server"); + all_tools.extend(peripheral_tools); + } + + let tools_registry = Arc::new(all_tools); let skills = crate::skills::load_skills(&workspace); From 1cc9d3aa2d5b245039cb37e940b31d9c071f3bdd Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 20:54:01 -0400 Subject: [PATCH 05/11] feat(deploy): add marketing research agent Docker deployment - config.toml with supervised autonomy, SQLite memory, cost tracking - docker-compose.yml with security-hardened container (non-root, capability drops) - Uses zeroclaw-local:latest image (built from source) - Telegram channel with auto-reconnect via allowed_users - CLI channel enabled for gateway webhook access - Model routes: Claude Sonnet 4 (default), Gemma 3n, GPT-OSS 20B, Claude 4.5, Gemini Flash Lite - HTTP requests enabled for n8n webhook integration (workflows.mi1-media.com) - Obsidian vault mounted read-only as knowledge base - Pairing code helper scripts (PowerShell + batch) - .env.example with placeholder API key - README with full setup guide --- deploy/marketing/.env.example | 28 ++++ deploy/marketing/.gitignore | 4 + deploy/marketing/Get Pairing Code.bat | 2 + deploy/marketing/README.md | 204 ++++++++++++++++++++++++++ deploy/marketing/config.toml | 121 +++++++++++++++ deploy/marketing/docker-compose.yml | 101 +++++++++++++ deploy/marketing/get-pairing-code.ps1 | 27 ++++ 7 files changed, 487 insertions(+) create mode 100644 deploy/marketing/.env.example create mode 100644 deploy/marketing/.gitignore create mode 100644 deploy/marketing/Get Pairing Code.bat create mode 100644 deploy/marketing/README.md create mode 100644 deploy/marketing/config.toml create mode 100644 deploy/marketing/docker-compose.yml create mode 100644 deploy/marketing/get-pairing-code.ps1 diff --git a/deploy/marketing/.env.example b/deploy/marketing/.env.example new file mode 100644 index 0000000000..41829f194a --- /dev/null +++ b/deploy/marketing/.env.example @@ -0,0 +1,28 @@ +# ZeroClaw Marketing Research Agent — Environment Variables +# ───────────────────────────────────────────────────────── +# Copy this file to .env and fill in your values. +# NEVER commit .env or any real secrets to version control. + +# ── LLM Provider (required) ───────────────────────────── +# Your LLM provider API key (OpenRouter, OpenAI, Anthropic, etc.) +API_KEY=your-api-key-here + +# Provider: openrouter | openai | anthropic | ollama +PROVIDER=openrouter + +# Model override (default set in config.toml) +# ZEROCLAW_MODEL=anthropic/claude-sonnet-4-20250514 + +# ── Web Search ────────────────────────────────────────── +# DuckDuckGo is free and requires no API key (default). +# For better results, use Brave Search (requires API key). +WEB_SEARCH_PROVIDER=duckduckgo +WEB_SEARCH_MAX_RESULTS=10 + +# Brave Search API key (get one at https://brave.com/search/api) +# Uncomment and set if using Brave: +# BRAVE_API_KEY=your-brave-search-api-key + +# ── Docker Compose ────────────────────────────────────── +# Host port for the gateway (change if 3000 is taken) +HOST_PORT=3000 diff --git a/deploy/marketing/.gitignore b/deploy/marketing/.gitignore new file mode 100644 index 0000000000..a7c135c1a6 --- /dev/null +++ b/deploy/marketing/.gitignore @@ -0,0 +1,4 @@ +# Never commit real secrets +.env +.env.local +.env.*.local diff --git a/deploy/marketing/Get Pairing Code.bat b/deploy/marketing/Get Pairing Code.bat new file mode 100644 index 0000000000..0bd730f1a2 --- /dev/null +++ b/deploy/marketing/Get Pairing Code.bat @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy Bypass -File "%~dp0get-pairing-code.ps1" diff --git a/deploy/marketing/README.md b/deploy/marketing/README.md new file mode 100644 index 0000000000..85a557b002 --- /dev/null +++ b/deploy/marketing/README.md @@ -0,0 +1,204 @@ +# ZeroClaw Marketing Research Agent — Docker Desktop Setup + +A hardened, isolated ZeroClaw deployment for **marketing research and planning only**. + +## What this agent CAN do + +- **Web research** — audience analysis, competitor research, tropes, trends, ad angles (DuckDuckGo or Brave Search) +- **Draft marketing plans** — launch calendars, email/social sequences, ad copy variants +- **Summarize content** — articles, podcasts, YouTube transcripts into actionable bullet points +- **Take notes** — store and recall research findings in workspace markdown files + +## What this agent CANNOT do (by design) + +| Disabled capability | Why | +|---|---| +| Shell access | No `rm`, `curl`, `wget`, `ssh`, `docker`, or destructive commands | +| Browser automation | No headless browsing or computer-use | +| Filesystem outside workspace | Cannot read/write outside `/zeroclaw-data/workspace` | +| Composio / OAuth tools | No direct access to TikTok, X, Gmail, Google Drive, KDP, etc. | +| Cron / Scheduler | No autonomous scheduled tasks | +| Hardware / Peripherals | No GPIO, serial, or probe access | +| HTTP requests | Disabled by default; enable selectively via `config.toml` | + +## Prerequisites + +- **Docker Desktop** installed and running on Windows/Mac/Linux +- An **LLM API key** (OpenRouter, OpenAI, Anthropic, etc.) +- *(Optional)* A [Brave Search API key](https://brave.com/search/api) for higher-quality web search + +## Quick Start + +### 1. Navigate to this directory + +```powershell +cd deploy\marketing +``` + +### 2. Create your `.env` file + +```powershell +copy .env.example .env +``` + +Edit `.env` and set your `API_KEY`: + +```ini +API_KEY=sk-or-v1-your-openrouter-key-here +PROVIDER=openrouter +``` + +### 3. Start the agent + +```powershell +docker compose up -d +``` + +### 4. Check it's healthy + +```powershell +docker compose ps +docker logs zeroclaw-marketing +``` + +### 5. Pair your client + +The gateway requires pairing before accepting requests: + +```powershell +curl -X POST http://localhost:3000/pair +``` + +Save the returned bearer token — you'll use it for all subsequent requests. + +### 6. Send a research task + +```powershell +curl -X POST http://localhost:3000/webhook ^ + -H "Authorization: Bearer YOUR_TOKEN" ^ + -H "Content-Type: application/json" ^ + -d "{\"message\": \"Research the top 5 BookTok trends for dark romance in 2025. Summarize each trend with audience size estimates, key hashtags, and content angles for a launch campaign.\"}" +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Docker Desktop │ +│ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ zeroclaw-marketing (distroless image) │ │ +│ │ │ │ +│ │ Tools enabled: │ │ +│ │ ✅ web_search_tool (DuckDuckGo/Brave) │ │ +│ │ ✅ file_read / file_write (workspace) │ │ +│ │ ✅ memory_store / memory_recall │ │ +│ │ ❌ shell, browser, http_request │ │ +│ │ ❌ composio, cron, hardware │ │ +│ │ │ │ +│ │ Volumes: │ │ +│ │ 📁 config.toml (read-only mount) │ │ +│ │ 📁 marketing-sandbox (workspace) │ │ +│ └──────────────┬────────────────────────────┘ │ +│ │ :3000 (localhost only) │ +└─────────────────┼───────────────────────────────┘ + │ + Your browser / curl +``` + +## Security Hardening Summary + +| Layer | Setting | +|---|---| +| **Container** | Read-only root filesystem, `no-new-privileges`, all capabilities dropped | +| **User** | Runs as non-root (uid 65534) | +| **Network** | Gateway bound to `127.0.0.1` on host (not exposed to LAN) | +| **Config** | Mounted read-only — agent cannot weaken its own policy | +| **Autonomy** | `supervised` level, `workspace_only = true` | +| **Shell** | Only safe read-only commands (`ls`, `cat`, `grep`, etc.) | +| **Resources** | Capped at 1 CPU, 1 GB RAM | +| **Cost** | Daily limit $5, monthly limit $50, warnings at 80% | + +## Using Brave Search (recommended for deep research) + +1. Get a free API key at [brave.com/search/api](https://brave.com/search/api) +2. Update your `.env`: + +```ini +WEB_SEARCH_PROVIDER=brave +BRAVE_API_KEY=BSA-your-key-here +``` + +3. Restart: `docker compose restart` + +## Enabling HTTP Requests (optional, for specific APIs) + +If you need the agent to call specific research APIs (e.g., Notion, ClickUp): + +1. Edit `config.toml`: + +```toml +[http_request] +enabled = true +allowed_domains = ["api.notion.com", "api.clickup.com"] +``` + +2. Restart: `docker compose restart` + +> **Warning:** Only allow-list domains you trust. Never add broad domains like `*.google.com`. + +## Using Local Ollama Instead of Cloud LLMs + +To use a local Ollama instance running on your host machine: + +1. Update `.env`: + +```ini +API_KEY=http://host.docker.internal:11434 +PROVIDER=ollama +ZEROCLAW_MODEL=llama3.2 +``` + +2. Restart: `docker compose restart` + +> `host.docker.internal` resolves to your host machine from inside Docker Desktop. + +## Workflow: "You Propose, I Approve" + +Instruct the agent with a system-level workflow template: + +``` +You are a marketing research assistant. For every task: + +1. State the campaign goal +2. Present audience insights with sources +3. Build an angle matrix (hook × audience × channel) +4. Propose a channel plan with rationale +5. Draft a content calendar (7-30 days) +6. Suggest A/B test ideas + +NEVER take external actions. Always output drafts for my review. +I will copy approved content to my real accounts manually. +``` + +## Stopping the Agent + +```powershell +docker compose down +``` + +To also remove the workspace data volume: + +```powershell +docker compose down -v +``` + +## Troubleshooting + +| Issue | Fix | +|---|---| +| `API_KEY not set` | Ensure `.env` exists and `API_KEY` is filled in | +| Container unhealthy | Check logs: `docker logs zeroclaw-marketing` | +| Port 3000 in use | Change `HOST_PORT=3001` in `.env` | +| Brave search not working | Verify `BRAVE_API_KEY` is set and `WEB_SEARCH_PROVIDER=brave` | +| Ollama connection refused | Ensure Ollama is running and `host.docker.internal` resolves | diff --git a/deploy/marketing/config.toml b/deploy/marketing/config.toml new file mode 100644 index 0000000000..cfde964ac9 --- /dev/null +++ b/deploy/marketing/config.toml @@ -0,0 +1,121 @@ +# ZeroClaw Marketing Research Agent — Hardened Config +workspace_dir = "/zeroclaw-data/workspace" +config_path = "/zeroclaw-data/.zeroclaw/config.toml" + +default_provider = "openrouter" +default_model = "anthropic/claude-sonnet-4" +default_temperature = 0.7 + +# ── Model Routes (use hint: prefix in Telegram to switch) ── +# hint:gemma → free, lightweight, fast drafts +# hint:gpt → free, 20B param, solid general-purpose +# hint:deep → Claude Sonnet 4.5, premium deep reasoning +# hint:flash → Gemini Flash Lite, ultra-cheap fast research + +[[model_routes]] +hint = "gemma" +provider = "openrouter" +model = "google/gemma-3n-e4b-it:free" + +[[model_routes]] +hint = "gpt" +provider = "openrouter" +model = "openai/gpt-oss-20b:free" + +[[model_routes]] +hint = "deep" +provider = "openrouter" +model = "anthropic/claude-sonnet-4.5" + +[[model_routes]] +hint = "flash" +provider = "openrouter" +model = "google/gemini-2.5-flash-lite-preview-09-2025" + +[gateway] +port = 3000 +host = "[::]" +allow_public_bind = true +require_pairing = true +pair_rate_limit_per_minute = 5 +webhook_rate_limit_per_minute = 30 + +[autonomy] +level = "supervised" +workspace_only = true +require_approval_for_medium_risk = true +block_high_risk_commands = true +max_actions_per_hour = 60 +max_cost_per_day_cents = 500 +allowed_commands = ["ls", "cat", "head", "tail", "wc", "grep", "find", "echo", "pwd"] +forbidden_paths = [ + "/etc", "/root", "/home", "/usr", "/bin", "/sbin", + "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", + "/var", "/tmp", "~/.ssh", "~/.gnupg", "~/.aws", "~/.config", +] +auto_approve = ["file_read", "memory_recall", "web_search_tool"] +always_ask = [] + +[web_search] +enabled = true +provider = "duckduckgo" +max_results = 10 +timeout_secs = 20 + +[http_request] +enabled = true +allowed_domains = ["workflows.mi1-media.com"] +max_response_size = 1000000 +timeout_secs = 30 + +[memory] +backend = "sqlite" +auto_save = true + +[browser] +enabled = false + +[composio] +enabled = false + +[hardware] +enabled = false + +[peripherals] +enabled = false + +[secrets] +encrypt = true + +[cost] +enabled = true +daily_limit_usd = 5.00 +monthly_limit_usd = 50.00 +warn_at_percent = 80 + +[channels_config] +cli = true + +[channels_config.telegram] +bot_token = "8711868088:AAE3ymXEXa739HfPm0crvBEI6XMIVcRaORk" +allowed_users = ["8203092181"] +stream_mode = "partial" +mention_only = false + +[observability] +backend = "log" + +[agent] +max_tool_iterations = 15 +max_history_messages = 50 +compact_context = false +parallel_tools = false + +[scheduler] +enabled = false + +[cron] +enabled = false + +[runtime] +kind = "native" \ No newline at end of file diff --git a/deploy/marketing/docker-compose.yml b/deploy/marketing/docker-compose.yml new file mode 100644 index 0000000000..f33f2a0ce3 --- /dev/null +++ b/deploy/marketing/docker-compose.yml @@ -0,0 +1,101 @@ +# ZeroClaw Marketing Research Agent — Docker Compose +# ────────────────────────────────────────────────────── +# Hardened deployment for marketing research and planning. +# +# Quick start (Docker Desktop): +# 1. Copy .env.example to .env and fill in your API key +# 2. docker compose up -d +# 3. Access gateway at http://localhost:3000 +# 4. Pair your client: curl -X POST http://localhost:3000/pair +# +# Security posture: +# - No shell/SSH/Docker tools enabled +# - Workspace isolated to a named volume (marketing-sandbox) +# - Gateway bound to localhost only on the host side +# - Read-only config mount (agent cannot modify its own policy) +# - Resource-limited (1 CPU, 1 GB RAM) +# - No privileged capabilities, read-only root filesystem +# - Runs as non-root (uid 65534) + +name: zeroclaw-marketing + +services: + # Init container: copies config.toml into the config volume with correct ownership + init-config: + image: alpine:3.20 + container_name: zeroclaw-marketing-init + volumes: + - ./config.toml:/src/config.toml:ro + - zeroclaw-config:/dest + command: > + sh -c "cp /src/config.toml /dest/config.toml && + chown 65534:65534 /dest/config.toml && + chmod 600 /dest/config.toml && + echo 'Config initialized'" + + zeroclaw: + image: zeroclaw-local:latest + container_name: zeroclaw-marketing + restart: unless-stopped + depends_on: + init-config: + condition: service_completed_successfully + # Run in daemon mode: gateway + channels (Telegram) + heartbeat + command: ["daemon"] + + # ── Environment ────────────────────────────────────────── + environment: + - API_KEY=${API_KEY:?Set API_KEY in .env} + - PROVIDER=${PROVIDER:-openrouter} + - ZEROCLAW_MODEL=${ZEROCLAW_MODEL:-anthropic/claude-sonnet-4} + - ZEROCLAW_ALLOW_PUBLIC_BIND=true + - ZEROCLAW_GATEWAY_PORT=3000 + # Web search + - WEB_SEARCH_ENABLED=true + - WEB_SEARCH_PROVIDER=${WEB_SEARCH_PROVIDER:-duckduckgo} + - WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-10} + - BRAVE_API_KEY=${BRAVE_API_KEY:-} + + # ── Volumes ────────────────────────────────────────────── + volumes: + # Config volume — owned by uid 65534, writable for pairing/bind persistence + - zeroclaw-config:/zeroclaw-data/.zeroclaw + # Isolated workspace — agent can only read/write here + - marketing-sandbox:/zeroclaw-data/workspace + # Obsidian vault — read-only knowledge base + - "H:/Documents/Papi projects/Papi Random Project:/zeroclaw-data/workspace/knowledge:ro" + + # ── Networking ─────────────────────────────────────────── + ports: + # Bind to localhost ONLY — not exposed to LAN/internet + - "127.0.0.1:${HOST_PORT:-3000}:3000" + + # ── Resource Limits ────────────────────────────────────── + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + + # ── Security Hardening ─────────────────────────────────── + tmpfs: + - /tmp:size=64M,noexec,nosuid + security_opt: + - no-new-privileges:true + + # ── Health Check ───────────────────────────────────────── + healthcheck: + test: ["CMD", "zeroclaw", "status"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 15s + +volumes: + zeroclaw-config: + name: zeroclaw-marketing-config + marketing-sandbox: + name: zeroclaw-marketing-sandbox diff --git a/deploy/marketing/get-pairing-code.ps1 b/deploy/marketing/get-pairing-code.ps1 new file mode 100644 index 0000000000..d495c9f958 --- /dev/null +++ b/deploy/marketing/get-pairing-code.ps1 @@ -0,0 +1,27 @@ +# ZeroClaw Marketing Agent — Get Pairing Code +# Double-click this file or run it in PowerShell to see the current pairing codes. + +Write-Host "`n=== ZeroClaw Pairing Codes ===" -ForegroundColor Cyan +Write-Host "" + +$logs = docker logs zeroclaw-marketing --tail 30 2>&1 | Out-String + +# Gateway pairing code +if ($logs -match '│\s+(\d{6})\s+│') { + Write-Host " Web Dashboard code: $($Matches[1])" -ForegroundColor Green +} else { + Write-Host " Web Dashboard code: (already paired or container not running)" -ForegroundColor Yellow +} + +# Telegram bind code +if ($logs -match 'One-time bind code:\s+(\d{6})') { + Write-Host " Telegram /bind code: $($Matches[1])" -ForegroundColor Green +} else { + Write-Host " Telegram /bind code: (already bound or not configured)" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "Container status:" -ForegroundColor Cyan +docker ps --filter "name=zeroclaw-marketing" --format " {{.Names}} {{.Status}}" +Write-Host "" +Read-Host "Press Enter to close" From 50a824c3bc7b23bce7e8cd16751245d57e0d41d9 Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 21:00:39 -0400 Subject: [PATCH 06/11] fix(security): move Telegram bot_token from config.toml to .env - Replace hardcoded bot_token with __TELEGRAM_BOT_TOKEN__ placeholder - Init container now injects token via sed from TELEGRAM_BOT_TOKEN env var - Added TELEGRAM_BOT_TOKEN to .env.example with placeholder - Token stays in .env (gitignored), never committed to repo --- deploy/marketing/.env.example | 4 ++++ deploy/marketing/config.toml | 2 +- deploy/marketing/docker-compose.yml | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/deploy/marketing/.env.example b/deploy/marketing/.env.example index 41829f194a..bfa97d22e8 100644 --- a/deploy/marketing/.env.example +++ b/deploy/marketing/.env.example @@ -23,6 +23,10 @@ WEB_SEARCH_MAX_RESULTS=10 # Uncomment and set if using Brave: # BRAVE_API_KEY=your-brave-search-api-key +# ── Telegram ────────────────────────────────────────── +# Bot token from @BotFather (required for Telegram channel) +TELEGRAM_BOT_TOKEN=your-telegram-bot-token-here + # ── Docker Compose ────────────────────────────────────── # Host port for the gateway (change if 3000 is taken) HOST_PORT=3000 diff --git a/deploy/marketing/config.toml b/deploy/marketing/config.toml index cfde964ac9..4746902654 100644 --- a/deploy/marketing/config.toml +++ b/deploy/marketing/config.toml @@ -97,7 +97,7 @@ warn_at_percent = 80 cli = true [channels_config.telegram] -bot_token = "8711868088:AAE3ymXEXa739HfPm0crvBEI6XMIVcRaORk" +bot_token = "__TELEGRAM_BOT_TOKEN__" allowed_users = ["8203092181"] stream_mode = "partial" mention_only = false diff --git a/deploy/marketing/docker-compose.yml b/deploy/marketing/docker-compose.yml index f33f2a0ce3..38c05fb707 100644 --- a/deploy/marketing/docker-compose.yml +++ b/deploy/marketing/docker-compose.yml @@ -24,14 +24,17 @@ services: init-config: image: alpine:3.20 container_name: zeroclaw-marketing-init + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env} volumes: - ./config.toml:/src/config.toml:ro - zeroclaw-config:/dest command: > sh -c "cp /src/config.toml /dest/config.toml && + sed -i 's|__TELEGRAM_BOT_TOKEN__|'\"$$TELEGRAM_BOT_TOKEN\"'|g' /dest/config.toml && chown 65534:65534 /dest/config.toml && chmod 600 /dest/config.toml && - echo 'Config initialized'" + echo 'Config initialized (secrets injected)'" zeroclaw: image: zeroclaw-local:latest From cabc31cac3f0a380021c4cb24d75e111b4c78b05 Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 21:16:34 -0400 Subject: [PATCH 07/11] feat(deploy): enable wildcard HTTP access for marketing research - Changed allowed_domains from ['workflows.mi1-media.com'] to ['*'] - Agent can now visit any website for marketing research - Still protected by autonomy level (supervised), workspace isolation, and cost limits --- deploy/marketing/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/marketing/config.toml b/deploy/marketing/config.toml index 4746902654..a209ec3e74 100644 --- a/deploy/marketing/config.toml +++ b/deploy/marketing/config.toml @@ -64,7 +64,7 @@ timeout_secs = 20 [http_request] enabled = true -allowed_domains = ["workflows.mi1-media.com"] +allowed_domains = ["*"] max_response_size = 1000000 timeout_secs = 30 From dffbd567c6ba42500c040d6f830b2a399b5730ea Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 21:36:34 -0400 Subject: [PATCH 08/11] feat(deploy): add marketing team agent roster with persona switching - Created AGENTS.md with 16 specialist agents organized by category - Mounted agency-agents repo at /zeroclaw-data/workspace/agents/ (read-only) - AGENTS.md auto-injected into system prompt by ZeroClaw's OpenClaw identity system - User says 'Activate Book Co-Author' in Telegram to switch personas - Agents: Book Co-Author, Content Creator, Social Media Strategist, SEO Specialist, Brand Guardian, LinkedIn Creator, Podcast Strategist, Instagram Curator, TikTok Strategist, Twitter Engager, Reddit Builder, Orchestrator, Document Generator, Executive Summary, Analytics Reporter, Sales Extraction - Includes workflow templates (e.g., Book Chapter Development) --- deploy/marketing/AGENTS.md | 93 +++++++++++++++++++++++++++++ deploy/marketing/docker-compose.yml | 4 ++ 2 files changed, 97 insertions(+) create mode 100644 deploy/marketing/AGENTS.md diff --git a/deploy/marketing/AGENTS.md b/deploy/marketing/AGENTS.md new file mode 100644 index 0000000000..4ba3198f6d --- /dev/null +++ b/deploy/marketing/AGENTS.md @@ -0,0 +1,93 @@ +# Marketing Team — Agent Roster + +You are the **Marketing Team Orchestrator** for a book publishing and marketing operation. You have access to a library of specialist agent personas stored at `/zeroclaw-data/workspace/agents/`. + +## How Agent Activation Works + +When the user says **"Activate [Agent Name]"**, you must: + +1. Read the agent's full definition file from `/zeroclaw-data/workspace/agents/` using `file_read` +2. Adopt that agent's identity, mission, rules, workflow, and communication style for the rest of the conversation +3. Announce which agent is now active and briefly describe what you can help with +4. Stay in that persona until the user says **"Deactivate"** or **"Activate [different agent]"** + +When no agent is activated, you operate as the **Orchestrator** — helping the user choose the right agent for their task and coordinating multi-step workflows. + +## Core Book Marketing Team + +These are the primary agents for book development and marketing: + +| Command | Agent | What They Do | +|---------|-------|-------------| +| `Activate Book Co-Author` | Book Co-Author | Transforms voice notes and fragments into versioned chapter drafts with editorial notes | +| `Activate Content Creator` | Content Creator | Multi-platform content strategy, blog posts, video scripts, brand storytelling | +| `Activate Social Media Strategist` | Social Media Strategist | Platform strategy for LinkedIn, Twitter, Instagram, TikTok, Reddit | +| `Activate SEO Specialist` | SEO Specialist | Keyword research, on-page optimization, organic traffic growth | +| `Activate Brand Guardian` | Brand Guardian | Brand foundation, visual identity, voice consistency, brand protection | +| `Activate LinkedIn Creator` | LinkedIn Content Creator | LinkedIn-specific thought leadership and content strategy | +| `Activate Podcast Strategist` | Podcast Strategist | Podcast planning, guest strategy, audience building | +| `Activate Instagram Curator` | Instagram Curator | Visual content strategy, reels, stories, grid aesthetics | +| `Activate TikTok Strategist` | TikTok Strategist | Short-form video strategy, trends, audience growth | +| `Activate Twitter Engager` | Twitter Engager | Twitter/X engagement, threads, community building | +| `Activate Reddit Builder` | Reddit Community Builder | Reddit strategy, community engagement, authentic participation | + +## Support & Specialized Agents + +| Command | Agent | What They Do | +|---------|-------|-------------| +| `Activate Orchestrator` | Agents Orchestrator | Coordinates multi-agent pipelines and complex workflows | +| `Activate Document Generator` | Document Generator | Creates PDFs, presentations, spreadsheets, Word docs programmatically | +| `Activate Executive Summary` | Executive Summary Generator | Distills complex information into C-suite-ready summaries | +| `Activate Analytics Reporter` | Analytics Reporter | Transforms data into strategic insights and dashboards | +| `Activate Sales Extraction` | Sales Data Extraction | Monitors Excel files and extracts key sales metrics | + +## Agent File Locations + +Agent definitions are organized by category: + +- **Marketing agents**: `/zeroclaw-data/workspace/agents/marketing/` +- **Design agents**: `/zeroclaw-data/workspace/agents/design/` +- **Specialized agents**: `/zeroclaw-data/workspace/agents/specialized/` +- **Support agents**: `/zeroclaw-data/workspace/agents/support/` +- **Workflow examples**: `/zeroclaw-data/workspace/agents/examples/` + +## File Name Mapping + +| Agent | File Path | +|-------|-----------| +| Book Co-Author | `agents/marketing/marketing-book-co-author.md` | +| Content Creator | `agents/marketing/marketing-content-creator.md` | +| Social Media Strategist | `agents/marketing/marketing-social-media-strategist.md` | +| SEO Specialist | `agents/marketing/marketing-seo-specialist.md` | +| Brand Guardian | `agents/design/design-brand-guardian.md` | +| LinkedIn Content Creator | `agents/marketing/marketing-linkedin-content-creator.md` | +| Podcast Strategist | `agents/marketing/marketing-podcast-strategist.md` | +| Instagram Curator | `agents/marketing/marketing-instagram-curator.md` | +| TikTok Strategist | `agents/marketing/marketing-tiktok-strategist.md` | +| Twitter Engager | `agents/marketing/marketing-twitter-engager.md` | +| Reddit Community Builder | `agents/marketing/marketing-reddit-community-builder.md` | +| Agents Orchestrator | `agents/specialized/agents-orchestrator.md` | +| Document Generator | `agents/specialized/specialized-document-generator.md` | +| Executive Summary Generator | `agents/support/support-executive-summary-generator.md` | +| Analytics Reporter | `agents/support/support-analytics-reporter.md` | +| Sales Data Extraction | `agents/specialized/sales-data-extraction-agent.md` | + +## Workflow Examples + +The user can also reference workflow templates: + +- **Book Chapter Development**: `agents/examples/workflow-book-chapter.md` — Step-by-step process for turning raw material into a strategic chapter draft + +To use a workflow, read the file and follow the steps defined within. + +## Knowledge Base + +The user's Obsidian vault is mounted at `/zeroclaw-data/workspace/knowledge/`. This contains worldbuilding notes, research, and reference material that agents can access during their work. + +## Key Rules + +1. **Always read the full agent definition** before adopting a persona — don't improvise from the summary alone +2. **Stay in character** until explicitly told to switch or deactivate +3. **Use the knowledge base** when the task relates to the user's existing content +4. **Save important outputs to memory** so work persists across sessions +5. **Ask clarifying questions** before starting major work, as specified in each agent's workflow diff --git a/deploy/marketing/docker-compose.yml b/deploy/marketing/docker-compose.yml index 38c05fb707..1def00bcb3 100644 --- a/deploy/marketing/docker-compose.yml +++ b/deploy/marketing/docker-compose.yml @@ -67,6 +67,10 @@ services: - marketing-sandbox:/zeroclaw-data/workspace # Obsidian vault — read-only knowledge base - "H:/Documents/Papi projects/Papi Random Project:/zeroclaw-data/workspace/knowledge:ro" + # Agent team definitions — read-only persona library + - "H:/GitHub/agency-agents:/zeroclaw-data/workspace/agents:ro" + # AGENTS.md — injected into system prompt automatically by ZeroClaw + - ./AGENTS.md:/zeroclaw-data/workspace/AGENTS.md:ro # ── Networking ─────────────────────────────────────────── ports: From b5438cbbc54f2a785abf1e14192a71ca5242bae8 Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 21:49:09 -0400 Subject: [PATCH 09/11] feat(deploy): add host-accessible output folder for document creation - Mounted deploy/marketing/output/ into container at /zeroclaw-data/workspace/output/ - Init container chowns output dir to uid 65534 for write access - Updated AGENTS.md with output folder instructions for all agents - Agent can create .md, .txt, .pdf scripts, .docx scripts directly readable by user --- deploy/marketing/AGENTS.md | 12 ++++++++++++ deploy/marketing/docker-compose.yml | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/deploy/marketing/AGENTS.md b/deploy/marketing/AGENTS.md index 4ba3198f6d..2971626a5a 100644 --- a/deploy/marketing/AGENTS.md +++ b/deploy/marketing/AGENTS.md @@ -84,6 +84,17 @@ To use a workflow, read the file and follow the steps defined within. The user's Obsidian vault is mounted at `/zeroclaw-data/workspace/knowledge/`. This contains worldbuilding notes, research, and reference material that agents can access during their work. +## Output Folder + +When creating documents (PDF, Markdown, text, DOCX, etc.), **always save them to `/zeroclaw-data/workspace/output/`**. This folder is directly accessible to the user on their host machine. Use descriptive filenames with dates, e.g.: + +- `output/chapter-2-draft-v1.md` +- `output/book-marketing-plan-2026-03.md` +- `output/social-media-calendar-q2.md` +- `output/executive-summary-launch.txt` + +For formats like PDF and DOCX that require code generation, write the generation script to `output/` as well, then explain how to run it. + ## Key Rules 1. **Always read the full agent definition** before adopting a persona — don't improvise from the summary alone @@ -91,3 +102,4 @@ The user's Obsidian vault is mounted at `/zeroclaw-data/workspace/knowledge/`. T 3. **Use the knowledge base** when the task relates to the user's existing content 4. **Save important outputs to memory** so work persists across sessions 5. **Ask clarifying questions** before starting major work, as specified in each agent's workflow +6. **Save all documents to the output folder** — never save to other workspace paths if the user needs to read the file diff --git a/deploy/marketing/docker-compose.yml b/deploy/marketing/docker-compose.yml index 1def00bcb3..d500c517d6 100644 --- a/deploy/marketing/docker-compose.yml +++ b/deploy/marketing/docker-compose.yml @@ -29,11 +29,13 @@ services: volumes: - ./config.toml:/src/config.toml:ro - zeroclaw-config:/dest + - "H:/GitHub/zeroclaw-main/deploy/marketing/output:/output" command: > sh -c "cp /src/config.toml /dest/config.toml && sed -i 's|__TELEGRAM_BOT_TOKEN__|'\"$$TELEGRAM_BOT_TOKEN\"'|g' /dest/config.toml && chown 65534:65534 /dest/config.toml && chmod 600 /dest/config.toml && + chown -R 65534:65534 /output && echo 'Config initialized (secrets injected)'" zeroclaw: @@ -67,6 +69,8 @@ services: - marketing-sandbox:/zeroclaw-data/workspace # Obsidian vault — read-only knowledge base - "H:/Documents/Papi projects/Papi Random Project:/zeroclaw-data/workspace/knowledge:ro" + # Output folder — agent writes here, user reads from host + - "H:/GitHub/zeroclaw-main/deploy/marketing/output:/zeroclaw-data/workspace/output" # Agent team definitions — read-only persona library - "H:/GitHub/agency-agents:/zeroclaw-data/workspace/agents:ro" # AGENTS.md — injected into system prompt automatically by ZeroClaw From cac48691d309e98642e48246fb6cabf24bed1fd9 Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 21:57:37 -0400 Subject: [PATCH 10/11] feat(deploy): make orchestrator the default mode for automatic agent selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Orchestrator now auto-selects the right specialist based on user's request - No need to manually 'Activate' agents — it reads definitions and adopts workflows automatically - Multi-step projects get planned across multiple specialists sequentially - Manual override still available via 'Activate [Agent Name]' --- deploy/marketing/AGENTS.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/deploy/marketing/AGENTS.md b/deploy/marketing/AGENTS.md index 2971626a5a..965c87f47c 100644 --- a/deploy/marketing/AGENTS.md +++ b/deploy/marketing/AGENTS.md @@ -1,17 +1,29 @@ -# Marketing Team — Agent Roster +# Marketing Team — Orchestrator -You are the **Marketing Team Orchestrator** for a book publishing and marketing operation. You have access to a library of specialist agent personas stored at `/zeroclaw-data/workspace/agents/`. +You are the **Marketing Team Orchestrator** for a book publishing and marketing operation. You have a team of specialist agents stored at `/zeroclaw-data/workspace/agents/`. -## How Agent Activation Works +## How You Work -When the user says **"Activate [Agent Name]"**, you must: +You **always** operate as the orchestrator. When the user gives you a task: -1. Read the agent's full definition file from `/zeroclaw-data/workspace/agents/` using `file_read` -2. Adopt that agent's identity, mission, rules, workflow, and communication style for the rest of the conversation -3. Announce which agent is now active and briefly describe what you can help with -4. Stay in that persona until the user says **"Deactivate"** or **"Activate [different agent]"** +1. **Analyze the request** and determine which specialist agent(s) are best suited +2. **Read the specialist's full definition** from `/zeroclaw-data/workspace/agents/` using `file_read` +3. **Adopt that specialist's workflow, rules, and deliverable format** to execute the task +4. **For multi-step projects**, plan the pipeline across multiple specialists and execute each phase sequentially +5. **Announce which specialist you're working as** so the user knows who's handling their request -When no agent is activated, you operate as the **Orchestrator** — helping the user choose the right agent for their task and coordinating multi-step workflows. +### Automatic Agent Selection Examples + +- User asks to write a chapter → **Book Co-Author** +- User asks for a social media plan → **Social Media Strategist** +- User asks for LinkedIn posts → **LinkedIn Content Creator** +- User asks for a brand guide → **Brand Guardian** +- User asks for a marketing launch plan → **Orchestrator coordinates** Book Co-Author + Content Creator + Social Media Strategist + Brand Guardian +- User asks for a summary report → **Executive Summary Generator** + +### Manual Override + +The user can still say **"Activate [Agent Name]"** to force a specific specialist, or **"Deactivate"** to return to general orchestrator mode. ## Core Book Marketing Team From 30b83ef7b0ee876ccb621273471f6144fb85868f Mon Sep 17 00:00:00 2001 From: mionemedia Date: Mon, 16 Mar 2026 23:02:39 -0400 Subject: [PATCH 11/11] feat(telegram): support incoming file uploads (documents, photos, voice, audio, video) - parse_update_message now falls back to 'caption' when 'text' is absent - Extract file metadata from document, photo, voice, audio, and video messages - Download files via Telegram Bot API (getFile + file URL) and save to workspace - Files saved to output/uploads/ directory (host-accessible) - Prepend file path to message content so agent can reference uploaded files - Graceful fallback: if download fails, agent still receives the caption text --- src/channels/telegram.rs | 205 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 192 insertions(+), 13 deletions(-) diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index ca0e03b29a..335b9b7d5f 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -74,6 +74,19 @@ struct TelegramAttachment { target: String, } +/// Metadata for a file received from Telegram (document, photo, etc.) +#[derive(Debug, Clone)] +struct TelegramIncomingFile { + file_id: String, + file_name: String, +} + +/// Result of parsing an incoming Telegram update — message plus optional file. +struct ParsedTelegramUpdate { + message: ChannelMessage, + file: Option, +} + impl TelegramAttachmentKind { fn from_marker(marker: &str) -> Option { match marker.trim().to_ascii_uppercase().as_str() { @@ -738,10 +751,22 @@ Allowlist Telegram username (without '@') or numeric user ID.", } } - fn parse_update_message(&self, update: &serde_json::Value) -> Option { + fn parse_update_message(&self, update: &serde_json::Value) -> Option { let message = update.get("message")?; - let text = message.get("text").and_then(serde_json::Value::as_str)?; + // Try "text" first, then "caption" (used with documents/photos) + let text_field = message + .get("text") + .and_then(serde_json::Value::as_str) + .or_else(|| message.get("caption").and_then(serde_json::Value::as_str)); + + // Extract incoming document metadata if present + let incoming_file = Self::extract_incoming_file(message); + + // Must have either text/caption or a file attachment to proceed + if text_field.is_none() && incoming_file.is_none() { + return None; + } let username = message .get("from") @@ -771,6 +796,8 @@ Allowlist Telegram username (without '@') or numeric user ID.", return None; } + let text = text_field.unwrap_or(""); + let is_group = Self::is_group_message(message); if self.mention_only && is_group { let bot_username = self.bot_username.lock(); @@ -811,23 +838,91 @@ Allowlist Telegram username (without '@') or numeric user ID.", let bot_username = self.bot_username.lock(); let bot_username = bot_username.as_ref()?; Self::normalize_incoming_content(text, bot_username)? + } else if text.is_empty() { + // File-only message with no caption — synthesize content + if let Some(ref file) = incoming_file { + format!("I've uploaded a file: {}", file.file_name) + } else { + return None; + } } else { text.to_string() }; - Some(ChannelMessage { - id: format!("telegram_{chat_id}_{message_id}"), - sender: sender_identity, - reply_target, - content, - channel: "telegram".to_string(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + Some(ParsedTelegramUpdate { + message: ChannelMessage { + id: format!("telegram_{chat_id}_{message_id}"), + sender: sender_identity, + reply_target, + content, + channel: "telegram".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }, + file: incoming_file, }) } + /// Extract file metadata from a Telegram message (document or photo). + fn extract_incoming_file(message: &serde_json::Value) -> Option { + // Check for document (pdf, docx, txt, etc.) + if let Some(doc) = message.get("document") { + let file_id = doc.get("file_id").and_then(|v| v.as_str())?.to_string(); + let file_name = doc + .get("file_name") + .and_then(|v| v.as_str()) + .unwrap_or("document") + .to_string(); + return Some(TelegramIncomingFile { file_id, file_name }); + } + + // Check for photo (array of sizes — pick largest) + if let Some(photos) = message.get("photo").and_then(|v| v.as_array()) { + if let Some(largest) = photos.last() { + let file_id = largest.get("file_id").and_then(|v| v.as_str())?.to_string(); + return Some(TelegramIncomingFile { + file_id, + file_name: "photo.jpg".to_string(), + }); + } + } + + // Check for voice message + if let Some(voice) = message.get("voice") { + let file_id = voice.get("file_id").and_then(|v| v.as_str())?.to_string(); + return Some(TelegramIncomingFile { + file_id, + file_name: "voice.ogg".to_string(), + }); + } + + // Check for audio + if let Some(audio) = message.get("audio") { + let file_id = audio.get("file_id").and_then(|v| v.as_str())?.to_string(); + let file_name = audio + .get("file_name") + .and_then(|v| v.as_str()) + .unwrap_or("audio.mp3") + .to_string(); + return Some(TelegramIncomingFile { file_id, file_name }); + } + + // Check for video + if let Some(video) = message.get("video") { + let file_id = video.get("file_id").and_then(|v| v.as_str())?.to_string(); + let file_name = video + .get("file_name") + .and_then(|v| v.as_str()) + .unwrap_or("video.mp4") + .to_string(); + return Some(TelegramIncomingFile { file_id, file_name }); + } + + None + } + async fn send_text_chunks( &self, message: &str, @@ -1411,6 +1506,61 @@ Allowlist Telegram username (without '@') or numeric user ID.", self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption) .await } + + /// Download a file from Telegram servers and save it to the workspace uploads directory. + async fn download_telegram_file( + &self, + file_id: &str, + file_name: &str, + ) -> anyhow::Result { + // 1. Call getFile to get the server-side file_path + let resp = self + .http_client() + .get(format!("{}?file_id={}", self.api_url("getFile"), file_id)) + .send() + .await + .context("Telegram getFile request failed")?; + + if !resp.status().is_success() { + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Telegram getFile failed: {err}"); + } + + let data: serde_json::Value = resp.json().await?; + let file_path = data + .get("result") + .and_then(|r| r.get("file_path")) + .and_then(|p| p.as_str()) + .context("Missing file_path in Telegram getFile response")?; + + // 2. Download the file bytes + let download_url = format!( + "https://api.telegram.org/file/bot{}/{}", + self.bot_token, file_path + ); + let file_bytes = self + .http_client() + .get(&download_url) + .send() + .await + .context("Telegram file download failed")? + .bytes() + .await?; + + // 3. Save to workspace uploads directory + let uploads_dir = Path::new("/zeroclaw-data/workspace/output/uploads"); + tokio::fs::create_dir_all(uploads_dir).await?; + + let dest = uploads_dir.join(file_name); + tokio::fs::write(&dest, &file_bytes).await?; + + tracing::info!( + "Telegram file saved: {} ({} bytes)", + dest.display(), + file_bytes.len() + ); + Ok(dest) + } } #[async_trait] @@ -1744,10 +1894,39 @@ Ensure only one `zeroclaw` process is using this bot token." offset = uid + 1; } - let Some(msg) = self.parse_update_message(update) else { + let Some(parsed) = self.parse_update_message(update) else { self.handle_unauthorized_message(update).await; continue; }; + + let mut msg = parsed.message; + + // Download attached file and prepend path to message content + if let Some(file_info) = parsed.file { + match self + .download_telegram_file(&file_info.file_id, &file_info.file_name) + .await + { + Ok(path) => { + msg.content = format!( + "[Uploaded file saved to: {}]\n\n{}", + path.display(), + msg.content + ); + } + Err(e) => { + tracing::warn!( + "Failed to download Telegram file '{}': {e}", + file_info.file_name + ); + msg.content = format!( + "[File upload received: '{}' but download failed: {e}]\n\n{}", + file_info.file_name, msg.content + ); + } + } + } + // Send "typing" indicator immediately when we receive a message let typing_body = serde_json::json!({ "chat_id": &msg.reply_target,