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/deploy/marketing/.env.example b/deploy/marketing/.env.example new file mode 100644 index 0000000000..bfa97d22e8 --- /dev/null +++ b/deploy/marketing/.env.example @@ -0,0 +1,32 @@ +# 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 + +# ── 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/.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/AGENTS.md b/deploy/marketing/AGENTS.md new file mode 100644 index 0000000000..965c87f47c --- /dev/null +++ b/deploy/marketing/AGENTS.md @@ -0,0 +1,117 @@ +# Marketing Team — Orchestrator + +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 You Work + +You **always** operate as the orchestrator. When the user gives you a task: + +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 + +### 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 + +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. + +## 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 +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 +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/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..a209ec3e74 --- /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 = ["*"] +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 = "__TELEGRAM_BOT_TOKEN__" +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..d500c517d6 --- /dev/null +++ b/deploy/marketing/docker-compose.yml @@ -0,0 +1,112 @@ +# 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 + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env} + 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: + 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" + # 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 + - ./AGENTS.md:/zeroclaw-data/workspace/AGENTS.md: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" 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..8079e5b107 100644 --- a/firmware/zeroclaw-uno-q-bridge/python/main.py +++ b/firmware/zeroclaw-uno-q-bridge/python/main.py @@ -1,36 +1,100 @@ -# 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 +# +# 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 -def handle_client(conn): +# Queue of (conn, data_str) tuples processed on the main thread. +request_queue = queue.Queue() + + +def process_request(data, conn): + """Process a single bridge command on the main thread.""" try: - data = conn.recv(256).decode().strip() - if not data: - conn.close() - return parts = data.split() - if len(parts) < 2: - conn.sendall(b"error: invalid command\n") - conn.close() + if not parts: + conn.sendall(b"error: empty command\n") 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: + val = Bridge.call("analogRead", int(parts[1])) conn.sendall(f"{val}\n".encode()) + + # ── PWM ─────────────────────────────────────────────── + elif cmd == "pwm_write" and len(parts) >= 3: + result = Bridge.call("analogWrite", int(parts[1]), int(parts[2])) + 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: + 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: + result = Bridge.call("spiTransfer", parts[1]) + conn.sendall(f"{result}\n".encode()) + + # ── CAN ─────────────────────────────────────────────── + elif cmd == "can_send" and len(parts) >= 3: + result = Bridge.call("canSend", int(parts[1]), parts[2]) + 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: + Bridge.call("ledMatrix", parts[1]) + conn.sendall(b"ok\n") + + # ── RGB LED ─────────────────────────────────────────── + elif cmd == "rgb_led" and len(parts) >= 5: + 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 0 or 1)\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: + 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: @@ -38,29 +102,44 @@ def handle_client(conn): finally: conn.close() + 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) + 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..7bc03e3751 100644 --- a/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino +++ b/firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino @@ -1,7 +1,79 @@ -// 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 String &hex, uint8_t *buf, int max_len) { + int len = 0; + 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 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++) { + result += hexchars[(data[i] >> 4) & 0x0F]; + result += hexchars[data[i] & 0x0F]; + } + return result; +} + +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 +85,146 @@ int gpio_read(int pin) { return digitalRead(pin); } +// ── ADC (12-bit, A0-A5) ──────────────────────────────────────── + +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); + return analogRead(pin); +} + +// ── PWM (D3, D5, D6, D9, D10, D11) ───────────────────────────── + +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; + pinMode(pin, OUTPUT); + analogWrite(pin, duty); + return 0; +} + +// ── I2C scan ──────────────────────────────────────────────────── + +String bridge_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 (all String params for MsgPack compatibility) ── + +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"; + + 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(); + } + return hex_encode(rx_buf, count); + } + return "ok"; +} + +// ── SPI transfer ──────────────────────────────────────────────── + +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"; + + 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(); + + return hex_encode(rx_buf, len); +} + +// ── CAN (stub — needs Zephyr FDCAN driver) ────────────────────── + +int bridge_can_send(int id, String hex_data) { + (void)id; + (void)hex_data; + return -2; // not yet available +} + +// ── LED matrix (8x13, 13-byte 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. + (void)bitmap; + return 0; +} + +// ── RGB LED (MCU LEDs 3-4, active-low) ────────────────────────── + +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); + 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 bridge_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("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/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); 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, 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,951 @@ 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:") { - Ok(ToolResult { - success: false, - output: resp.clone(), - error: Some(resp), - }) - } else { - Ok(ToolResult { - success: true, - output: "done".into(), - error: None, - }) + + 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 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": { + "width": { + "type": "integer", + "description": "Image width in pixels (default: 1280)" + }, + "height": { + "type": "integer", + "description": "Image height in pixels (default: 720)" + }, + "device": { + "type": "string", + "description": "V4L2 device path (default: /dev/video0)" } } + }) + } + + async fn execute(&self, args: Value) -> anyhow::Result { + 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("/dev/video0"); + let output_path = "/tmp/zeroclaw_capture.jpg"; + + let fmt = format!("width={},height={},pixelformat=MJPG", width, height); + let output = tokio::process::Command::new("v4l2-ctl") + .args([ + "-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) => { + 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!("Bridge error: {}", e), + output: format!("Failed to run v4l2-ctl: {}. Is v4l-utils installed?", 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(()) +}