feat(deploy): Uno Q workshop-node provisioning kit #2
@@ -0,0 +1,92 @@
|
|||||||
|
# Uno Q workshop node — provisioning
|
||||||
|
|
||||||
|
Turns an Arduino Uno Q into an APESS workshop node: a ZeroClaw daemon that
|
||||||
|
exposes an HTTP/SSE gateway, drives the on-board MCU (generate → compile →
|
||||||
|
flash), and answers with cloud-first / on-board-Qwen-fallback inference. APESS
|
||||||
|
proxies each board and streams its activity to participants and instructors.
|
||||||
|
|
||||||
|
## What a provisioned board runs
|
||||||
|
|
||||||
|
- **`zeroclaw daemon`** on `:8080` — the gateway APESS talks to (`/pair`,
|
||||||
|
`/webhook`, `/ws/chat`, `/api/events`, `/health`). Use `daemon`, **not**
|
||||||
|
`gateway start` — only the daemon/agent/channel paths register the hardware
|
||||||
|
tools (`uno_q_flash`, …).
|
||||||
|
- **`llama-server`** on `:8083` — on-board Qwen for offline/fallback inference.
|
||||||
|
- **Three agents**, selected per request via `?agent=` (APESS maps the harness
|
||||||
|
provider choice → alias; see `src/lib/harness.ts` `harnessToAgent`):
|
||||||
|
| alias | provider | behaviour |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| `default` | `custom.claude` | cloud, **fallback to on-board Qwen** |
|
||||||
|
| `cloud` | `custom.cloud` | cloud only |
|
||||||
|
| `local` | `llamacpp.local`| on-board Qwen only (fully offline) |
|
||||||
|
|
||||||
|
## Prerequisites on the board
|
||||||
|
|
||||||
|
- `/home/arduino/zeroclaw` — the ZeroClaw binary (aarch64), **with the
|
||||||
|
gateway-peripheral-registration fix** (merged to `osobh/zeroclaw` main).
|
||||||
|
- `/home/arduino/llama/llama-server` + `/home/arduino/models/qwen.gguf`.
|
||||||
|
- Arduino Zephyr core `arduino:zephyr:unoq` (0.51.0) + OpenOCD for flashing,
|
||||||
|
and `ArduinoGraphics` if using scroll text.
|
||||||
|
|
||||||
|
## Provision (dev — over USB/adb)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export APESS_ADMIN_CODE=adm-xxxxxxxx # instructor code
|
||||||
|
./provision-uno-q.sh <adb-serial> team-07 https://apess-api.redclaw.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps it runs: install `config.template.toml` (cloud endpoint substituted) →
|
||||||
|
start `llama-server` → start `zeroclaw daemon` → pair for a bearer token →
|
||||||
|
`POST /nodes` to APESS. Idempotent; re-run to re-provision.
|
||||||
|
|
||||||
|
Override the cloud endpoint (default is the local `claude_shim` on `:8090`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CLOUD_URI=https://api.anthropic.com/v1 CLOUD_MODEL=claude-haiku-4-5 \
|
||||||
|
APESS_ADMIN_CODE=adm-xxxx ./provision-uno-q.sh <serial> team-07
|
||||||
|
```
|
||||||
|
|
||||||
|
> The shim on `:8090` is a bring-up convenience (one Mac). A real fleet points
|
||||||
|
> `CLOUD_URI` at a shared cloud endpoint (Anthropic / OpenRouter / LiteLLM) with
|
||||||
|
> a key, so boards don't each need a tunnel.
|
||||||
|
|
||||||
|
## Provision (production — systemd, boots on power-up)
|
||||||
|
|
||||||
|
With root on the board:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo cp systemd/zeroclaw-llama.service systemd/zeroclaw-daemon.service /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now zeroclaw-llama zeroclaw-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
For a LAN fleet (participants reach the board's WiFi IP directly), set in
|
||||||
|
`config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[gateway]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
allow_public_bind = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pair + register once (steps 4–5 of the script) so APESS has the board's
|
||||||
|
`{ url, token }`.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s <serial> forward tcp:8080 tcp:8080
|
||||||
|
curl -s localhost:8080/health # {"status":"ok"}
|
||||||
|
# each agent alias resolves (needs a bearer token from /pair):
|
||||||
|
curl -s -X POST 'localhost:8080/webhook?agent=local' -H "authorization: Bearer $TOK" \
|
||||||
|
-H 'content-type: application/json' -d '{"message":"one word: local"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`?agent=local` routes to Qwen (offline), `?agent=cloud` to the cloud, `?agent=default`
|
||||||
|
cloud-with-fallback. In APESS, the team's provider/fallback toggle picks the alias.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `config.template.toml` — the node config (secrets stripped; `__CLOUD_URI__` /
|
||||||
|
`__CLOUD_MODEL__` substituted at provision time).
|
||||||
|
- `provision-uno-q.sh` — one-shot provisioner (adb-driven).
|
||||||
|
- `systemd/*.service` — production units.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
schema_version = 3
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Providers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# "cloud" primary. During bring-up this points at the local claude_shim
|
||||||
|
# (adb-reverse tunnel on :8090). For a real fleet, swap in a shared cloud
|
||||||
|
# endpoint + key, e.g.:
|
||||||
|
# uri = "https://api.anthropic.com/v1" (or an OpenRouter/LiteLLM gateway)
|
||||||
|
# api_key = "..." (prefer an env-injected key)
|
||||||
|
[providers.models.custom.cloud]
|
||||||
|
uri = "__CLOUD_URI__"
|
||||||
|
model = "__CLOUD_MODEL__"
|
||||||
|
native_tools = false
|
||||||
|
|
||||||
|
# Same cloud endpoint, but with an on-board Qwen fallback ("cloud first,
|
||||||
|
# local if it fails"). Used by the `default` agent.
|
||||||
|
[providers.models.custom.claude]
|
||||||
|
uri = "__CLOUD_URI__"
|
||||||
|
model = "__CLOUD_MODEL__"
|
||||||
|
native_tools = false
|
||||||
|
fallback = ["llamacpp.local"]
|
||||||
|
|
||||||
|
[providers.models.llamacpp]
|
||||||
|
|
||||||
|
# On-board Qwen via llama-server (see zeroclaw-llama.service).
|
||||||
|
[providers.models.llamacpp.local]
|
||||||
|
uri = "http://127.0.0.1:8083/v1"
|
||||||
|
model = "qwen"
|
||||||
|
native_tools = false
|
||||||
|
|
||||||
|
[providers.models.custom]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hardware — the Uno Q's onboard MCU over the GPIO bridge.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[[peripherals.boards]]
|
||||||
|
board = "arduino-uno-q"
|
||||||
|
transport = "bridge"
|
||||||
|
|
||||||
|
[peripherals]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gateway — the HTTP/WS/SSE surface APESS talks to.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[gateway]
|
||||||
|
port = 8080
|
||||||
|
# Bind 0.0.0.0 + allow_public_bind for a real LAN fleet (participants reach the
|
||||||
|
# board's WiFi IP). Leave default (localhost) when reaching it over adb-forward.
|
||||||
|
# host = "0.0.0.0"
|
||||||
|
# allow_public_bind = true
|
||||||
|
# paired_tokens are added by the pairing flow (see provision-uno-q.sh); never
|
||||||
|
# commit a real token.
|
||||||
|
|
||||||
|
[skills]
|
||||||
|
prompt_injection_mode = "compact"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Risk profile — only the on-board hardware tools, auto-approved so the agent
|
||||||
|
# can flash without a human in the loop.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[risk_profiles.default]
|
||||||
|
level = "supervised"
|
||||||
|
allowed_tools = ["uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect"]
|
||||||
|
auto_approve = ["uno_q_flash", "sysfs_led", "camera", "network", "i2cdetect", "file_read", "content_search"]
|
||||||
|
|
||||||
|
[runtime_profiles.unoq]
|
||||||
|
agentic = true
|
||||||
|
max_tool_iterations = 6
|
||||||
|
strict_tool_parsing = false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agents — one per provider strategy. The APESS harness choice routes here via
|
||||||
|
# ?agent= (see harnessToAgent in src/lib/harness.ts).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[agents.default] # cloud + on-board Qwen fallback
|
||||||
|
enabled = true
|
||||||
|
model_provider = "custom.claude"
|
||||||
|
risk_profile = "default"
|
||||||
|
runtime_profile = "unoq"
|
||||||
|
|
||||||
|
[agents.cloud] # cloud only, no fallback
|
||||||
|
enabled = true
|
||||||
|
model_provider = "custom.cloud"
|
||||||
|
risk_profile = "default"
|
||||||
|
runtime_profile = "unoq"
|
||||||
|
|
||||||
|
[agents.local] # on-board Qwen only (fully offline)
|
||||||
|
enabled = true
|
||||||
|
model_provider = "llamacpp.local"
|
||||||
|
risk_profile = "default"
|
||||||
|
runtime_profile = "unoq"
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Provision an Arduino Uno Q as an APESS workshop node — host-driven via adb,
|
||||||
|
# no root needed on the board. Installs config, brings up the on-board Qwen and
|
||||||
|
# the ZeroClaw daemon, pairs for a bearer token, and registers with APESS.
|
||||||
|
#
|
||||||
|
# APESS_ADMIN_CODE=adm-xxxx ./provision-uno-q.sh <adb-serial> <team-id> [apess-url]
|
||||||
|
#
|
||||||
|
# Env overrides: CLOUD_URI, CLOUD_MODEL (default: local shim :8090 / sonnet).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERIAL="${1:?usage: provision-uno-q.sh <adb-serial> <team-id> [apess-url]}"
|
||||||
|
TEAM="${2:?team id required}"
|
||||||
|
APESS="${3:-https://apess-api.redclaw.dev}"
|
||||||
|
CLOUD_URI="${CLOUD_URI:-http://127.0.0.1:8090/v1}"
|
||||||
|
CLOUD_MODEL="${CLOUD_MODEL:-sonnet}"
|
||||||
|
ADMIN_CODE="${APESS_ADMIN_CODE:?set APESS_ADMIN_CODE to register with APESS}"
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
a() { adb -s "$SERIAL" "$@"; }
|
||||||
|
|
||||||
|
echo "==> [1/5] install config (cloud=$CLOUD_URI)"
|
||||||
|
sed -e "s#__CLOUD_URI__#${CLOUD_URI}#g" -e "s#__CLOUD_MODEL__#${CLOUD_MODEL}#g" \
|
||||||
|
"$HERE/config.template.toml" > /tmp/apess-node-config.toml
|
||||||
|
a shell 'mkdir -p /home/arduino/.zeroclaw'
|
||||||
|
a push /tmp/apess-node-config.toml /home/arduino/.zeroclaw/config.toml >/dev/null
|
||||||
|
|
||||||
|
echo "==> [2/5] start on-board Qwen (llama-server :8083)"
|
||||||
|
a shell 'pgrep -f llama-server >/dev/null 2>&1 || (cd /home/arduino/llama && \
|
||||||
|
LD_LIBRARY_PATH=/home/arduino/llama nohup ./llama-server -m /home/arduino/models/qwen.gguf \
|
||||||
|
--host 127.0.0.1 --port 8083 -np 1 -c 16384 --jinja --mlock >/tmp/llama8083.log 2>&1 &)'
|
||||||
|
|
||||||
|
echo "==> [3/5] start zeroclaw daemon (:8080)"
|
||||||
|
a shell 'pkill -f "zeroclaw daemon" 2>/dev/null; pkill -f "zeroclaw gateway" 2>/dev/null; sleep 2; \
|
||||||
|
nohup /home/arduino/zeroclaw daemon >/tmp/zc-daemon.log 2>&1 &'
|
||||||
|
sleep 8
|
||||||
|
|
||||||
|
echo "==> [4/5] pair → bearer token"
|
||||||
|
a forward tcp:8080 tcp:8080 >/dev/null
|
||||||
|
until curl -sf --max-time 3 http://127.0.0.1:8080/health >/dev/null; do sleep 1; done
|
||||||
|
CODE=$(a shell '/home/arduino/zeroclaw gateway get-paircode --new --port 8080' | grep -oE '[0-9]{6}' | head -1)
|
||||||
|
TOKEN=$(curl -s -X POST http://127.0.0.1:8080/pair -H "X-Pairing-Code: ${CODE}" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
|
||||||
|
[ -n "$TOKEN" ] || { echo "pairing failed" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "==> [5/5] register team '$TEAM' with APESS"
|
||||||
|
# Board's LAN IP — what participants reach in a real fleet (falls back to localhost).
|
||||||
|
IP=$(a shell "ip -4 -o addr show 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' | awk '{print \$2}' | head -1" | tr -d '\r')
|
||||||
|
URL="http://${IP:-127.0.0.1}:8080"
|
||||||
|
curl -sf -X POST "${APESS}/nodes" -H "x-access-code: ${ADMIN_CODE}" -H 'content-type: application/json' \
|
||||||
|
-d "{\"teamId\":\"${TEAM}\",\"url\":\"${URL}\",\"token\":\"${TOKEN}\"}" >/dev/null \
|
||||||
|
&& echo " registered at ${URL}" || echo " WARN: APESS registration failed (is ${APESS} reachable?)"
|
||||||
|
|
||||||
|
echo "==> done — node '$TEAM' is live. Agents: default (cloud+fallback), cloud, local."
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ZeroClaw daemon (gateway + agent runtime)
|
||||||
|
After=network.target zeroclaw-llama.service
|
||||||
|
Wants=zeroclaw-llama.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=arduino
|
||||||
|
# Use `daemon`, NOT `gateway start`: only the daemon/agent/channel arms register
|
||||||
|
# the peripheral tools (uno_q_flash etc.). `gateway start` on an older build
|
||||||
|
# leaves the agent unable to flash.
|
||||||
|
ExecStart=/home/arduino/zeroclaw daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ZeroClaw on-board Qwen (llama-server)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=arduino
|
||||||
|
Environment=LD_LIBRARY_PATH=/home/arduino/llama
|
||||||
|
# -np 1 is mandatory (a bigger slot count makes the tiny model 500 on ctx).
|
||||||
|
ExecStart=/home/arduino/llama/llama-server -m /home/arduino/models/qwen.gguf \
|
||||||
|
--host 127.0.0.1 --port 8083 -np 1 -c 16384 --jinja --mlock
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Reference in New Issue
Block a user