feat(uno-q): board self-register on boot + QR claim prefill (onboarding slice 3)
Board-side half of "preloaded + self-register + claim", plus the QR flow.
- apess-selfregister.sh (on-board): pairs locally for a token, discovers
the LAN IP, and announces {kitId, url, token, claimCode} to APESS
/nodes/self-register (x-fleet-secret gated). Retries until APESS is up;
idempotent, safe on boot and on a timer.
- systemd/apess-selfregister.{service,timer}: self-register After the
daemon, re-announce every 5 min so a DHCP lease change can't strand a
board.
- apess-node.env.example: per-board identity (KIT_ID, CLAIM_CODE,
FLEET_SECRET, APESS_URL).
- gen-kit-codes.sh (host): mint per-kit 6-digit codes, write the
per-board env files, and emit the QR sticker CSV
(…/workshop?kit=KIT-NN&code=NNNNNN).
- web: BoardClaim accepts initialCode; TeamRegistration pre-fills it from
?code= so scanning the kit QR fills kit + code — one tap to claim.
- deploy/uno-q/README: documents the whole self-serve onboarding path.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0442f7e865
commit
3f6c6ab399
@@ -50,6 +50,48 @@ CLOUD_URI=https://api.anthropic.com/v1 CLOUD_MODEL=claude-haiku-4-5 \
|
|||||||
> `CLOUD_URI` at a shared cloud endpoint (Anthropic / OpenRouter / LiteLLM) with
|
> `CLOUD_URI` at a shared cloud endpoint (Anthropic / OpenRouter / LiteLLM) with
|
||||||
> a key, so boards don't each need a tunnel.
|
> a key, so boards don't each need a tunnel.
|
||||||
|
|
||||||
|
## Onboarding — attendee self-serve (self-register + claim)
|
||||||
|
|
||||||
|
The workshop path: boards are **preloaded** and, on boot, **announce themselves**
|
||||||
|
into APESS's *unclaimed pool*; an attendee then **claims** their board to their
|
||||||
|
team from the web app (kit + 6-digit code) — no operator, no admin code, no adb.
|
||||||
|
|
||||||
|
```
|
||||||
|
board boot ──self-register {kitId,url,token}──▶ APESS unclaimed pool
|
||||||
|
▲
|
||||||
|
attendee: scan kit QR → team name → 6-digit code ──POST /claim──┘
|
||||||
|
(bearer token moves pool → node bridge, never touches the browser)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fleet prep (host, once).** Mint per-kit codes + per-board env files + the QR
|
||||||
|
sticker CSV:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
FLEET_SECRET=<shared-secret> APESS_URL=https://apess-api.redclaw.dev \
|
||||||
|
APESS_WEB=https://apess.redclaw.dev ./gen-kit-codes.sh 15
|
||||||
|
# → kit-codes/env/KIT-NN.env (one per board) kit-codes/kit-codes.csv (stickers)
|
||||||
|
```
|
||||||
|
|
||||||
|
The same `FLEET_SECRET` must be set on the APESS API (`FLEET_SECRET` env) — it
|
||||||
|
gates `/nodes/self-register` so only your boards can seed the pool. Each sticker
|
||||||
|
QR encodes `…/workshop?kit=KIT-NN&code=NNNNNN`, so scanning it pre-fills both.
|
||||||
|
|
||||||
|
**Per board.** Drop its env file + the self-register unit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb -s <serial> push kit-codes/env/KIT-07.env /home/arduino/.zeroclaw/apess-node.env
|
||||||
|
adb -s <serial> push apess-selfregister.sh /home/arduino/
|
||||||
|
adb -s <serial> shell 'chmod +x /home/arduino/apess-selfregister.sh'
|
||||||
|
# with root: enable the boot + refresh timer
|
||||||
|
sudo cp systemd/apess-selfregister.service systemd/apess-selfregister.timer /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now apess-selfregister.timer
|
||||||
|
# no root: run it once now (and let zeroclaw-supervisor / cron re-run it)
|
||||||
|
/home/arduino/apess-selfregister.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`provision-uno-q.sh` (admin-driven, binds a board straight to a known team) is
|
||||||
|
still there for pre-provisioning / demo boards — the two paths coexist.
|
||||||
|
|
||||||
## Provision (production — systemd, boots on power-up)
|
## Provision (production — systemd, boots on power-up)
|
||||||
|
|
||||||
With root on the board:
|
With root on the board:
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Per-board onboarding identity. Copy to /home/arduino/.zeroclaw/apess-node.env
|
||||||
|
# on each board (gen-kit-codes.sh writes these for a whole fleet). NEVER commit
|
||||||
|
# a filled-in copy — CLAIM_CODE + FLEET_SECRET are secrets.
|
||||||
|
|
||||||
|
KIT_ID=KIT-07 # this board's kit (matches its sticker/QR)
|
||||||
|
CLAIM_CODE=418302 # 6-digit code printed / QR-encoded on the kit
|
||||||
|
FLEET_SECRET=change-me-shared-fleet-secret # shared across the fleet; == APESS FLEET_SECRET
|
||||||
|
APESS_URL=https://apess-api.redclaw.dev # the APESS API base
|
||||||
|
# GATEWAY_PORT=8080 # override if the daemon isn't on :8080
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs ON the Uno Q. Announces this board into APESS's unclaimed pool so an
|
||||||
|
# attendee can claim it (kit + code) with no operator in the loop — the
|
||||||
|
# board-side half of the "preloaded + self-register + claim" onboarding.
|
||||||
|
#
|
||||||
|
# It pairs locally for a bearer token, discovers the board's LAN IP, and POSTs
|
||||||
|
# {kitId, url, token, claimCode} to APESS /nodes/self-register (authorized by
|
||||||
|
# the shared fleet secret, NOT the admin code). Idempotent + retrying: safe to
|
||||||
|
# run on every boot and on a timer (re-announces if the DHCP lease changes).
|
||||||
|
#
|
||||||
|
# Config: /home/arduino/.zeroclaw/apess-node.env (see apess-node.env.example) —
|
||||||
|
# KIT_ID, CLAIM_CODE, FLEET_SECRET, APESS_URL [, GATEWAY_PORT]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${APESS_NODE_ENV:-/home/arduino/.zeroclaw/apess-node.env}"
|
||||||
|
ZEROCLAW="${ZEROCLAW_BIN:-/home/arduino/zeroclaw}"
|
||||||
|
[ -r "$ENV_FILE" ] || { echo "missing $ENV_FILE" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
|
||||||
|
: "${KIT_ID:?KIT_ID required}"
|
||||||
|
: "${CLAIM_CODE:?CLAIM_CODE required}"
|
||||||
|
: "${FLEET_SECRET:?FLEET_SECRET required}"
|
||||||
|
: "${APESS_URL:?APESS_URL required}"
|
||||||
|
PORT="${GATEWAY_PORT:-8080}"
|
||||||
|
GW="http://127.0.0.1:${PORT}"
|
||||||
|
|
||||||
|
log() { echo "[selfregister] $*"; }
|
||||||
|
|
||||||
|
# Wait for the local daemon to be up (systemd orders us After=, but be safe).
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf --max-time 3 "$GW/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Pair locally for a bearer token (the token stays server-side once APESS has it).
|
||||||
|
CODE=$("$ZEROCLAW" gateway get-paircode --new --port "$PORT" | grep -oE '[0-9]{6}' | head -1)
|
||||||
|
TOKEN=$(curl -s -X POST "$GW/pair" -H "X-Pairing-Code: ${CODE}" \
|
||||||
|
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("token",""))')
|
||||||
|
[ -n "$TOKEN" ] || { echo "local pairing failed" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The LAN IP participants (and APESS) reach; fall back to localhost for adb-only.
|
||||||
|
IP=$(ip -4 -o addr show 2>/dev/null | grep -oE 'inet [0-9.]+' | grep -v '127.0.0.1' \
|
||||||
|
| awk '{print $2}' | head -1)
|
||||||
|
URL="http://${IP:-127.0.0.1}:${PORT}"
|
||||||
|
|
||||||
|
payload=$(python3 - "$KIT_ID" "$URL" "$TOKEN" "$CLAIM_CODE" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
kit, url, token, code = sys.argv[1:5]
|
||||||
|
print(json.dumps({"kitId": kit, "url": url, "token": token, "claimCode": code}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
# Announce, retrying until APESS is reachable (it may boot after the boards).
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if curl -sf -X POST "${APESS_URL%/}/nodes/self-register" \
|
||||||
|
-H "x-fleet-secret: ${FLEET_SECRET}" -H 'content-type: application/json' \
|
||||||
|
-d "$payload" >/dev/null; then
|
||||||
|
log "announced ${KIT_ID} at ${URL}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
log "APESS unreachable (attempt ${attempt}/30) — retrying"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "could not reach APESS at ${APESS_URL}" >&2
|
||||||
|
exit 1
|
||||||
Executable
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Host-side fleet helper. Mints a random 6-digit claim code per kit, writes the
|
||||||
|
# per-board env file each Uno Q needs (apess-node.env), and emits a printable
|
||||||
|
# CSV (kit, code, claim_url) for the QR stickers.
|
||||||
|
#
|
||||||
|
# FLEET_SECRET=... APESS_URL=https://apess-api.redclaw.dev \
|
||||||
|
# APESS_WEB=https://apess.redclaw.dev ./gen-kit-codes.sh 15 [out-dir]
|
||||||
|
#
|
||||||
|
# Then, per board: copy out/env/KIT-NN.env → the board's
|
||||||
|
# /home/arduino/.zeroclaw/apess-node.env, and encode the claim_url in its QR
|
||||||
|
# sticker (scanning it lands on /workshop?kit=..&code=.. with both pre-filled).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
N="${1:-15}"
|
||||||
|
OUT="${2:-./kit-codes}"
|
||||||
|
FLEET_SECRET="${FLEET_SECRET:?set FLEET_SECRET (the shared fleet secret, == APESS FLEET_SECRET)}"
|
||||||
|
APESS_URL="${APESS_URL:-https://apess-api.redclaw.dev}"
|
||||||
|
APESS_WEB="${APESS_WEB:-https://apess.redclaw.dev}"
|
||||||
|
|
||||||
|
mkdir -p "$OUT/env"
|
||||||
|
echo "kit,code,claim_url" > "$OUT/kit-codes.csv"
|
||||||
|
|
||||||
|
for i in $(seq 1 "$N"); do
|
||||||
|
kit=$(printf "KIT-%02d" "$i")
|
||||||
|
# 3 random bytes → hex → integer mod 1e6 → zero-padded 6-digit string.
|
||||||
|
hex=$(head -c3 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||||
|
code=$(printf "%06d" "$(( 0x${hex} % 1000000 ))")
|
||||||
|
umask 077
|
||||||
|
cat > "$OUT/env/${kit}.env" <<EOF
|
||||||
|
KIT_ID=${kit}
|
||||||
|
CLAIM_CODE=${code}
|
||||||
|
FLEET_SECRET=${FLEET_SECRET}
|
||||||
|
APESS_URL=${APESS_URL}
|
||||||
|
EOF
|
||||||
|
echo "${kit},${code},${APESS_WEB%/}/workshop?kit=${kit}&code=${code}" >> "$OUT/kit-codes.csv"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "wrote $N per-board env files to $OUT/env/ and the sticker CSV to $OUT/kit-codes.csv" >&2
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=APESS board self-registration (announce into the unclaimed pool)
|
||||||
|
After=network-online.target zeroclaw-daemon.service
|
||||||
|
Wants=network-online.target zeroclaw-daemon.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=arduino
|
||||||
|
# The script retries internally until APESS is reachable; the paired timer
|
||||||
|
# re-announces periodically so a DHCP lease change doesn't strand the board.
|
||||||
|
ExecStart=/home/arduino/apess-selfregister.sh
|
||||||
|
RemainAfterExit=no
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Re-announce this board into APESS (keeps the pool entry fresh)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Once shortly after boot, then every 5 minutes. Re-registering is idempotent
|
||||||
|
# (announce resets the entry's url/token/claimCode) and cheap.
|
||||||
|
OnBootSec=30s
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -10,6 +10,8 @@ export interface BoardClaimProps {
|
|||||||
connected: boolean
|
connected: boolean
|
||||||
port: string | null
|
port: string | null
|
||||||
onClaimed: (result: ClaimResult) => void
|
onClaimed: (result: ClaimResult) => void
|
||||||
|
/** Pre-fill the code (from the kit QR's ?code= param). */
|
||||||
|
initialCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The three physical bring-up steps an attendee performs before claiming. */
|
/** The three physical bring-up steps an attendee performs before claiming. */
|
||||||
@@ -24,8 +26,8 @@ const STEPS = [
|
|||||||
* and claims it to their team by proving the kit's claim code. On success the
|
* and claims it to their team by proving the kit's claim code. On success the
|
||||||
* board is bound server-side (its bearer token never touches the browser).
|
* board is bound server-side (its bearer token never touches the browser).
|
||||||
*/
|
*/
|
||||||
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed }: BoardClaimProps) {
|
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) {
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState(initialCode ?? '')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -112,4 +112,13 @@ describe('TeamRegistration', () => {
|
|||||||
expect(useSession.getState().team.kit).toBe('KIT-12')
|
expect(useSession.getState().team.kit).toBe('KIT-12')
|
||||||
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
|
expect(screen.getByRole('button', { name: 'KIT-12' })).toHaveAttribute('aria-pressed', 'true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('pre-fills the claim code from the ?code= URL param (full QR flow)', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/workshop?kit=KIT-07&code=418302']}>
|
||||||
|
<TeamRegistration />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
expect(screen.getByLabelText(/claim code/i)).toHaveValue('418302')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function TeamRegistration() {
|
|||||||
teamName={team.name}
|
teamName={team.name}
|
||||||
connected={device.connected}
|
connected={device.connected}
|
||||||
port={device.port}
|
port={device.port}
|
||||||
|
initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||||
onClaimed={(r) => setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 })}
|
onClaimed={(r) => setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0 })}
|
||||||
/>
|
/>
|
||||||
{!device.connected && (
|
{!device.connected && (
|
||||||
|
|||||||
Reference in New Issue
Block a user