Compare commits
8
Commits
62e4a3b688
...
fc5f66021f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5f66021f | ||
|
|
1b31f6db1b | ||
|
|
1642fbce39 | ||
|
|
eb8c41b14b | ||
|
|
413230255e | ||
|
|
3fef8c4d23 | ||
|
|
b09068b60c | ||
|
|
1c762e51ba |
+11
-6
@@ -173,10 +173,15 @@ export function createApp(opts: AppOptions): Express {
|
||||
app.post('/claim', async (req, res) => {
|
||||
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
|
||||
const b = req.body ?? {}
|
||||
if (typeof b.kit !== 'string' || typeof b.teamId !== 'string' || typeof b.code !== 'string') {
|
||||
return res.status(400).json({ error: 'kit, teamId and code are required' })
|
||||
if (typeof b.teamId !== 'string' || typeof b.code !== 'string') {
|
||||
return res.status(400).json({ error: 'teamId and code are required' })
|
||||
}
|
||||
const result = boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
|
||||
// Code-first (board scrolls its code on the matrix, no kit picked) is the
|
||||
// default; a supplied `kit` keeps the legacy sticker-claim path working.
|
||||
const result =
|
||||
typeof b.kit === 'string' && b.kit
|
||||
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
|
||||
: boards.claimByCode(b.code, b.teamId, Date.parse(now()))
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'unknown') {
|
||||
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' })
|
||||
@@ -184,7 +189,7 @@ export function createApp(opts: AppOptions): Express {
|
||||
if (result.reason === 'rate_limited') {
|
||||
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
|
||||
}
|
||||
return res.status(401).json({ error: 'wrong claim code' })
|
||||
return res.status(401).json({ error: "wrong code — check what your board is showing on its matrix" })
|
||||
}
|
||||
const teamId = result.board.claimedBy as string // canonical (== b.teamId on first claim)
|
||||
await nodes.register({ teamId, url: result.board.url, token: result.board.token })
|
||||
@@ -196,7 +201,7 @@ export function createApp(opts: AppOptions): Express {
|
||||
const team: TeamSnapshot = {
|
||||
id: teamId,
|
||||
name: pickName ?? '',
|
||||
kit: b.kit,
|
||||
kit: result.board.kitId,
|
||||
members: pickMembers ?? [],
|
||||
domain: typeof prev?.domain === 'string' ? prev.domain : '',
|
||||
phases: { ...emptyPhases, ...(prev?.phases ?? {}) },
|
||||
@@ -208,7 +213,7 @@ export function createApp(opts: AppOptions): Express {
|
||||
broadcast({ type: 'team:update', team })
|
||||
broadcastUnclaimed() // the claimed kit left the pool
|
||||
const online = nodes.list().find((n) => n.teamId === teamId)?.online ?? false
|
||||
res.status(201).json({ teamId, kit: b.kit, url: result.board.url, online, resumed: result.resumed, team })
|
||||
res.status(201).json({ teamId, kit: result.board.kitId, url: result.board.url, online, resumed: result.resumed, team })
|
||||
})
|
||||
|
||||
// Instructor action: release a kit back to the unclaimed pool and unbind its
|
||||
|
||||
@@ -112,6 +112,20 @@ describe('board self-register + claim', () => {
|
||||
expect(pool.body).toEqual({ kits: [] })
|
||||
})
|
||||
|
||||
it('code-first: binds by the matrix code with no kit supplied', async () => {
|
||||
const res = await request(app)
|
||||
.post('/claim')
|
||||
.send({ teamId: 'team-07', teamName: 'team_resonance', members: ['A. Rossi'], code: '418302' })
|
||||
.expect(201)
|
||||
expect(res.body).toMatchObject({ teamId: 'team-07', kit: 'KIT-07', online: true, resumed: false })
|
||||
expect(res.body.team).toMatchObject({ id: 'team-07', name: 'team_resonance', members: ['A. Rossi'], deviceConnected: true })
|
||||
expect(JSON.stringify(res.body)).not.toContain('zc_secret_token')
|
||||
})
|
||||
|
||||
it('code-first: a wrong code is rejected', async () => {
|
||||
await request(app).post('/claim').send({ teamId: 'team-07', code: '000000' }).expect(401)
|
||||
})
|
||||
|
||||
it('exposes public per-team liveness after a claim', async () => {
|
||||
await request(app).get('/nodes/team-07/status').expect(404) // not yet claimed
|
||||
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
||||
|
||||
@@ -51,6 +51,25 @@ describe('board registry', () => {
|
||||
expect(again.ok && again.board.claimedBy).toBe('team-07') // canonical, not team-99
|
||||
})
|
||||
|
||||
it('claimByCode binds the matching board with no kit, and rejects a wrong code', () => {
|
||||
const r = createBoardRegistry()
|
||||
r.announce(board())
|
||||
expect(r.claimByCode('000000', 'team-07', 0)).toEqual({ ok: false, reason: 'bad_code' })
|
||||
const out = r.claimByCode('418302', 'team-07', 0)
|
||||
expect(out).toMatchObject({ ok: true, resumed: false })
|
||||
expect(out.ok && out.board.kitId).toBe('KIT-07')
|
||||
expect(r.isClaimed('KIT-07')).toBe(true)
|
||||
})
|
||||
|
||||
it('claimByCode resumes an already-claimed board to its canonical team', () => {
|
||||
const r = createBoardRegistry()
|
||||
r.announce(board())
|
||||
r.claimByCode('418302', 'team-07', 0)
|
||||
const again = r.claimByCode('418302', 'team-99', 1)
|
||||
expect(again).toMatchObject({ ok: true, resumed: true })
|
||||
expect(again.ok && again.board.claimedBy).toBe('team-07')
|
||||
})
|
||||
|
||||
it('a rebooted claimed board stays claimed, out of the pool, with refreshed url/token (auto-heal)', () => {
|
||||
const r = createBoardRegistry()
|
||||
r.announce(board())
|
||||
|
||||
@@ -40,6 +40,12 @@ export interface BoardRegistry {
|
||||
* lost its browser can get back onto its own board. Rate-limited per kit.
|
||||
*/
|
||||
claim(kitId: string, code: string, teamId: string, nowMs: number): ClaimOutcome
|
||||
/**
|
||||
* Claim (or resume) a board by its code ALONE — the attendee proves possession
|
||||
* by reading the code the board scrolls on its own matrix, with no kit to pick.
|
||||
* Finds the unique board whose `claimCode` matches; otherwise `bad_code`.
|
||||
*/
|
||||
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
|
||||
/** Release a kit back to unclaimed; returns the freed teamId (or null). */
|
||||
release(kitId: string): string | null
|
||||
get(kitId: string): Board | undefined
|
||||
@@ -90,6 +96,18 @@ export function createBoardRegistry(seed: Board[] = []): BoardRegistry {
|
||||
}
|
||||
return { ok: true, board, resumed: true } // already claimed → resume to the canonical team
|
||||
},
|
||||
claimByCode(code, teamId, nowMs) {
|
||||
// Unique per-board codes → the code identifies the board. No match = bad code.
|
||||
const board = [...boards.values()].find((b) => matches(code, b.claimCode))
|
||||
if (!board) return { ok: false, reason: 'bad_code' }
|
||||
if (recentFails(board.kitId, nowMs) >= MAX_FAILS) return { ok: false, reason: 'rate_limited' }
|
||||
fails.delete(board.kitId)
|
||||
if (board.claimedBy === null) {
|
||||
board.claimedBy = teamId
|
||||
return { ok: true, board, resumed: false }
|
||||
}
|
||||
return { ok: true, board, resumed: true }
|
||||
},
|
||||
release(kitId) {
|
||||
const board = boards.get(kitId)
|
||||
if (!board || board.claimedBy === null) return null
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# APESS Demo Runbook — Multi-Channel Agent Controls the LED Matrix
|
||||
|
||||
One on-board agent on the Arduino Uno Q changes the physical **13×8 LED-matrix
|
||||
animation** on command from **web chat, Telegram, and voice** — same agent, same
|
||||
`matrix_pattern` tool, all on cloud sonnet via the reliable `/webhook` path.
|
||||
|
||||
## Secrets
|
||||
|
||||
All three env-only secrets live in **Infisical** on the `icarus` instance (project
|
||||
`cloud-providers`, env `prod`). Pull them into the environment in one block:
|
||||
|
||||
```bash
|
||||
DOM=http://icarus.lan:8443 # or https://icarus.taila4f562.ts.net
|
||||
CP=0788e188-b746-4ea0-a4b2-e0c2d0aec1b6 # cloud-providers project id
|
||||
export INFISICAL_TOKEN=$(infisical login --method=universal-auth \
|
||||
--client-id=$(cat ~/.infisical/macbook-admin-id) \
|
||||
--client-secret=$(cat ~/.infisical/macbook-admin-secret) \
|
||||
--domain=$DOM --silent --plain)
|
||||
get(){ infisical secrets get "$1" --projectId=$CP --env=prod --domain=$DOM --plain; }
|
||||
export ANTHROPIC_OAUTH_TOKEN=$(get ANTHROPIC_OAUTH_TOKEN) # Claude Max setup-token (cloud brain)
|
||||
export NODE_TOKEN=$(get APESS_NODE_TOKEN) # Uno Q gateway bearer token
|
||||
export ELEVENLABS_API_KEY=$(get ELEVENLABS_API_KEY) # ElevenLabs TTS voice
|
||||
```
|
||||
|
||||
**None of these may be written to disk or committed** — env-only. Infisical is the vault;
|
||||
`recover.sh` and `serve.py` read them from the environment. (Vault key `APESS_NODE_TOKEN`
|
||||
maps to the `NODE_TOKEN` env var the scripts expect.)
|
||||
|
||||
## Pre-flight (~5 min before, board plugged into USB)
|
||||
|
||||
```bash
|
||||
cd ~/projects/apress
|
||||
# (secrets exported per above)
|
||||
./deploy/uno-q/recover.sh # brings up node + verifies sonnet + matrix (all-green)
|
||||
|
||||
# voice proxy — leave running in its own terminal:
|
||||
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=$NODE_TOKEN ELEVENLABS_API_KEY=$ELEVENLABS_API_KEY \
|
||||
python3 deploy/voice-client/serve.py 8090
|
||||
```
|
||||
|
||||
`recover.sh` re-tunnels (`adb forward :8080`), relaunches the daemon with the cloud
|
||||
token in its environment, starts the matrix bridge app, and confirms
|
||||
`agent=demo` is on `claude-sonnet-5` and `matrix_pattern` fires.
|
||||
|
||||
**On the workshop LAN:** point `NODE_URL` at the board's LAN IP (`http://192.168.x.x:8080`)
|
||||
instead of the adb-forwarded `127.0.0.1:8080`, so the browser voice client reaches the
|
||||
board over the network.
|
||||
|
||||
## The three acts
|
||||
|
||||
1. **Web chat** — prompt *"show the rain animation"* → matrix changes + live activity feed.
|
||||
2. **Telegram** — **t.me/Apess2026Bot** → *"change it to a beating heart"* → the *same
|
||||
physical matrix* changes, driven from a phone. (One-time pairing: `/bind <code>` — the
|
||||
code prints in the daemon log at startup; grep `bind code`.)
|
||||
3. **Voice** — **http://localhost:8090** in Chrome → hold-to-talk *"make it wave"* → matrix
|
||||
changes and the reply is **spoken back in the ElevenLabs voice** (Sarah).
|
||||
|
||||
Patterns the agent understands: `off, rain, heart, wave, sparkle, checker, solid, blink`.
|
||||
|
||||
## If the board disconnects mid-demo
|
||||
|
||||
The recurring USB drop kills the daemon/llama/bridge and loses the env-only cloud token.
|
||||
Re-plug, then:
|
||||
|
||||
```bash
|
||||
./deploy/uno-q/recover.sh # ~30s, re-injects the token, verifies end-to-end
|
||||
```
|
||||
|
||||
The voice proxy auto-recovers via the re-armed tunnel (no restart needed). If it was
|
||||
stopped, relaunch the `serve.py` line above.
|
||||
|
||||
## Gotchas / facts
|
||||
|
||||
- **Cloud token is env-only** — a disconnect loses it; recovery *must* re-export it (the
|
||||
script uses `$ANTHROPIC_OAUTH_TOKEN` from your shell).
|
||||
- **Use `/webhook`, not `/ws/chat`** — the WS path builds a fresh agent that omits the
|
||||
peripheral `matrix_pattern` tool; the voice client's `serve.py` proxies `/webhook` for
|
||||
this reason.
|
||||
- **First turn after a fresh daemon** is a touch slower (cold); the pre-flight
|
||||
`recover.sh` call warms it.
|
||||
- **ElevenLabs free tier** can only use the premade voices attached to the account (not
|
||||
"library" voices → 402). Default `EXAVITQu4vr4xnSDxMaL` (Sarah) works.
|
||||
- **Board serial** `65301572`. **Bridge app**: `~/ArduinoApps/uno-q-bridge`
|
||||
(`arduino-app-cli`, needs `TMPDIR=/tmp`).
|
||||
|
||||
## Where things live
|
||||
|
||||
- `deploy/uno-q/recover.sh` — one-command node recovery.
|
||||
- `deploy/voice-client/` — browser voice client + `serve.py` proxy (STT/TTS + ElevenLabs).
|
||||
- zeroclaw fork (`fix/uno-q-flash-timeouts`) — resident matrix responder + `matrix_pattern`
|
||||
tool + Telegram channel.
|
||||
</content>
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# recover.sh — one-command recovery for the APESS Uno Q demo node after a USB drop.
|
||||
#
|
||||
# On a disconnect the daemon/llama/bridge die and the cloud token (env-only) is lost.
|
||||
# This re-tunnels, relaunches the supervisor WITH the token in its environment,
|
||||
# restarts the matrix bridge app, and verifies the whole chain end-to-end.
|
||||
#
|
||||
# Secrets are read from the environment — NEVER hardcoded here. Export first:
|
||||
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-... # required: cloud brain
|
||||
# export NODE_TOKEN=zc_... # optional: end-to-end verify
|
||||
# ./recover.sh
|
||||
#
|
||||
# Env knobs: SERIAL (default 65301572), the two tokens above.
|
||||
set -u
|
||||
SERIAL="${SERIAL:-65301572}"
|
||||
A(){ adb -s "$SERIAL" "$@"; }
|
||||
S(){ adb -s "$SERIAL" shell "$@"; }
|
||||
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
|
||||
step(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
step "0· Preconditions"
|
||||
if ! adb devices | grep -q "^${SERIAL}[[:space:]]*device"; then
|
||||
bad "board $SERIAL not attached — re-plug USB, then re-run"; exit 1
|
||||
fi
|
||||
ok "board $SERIAL attached"
|
||||
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set — cloud brain will fail. export it and re-run"; exit 1; }
|
||||
ok "cloud token present in env"
|
||||
|
||||
step "1· Tunnel"
|
||||
A forward tcp:8080 tcp:8080 >/dev/null && ok "adb forward :8080 → laptop localhost:8080"
|
||||
|
||||
step "2· Stop stale supervisor + daemons (preserve llama)"
|
||||
S 'for p in $(ps -C zeroclaw-supervisor -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
|
||||
for p in $(ps -C zeroclaw -o pid= 2>/dev/null); do kill -9 $p 2>/dev/null; done
|
||||
rm -f /home/arduino/.zc-supervisor.lock; sleep 2
|
||||
echo " daemons left: $(ps -C zeroclaw -o pid= 2>/dev/null | wc -l)"'
|
||||
|
||||
step "3· Relaunch supervisor WITH token env (env-only, never on disk)"
|
||||
S "export ANTHROPIC_OAUTH_TOKEN='$ANTHROPIC_OAUTH_TOKEN'; \
|
||||
export ZEROCLAW_providers__models__anthropic__max__api_key='$ANTHROPIC_OAUTH_TOKEN'; \
|
||||
setsid nohup /home/arduino/zeroclaw-supervisor.sh >/dev/null 2>&1 </dev/null & sleep 2; echo done" >/dev/null
|
||||
S 'pgrep -f "[z]eroclaw-supervisor" >/dev/null' && ok "supervisor relaunched" || bad "supervisor did NOT start"
|
||||
|
||||
step "4· Matrix bridge app (start only if down)"
|
||||
if [ "$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')" = "pong" ]; then
|
||||
ok "bridge already running"
|
||||
else
|
||||
S 'cd ~/ArduinoApps/uno-q-bridge && TMPDIR=/tmp arduino-app-cli app start ~/ArduinoApps/uno-q-bridge 2>&1 | tail -1'
|
||||
fi
|
||||
|
||||
step "5· Wait for services"
|
||||
for i in $(seq 1 30); do
|
||||
L=$(S 'curl -sf -m3 http://127.0.0.1:8083/health >/dev/null 2>&1 && echo 1 || echo 0')
|
||||
D=$(S 'curl -sf -m3 http://127.0.0.1:8080/health >/dev/null 2>&1 && echo 1 || echo 0')
|
||||
printf '\r [%02d] llama=%s daemon=%s ' "$i" "$L" "$D"
|
||||
[ "$D" = 1 ] && break; sleep 6
|
||||
done; echo
|
||||
[ "$L" = 1 ] && ok "llama :8083 healthy" || bad "llama :8083 DOWN (cold load can take 3–5 min; re-check)"
|
||||
[ "$D" = 1 ] && ok "daemon :8080 healthy" || { bad "daemon :8080 DOWN"; exit 1; }
|
||||
|
||||
step "6· Bridge (matrix responder)"
|
||||
P=$(S 'printf "ping\n" | timeout 4 nc 127.0.0.1 9999 2>/dev/null')
|
||||
[ "$P" = "pong" ] && ok "bridge :9999 responds (ping→pong)" || bad "bridge :9999 not responding — re-run step 4"
|
||||
|
||||
step "7· End-to-end: demo agent = cloud sonnet + matrix fires"
|
||||
if [ -n "${NODE_TOKEN:-}" ]; then
|
||||
S 'printf "matrix 0\n" | timeout 5 nc 127.0.0.1 9999 >/dev/null 2>&1'
|
||||
R=$(curl -s -m 30 -X POST "http://127.0.0.1:8080/webhook?agent=demo" \
|
||||
-H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"message":"Show the rain animation on the LED matrix"}')
|
||||
echo "$R" | grep -q "claude-sonnet-5" && ok "agent=demo on claude-sonnet-5" || bad "agent NOT on sonnet — token may not have loaded: $R"
|
||||
M=$(S "docker logs --since 40s uno-q-bridge-main-1 2>&1 | grep -c \"parts=\['matrix', '1'\]\"")
|
||||
[ "${M:-0}" -ge 1 ] && ok "matrix_pattern fired (rain)" || bad "matrix did not change"
|
||||
else
|
||||
echo " (NODE_TOKEN unset — skipping authenticated end-to-end check)"
|
||||
fi
|
||||
|
||||
step "Recovery complete."
|
||||
echo " Voice proxy (laptop): if it was running it auto-recovers via the re-armed tunnel."
|
||||
echo " If not running: NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=\$NODE_TOKEN python3 deploy/voice-client/serve.py 8090"
|
||||
@@ -0,0 +1,53 @@
|
||||
# APESS Voice → Node client
|
||||
|
||||
Talk to the on-board agent and change the LED-matrix animation **by voice** — same
|
||||
agent, same `matrix_pattern` tool as web chat and Telegram.
|
||||
|
||||
- **STT + TTS run in the browser** (Web Speech API) — no ElevenLabs, no keys.
|
||||
- **`serve.py` serves the page AND proxies `/webhook`** to the node on the same origin,
|
||||
so the browser needs no CORS and no bearer token, and we reuse the reliable
|
||||
`/webhook` path (the `/ws/chat` path drops the peripheral matrix tool, so we avoid it).
|
||||
|
||||
## Run it
|
||||
|
||||
The node is reached over USB via `adb forward`, or by its LAN IP on the day.
|
||||
|
||||
```bash
|
||||
# 1. expose the board's gateway locally (USB path)
|
||||
adb forward tcp:8080 tcp:8080
|
||||
|
||||
# 2. serve the client + proxy (tokens stay server-side, never in the browser)
|
||||
cd deploy/voice-client
|
||||
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=<gateway-bearer-token> \
|
||||
ELEVENLABS_API_KEY=<sk_...> python3 serve.py 8090
|
||||
# on the day, point NODE_URL at the board's LAN IP instead:
|
||||
# NODE_URL=http://192.168.x.x:8080 NODE_TOKEN=... ELEVENLABS_API_KEY=... python3 serve.py 8090
|
||||
|
||||
# 3. open http://localhost:8090 in Chrome
|
||||
```
|
||||
|
||||
Then: the dot goes green (proxy reachable), **hold** the circle, say
|
||||
*"show the wave animation"*, release. The node runs the agent → `matrix_pattern` →
|
||||
the matrix changes, and the reply is spoken back.
|
||||
|
||||
`localhost` is a secure context so Chrome grants mic access; the proxy hop is
|
||||
server-side so there is no CORS. One process, one origin, no keys. Chrome required
|
||||
(Web Speech API).
|
||||
|
||||
## TTS: ElevenLabs vs browser
|
||||
|
||||
`serve.py` synthesizes replies with **ElevenLabs** when `ELEVENLABS_API_KEY` is set
|
||||
(server-side `/tts` endpoint → the client plays the returned MP3); otherwise the client
|
||||
falls back to the browser's built-in `speechSynthesis` voice. The client learns which
|
||||
mode is active from `GET /config`, so no client change is needed either way.
|
||||
|
||||
- Default voice: **Sarah** (`EXAVITQu4vr4xnSDxMaL`). Override with `ELEVENLABS_VOICE_ID`.
|
||||
- **Free-tier gotcha:** free ElevenLabs accounts can only use the ~21 *premade voices
|
||||
attached to the account* — not "library" voices (e.g. Rachel `21m00…`), which return
|
||||
HTTP 402 `paid_plan_required`. List usable voices:
|
||||
`curl -s https://api.elevenlabs.io/v1/voices -H "xi-api-key: $ELEVENLABS_API_KEY"`.
|
||||
- Model: `eleven_turbo_v2_5` (low latency) — override with `ELEVENLABS_MODEL`.
|
||||
|
||||
(The gateway also has a native voice-duplex path, but it shares the `/ws/chat`
|
||||
peripheral-tool gap noted above and needs a fork fix first.)
|
||||
</content>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>APESS · Voice → Node</title>
|
||||
<style>
|
||||
:root { --bg:#0d0d0f; --fg:#eae6df; --dim:#8a857c; --accent:#e8543f; --line:#26242a; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--fg);
|
||||
font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
min-height:100vh; display:flex; flex-direction:column; align-items:center; }
|
||||
header { width:100%; border-bottom:1px solid var(--line); padding:14px 18px;
|
||||
text-transform:uppercase; letter-spacing:.18em; font-size:12px; color:var(--dim);
|
||||
display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.cfg { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.cfg input { background:#141317; color:var(--fg); border:1px solid var(--line);
|
||||
padding:6px 8px; font:inherit; font-size:12px; border-radius:4px; }
|
||||
.cfg input#ip { width:150px; }
|
||||
.cfg input#agent { width:80px; }
|
||||
button { font:inherit; cursor:pointer; }
|
||||
.conn { padding:6px 10px; border:1px solid var(--line); border-radius:4px;
|
||||
background:transparent; color:var(--fg); font-size:12px; text-transform:uppercase; letter-spacing:.1em; }
|
||||
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:#5a5750; margin-right:6px; vertical-align:middle; }
|
||||
.dot.on { background:#4caf72; } .dot.err { background:var(--accent); }
|
||||
main { flex:1; width:100%; max-width:720px; padding:24px 18px; display:flex; flex-direction:column; gap:18px; }
|
||||
.mic { align-self:center; width:150px; height:150px; border-radius:50%; border:2px solid var(--line);
|
||||
background:#141317; color:var(--fg); font-size:13px; text-transform:uppercase; letter-spacing:.12em;
|
||||
display:flex; align-items:center; justify-content:center; transition:all .15s; user-select:none; }
|
||||
.mic:hover { border-color:var(--dim); }
|
||||
.mic.live { border-color:var(--accent); background:#2a1512; box-shadow:0 0 0 6px rgba(232,84,63,.12); }
|
||||
.mic:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.hint { text-align:center; color:var(--dim); font-size:12px; margin-top:-8px; }
|
||||
.log { display:flex; flex-direction:column; gap:12px; }
|
||||
.row { border:1px solid var(--line); border-radius:6px; padding:12px 14px; }
|
||||
.row .who { font-size:10px; text-transform:uppercase; letter-spacing:.16em; color:var(--dim); margin-bottom:6px; }
|
||||
.row.you { border-color:#2f3a44; } .row.node { border-color:#3a2f2c; }
|
||||
.row.tool { border-style:dashed; color:var(--dim); font-size:12px; }
|
||||
.txt { font-size:15px; line-height:1.5; white-space:pre-wrap; }
|
||||
.interim { color:var(--dim); font-style:italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span>APESS · VOICE → NODE</span>
|
||||
<div class="cfg">
|
||||
<input id="ip" placeholder="board-ip:8080" />
|
||||
<input id="agent" value="demo" />
|
||||
<button class="conn" id="connect"><span class="dot" id="dot"></span><span id="connlabel">Connect</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<button class="mic" id="mic" disabled>Hold to talk</button>
|
||||
<div class="hint" id="hint">Connect to your node, then hold the circle and speak.</div>
|
||||
<div class="log" id="log"></div>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const ipEl=$('ip'), agentEl=$('agent'), micEl=$('mic'), logEl=$('log'),
|
||||
dot=$('dot'), connLabel=$('connlabel'), hint=$('hint'), connectBtn=$('connect');
|
||||
|
||||
// The node address is configured on serve.py; the browser only picks the agent.
|
||||
ipEl.style.display = 'none';
|
||||
agentEl.value = localStorage.getItem('apess_agent') || 'demo';
|
||||
|
||||
let connected=false;
|
||||
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SR) { hint.textContent = 'This browser has no Web Speech API — use Chrome.'; }
|
||||
|
||||
function setConn(state){ // 'on' | 'err' | ''
|
||||
dot.className = 'dot' + (state ? ' '+state : '');
|
||||
connected = state==='on';
|
||||
connLabel.textContent = connected ? 'Ready' : (state==='err'?'Retry':'Connect');
|
||||
micEl.disabled = !connected || !SR;
|
||||
if (connected) hint.textContent = 'Hold the circle, speak, release. The node acts and talks back.';
|
||||
}
|
||||
|
||||
function addRow(cls, who, text){
|
||||
const r=document.createElement('div'); r.className='row '+cls;
|
||||
r.innerHTML=`<div class="who">${who}</div><div class="txt"></div>`;
|
||||
r.querySelector('.txt').textContent=text; logEl.appendChild(r);
|
||||
r.scrollIntoView({behavior:'smooth',block:'end'}); return r;
|
||||
}
|
||||
|
||||
// Transport: POST to a SAME-ORIGIN /webhook that serve.py proxies to the node's
|
||||
// gateway (reliable path — the WS path drops peripheral tools). No CORS, no auth
|
||||
// in the browser (serve.py holds the bearer token).
|
||||
function connect(){
|
||||
const agent = agentEl.value.trim() || 'demo';
|
||||
localStorage.setItem('apess_agent', agent);
|
||||
setConn('');
|
||||
fetch('/ping').then(r => setConn(r.ok?'on':'err')).catch(()=>setConn('err'));
|
||||
}
|
||||
|
||||
let ttsMode = 'browser', curAudio = null;
|
||||
fetch('/config').then(r=>r.json()).then(c=>{ ttsMode = c.tts||'browser'; }).catch(()=>{});
|
||||
|
||||
async function speak(text){
|
||||
if (!text) return;
|
||||
// stop anything currently playing (barge-in)
|
||||
try { window.speechSynthesis && window.speechSynthesis.cancel(); } catch(e){}
|
||||
if (curAudio) { try{ curAudio.pause(); }catch(e){} curAudio=null; }
|
||||
if (ttsMode === 'elevenlabs') {
|
||||
try {
|
||||
const r = await fetch('/tts', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({text})});
|
||||
if (r.ok) {
|
||||
const url = URL.createObjectURL(await r.blob());
|
||||
curAudio = new Audio(url); curAudio.onended=()=>URL.revokeObjectURL(url);
|
||||
await curAudio.play(); return;
|
||||
}
|
||||
} catch(e){ /* fall through to browser TTS */ }
|
||||
}
|
||||
if (window.speechSynthesis) {
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 1.02; u.pitch = 1.0; window.speechSynthesis.speak(u);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text){
|
||||
if (!text) return;
|
||||
addRow('you','You',text);
|
||||
const agent = agentEl.value.trim() || 'demo';
|
||||
const pending = addRow('node','Node','…');
|
||||
try {
|
||||
const r = await fetch('/webhook?agent='+encodeURIComponent(agent), {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({message:text})
|
||||
});
|
||||
const j = await r.json();
|
||||
const reply = (j.response || j.error || '(no response)').trim();
|
||||
pending.querySelector('.txt').textContent = reply;
|
||||
speak(reply);
|
||||
} catch(e) {
|
||||
pending.querySelector('.txt').textContent = 'error: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- push-to-talk: hold the circle, speak, release ---
|
||||
let rec=null, listening=false, finalText='';
|
||||
function startListen(){
|
||||
if (!SR || !connected || listening) return;
|
||||
finalText=''; listening=true; micEl.classList.add('live'); micEl.textContent='Listening…';
|
||||
rec = new SR(); rec.lang='en-US'; rec.interimResults=true; rec.continuous=false;
|
||||
rec.onresult = e => {
|
||||
let interim='';
|
||||
for (let i=e.resultIndex;i<e.results.length;i++){
|
||||
const t=e.results[i][0].transcript;
|
||||
if (e.results[i].isFinal) finalText+=t; else interim+=t;
|
||||
}
|
||||
hint.innerHTML = '<span class="interim">'+(finalText+interim||'…')+'</span>';
|
||||
};
|
||||
rec.onerror = () => {};
|
||||
rec.onend = () => { listening=false; micEl.classList.remove('live'); micEl.textContent='Hold to talk';
|
||||
const t=finalText.trim(); hint.textContent='Hold the circle and speak.'; if (t) send(t); };
|
||||
try { rec.start(); } catch(e){ listening=false; }
|
||||
}
|
||||
function stopListen(){ if (rec && listening) { try{ rec.stop(); }catch(e){} } }
|
||||
|
||||
micEl.addEventListener('mousedown', startListen);
|
||||
micEl.addEventListener('mouseup', stopListen);
|
||||
micEl.addEventListener('mouseleave', stopListen);
|
||||
micEl.addEventListener('touchstart', e=>{e.preventDefault();startListen();},{passive:false});
|
||||
micEl.addEventListener('touchend', e=>{e.preventDefault();stopListen();},{passive:false});
|
||||
connectBtn.addEventListener('click', connect);
|
||||
setConn('');
|
||||
connect(); // auto-check the proxy on load
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""APESS voice client host + gateway proxy.
|
||||
|
||||
Serves index.html AND proxies POST /webhook to the node's ZeroClaw gateway on the
|
||||
same origin — so the browser needs no CORS and no bearer token, and we reuse the
|
||||
reliable /webhook path (the /ws/chat path drops the peripheral matrix tool).
|
||||
|
||||
Usage:
|
||||
NODE_URL=http://127.0.0.1:8080 NODE_TOKEN=zc_xxx python3 serve.py [port]
|
||||
|
||||
- NODE_URL node gateway base (default http://127.0.0.1:8080; via `adb forward
|
||||
tcp:8080 tcp:8080` over USB, or the board's LAN IP:8080 on the day).
|
||||
- NODE_TOKEN gateway bearer token (kept server-side, never sent to the browser).
|
||||
- port local port to serve on (default 8090). Open http://localhost:<port>.
|
||||
|
||||
localhost is a secure context, so the browser grants mic access; the proxy hop is
|
||||
server-side, so there is no CORS. One process, one origin.
|
||||
"""
|
||||
import os, sys, json, urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
NODE_URL = os.environ.get("NODE_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
NODE_TOKEN = os.environ.get("NODE_TOKEN", "")
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
|
||||
# Optional ElevenLabs TTS (server-side; key never reaches the browser). If unset,
|
||||
# the client falls back to the browser's built-in speechSynthesis voice.
|
||||
ELEVEN_KEY = os.environ.get("ELEVENLABS_API_KEY", "")
|
||||
ELEVEN_VOICE = os.environ.get("ELEVENLABS_VOICE_ID", "EXAVITQu4vr4xnSDxMaL") # Sarah (free-tier usable)
|
||||
ELEVEN_MODEL = os.environ.get("ELEVENLABS_MODEL", "eleven_turbo_v2_5")
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a): pass # quiet
|
||||
|
||||
def _send(self, code, body, ctype="application/json"):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/ping":
|
||||
return self._send(200, b'{"ok":true}')
|
||||
if self.path == "/config":
|
||||
mode = "elevenlabs" if ELEVEN_KEY else "browser"
|
||||
return self._send(200, json.dumps({"tts": mode}).encode())
|
||||
path = "/index.html" if self.path in ("/", "") else self.path.split("?")[0]
|
||||
fp = os.path.normpath(os.path.join(HERE, path.lstrip("/")))
|
||||
if not fp.startswith(HERE) or not os.path.isfile(fp):
|
||||
return self._send(404, b"not found", "text/plain")
|
||||
ctype = "text/html" if fp.endswith(".html") else "text/plain"
|
||||
with open(fp, "rb") as f:
|
||||
self._send(200, f.read(), ctype)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/tts":
|
||||
return self._tts()
|
||||
if not self.path.startswith("/webhook"):
|
||||
return self._send(404, b'{"error":"not found"}')
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(n)
|
||||
q = self.path[len("/webhook"):] # keep ?agent=...
|
||||
req = urllib.request.Request(
|
||||
f"{NODE_URL}/webhook{q}", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {NODE_TOKEN}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
self._send(resp.status, resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
self._send(e.code, e.read() or b'{"error":"upstream"}')
|
||||
except Exception as e:
|
||||
self._send(502, json.dumps({"error": str(e)}).encode())
|
||||
|
||||
def _tts(self):
|
||||
if not ELEVEN_KEY:
|
||||
return self._send(503, b'{"error":"tts disabled"}')
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
try:
|
||||
text = json.loads(self.rfile.read(n)).get("text", "").strip()
|
||||
except Exception:
|
||||
text = ""
|
||||
if not text:
|
||||
return self._send(400, b'{"error":"no text"}')
|
||||
payload = json.dumps({
|
||||
"text": text, "model_id": ELEVEN_MODEL,
|
||||
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75},
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://api.elevenlabs.io/v1/text-to-speech/{ELEVEN_VOICE}",
|
||||
data=payload, method="POST",
|
||||
headers={"xi-api-key": ELEVEN_KEY, "Content-Type": "application/json",
|
||||
"Accept": "audio/mpeg"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
self._send(200, resp.read(), "audio/mpeg")
|
||||
except urllib.error.HTTPError as e:
|
||||
self._send(e.code, e.read() or b'{"error":"tts upstream"}')
|
||||
except Exception as e:
|
||||
self._send(502, json.dumps({"error": str(e)}).encode())
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not NODE_TOKEN:
|
||||
print("WARN: NODE_TOKEN is empty — gateway calls will 401.", file=sys.stderr)
|
||||
print(f"APESS voice client → {NODE_URL}")
|
||||
print(f"TTS: {'ElevenLabs ('+ELEVEN_VOICE+')' if ELEVEN_KEY else 'browser (speechSynthesis)'}")
|
||||
print(f"open http://localhost:{PORT}")
|
||||
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||
@@ -5,28 +5,31 @@ import { claimBoard, ClaimError, type ClaimResult } from '@/lib/api'
|
||||
|
||||
export interface BoardClaimProps {
|
||||
teamId: string
|
||||
kit: string
|
||||
teamName: string
|
||||
members: string[]
|
||||
connected: boolean
|
||||
port: string | null
|
||||
onClaimed: (result: ClaimResult) => void
|
||||
/** Pre-fill the code (from the kit QR's ?code= param). */
|
||||
/** Pre-fill the code (e.g. from a ?code= param). */
|
||||
initialCode?: string
|
||||
}
|
||||
|
||||
/** The three physical bring-up steps an attendee performs before claiming. */
|
||||
/** The self-service bring-up steps — the board is already set up from the week. */
|
||||
const STEPS = [
|
||||
'Plug your Uno Q into power over USB-C — the 13×8 matrix lights up.',
|
||||
'Wait ~30 s for it to boot and join the workshop network.',
|
||||
'Enter the 6-digit claim code printed on your kit sticker.',
|
||||
'On your board, open a terminal and run the workshop setup script (below).',
|
||||
'It checks your node is ready, registers it, and scrolls a code across the LED matrix.',
|
||||
'Type the code your board is showing to bind it to your team.',
|
||||
]
|
||||
|
||||
const SETUP_CMD = 'curl -fsSL https://apess.redclaw.dev/setup.sh | bash'
|
||||
|
||||
/**
|
||||
* The board bring-up wizard: walks the attendee through powering on their Uno Q
|
||||
* 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).
|
||||
* The board bring-up wizard for pre-deployed devices: the attendee runs the
|
||||
* setup script on their own Uno Q, which self-registers the node and shows a
|
||||
* code on its matrix; entering that code binds the board to the team. The
|
||||
* bearer token never touches the browser.
|
||||
*/
|
||||
export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed, initialCode }: BoardClaimProps) {
|
||||
export function BoardClaim({ teamId, teamName, members, connected, port, onClaimed, initialCode }: BoardClaimProps) {
|
||||
const [code, setCode] = useState(initialCode ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -49,7 +52,7 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await claimBoard({ teamId, kit, teamName, code: trimmed })
|
||||
const result = await claimBoard({ teamId, teamName, members, code: trimmed })
|
||||
onClaimed(result)
|
||||
} catch (e) {
|
||||
setError(e instanceof ClaimError ? e.message : 'Could not reach the workshop — check your connection.')
|
||||
@@ -68,18 +71,29 @@ export function BoardClaim({ teamId, kit, teamName, connected, port, onClaimed,
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<code className="font-mono text-[11px] text-foreground/90 select-all flex-1 truncate">{SETUP_CMD}</code>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy setup command"
|
||||
onClick={() => navigator.clipboard?.writeText(SETUP_CMD)}
|
||||
className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground shrink-0"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label="Claim code"
|
||||
inputMode="numeric"
|
||||
placeholder="418302"
|
||||
placeholder="code on your matrix"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && claim()}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button onClick={claim} disabled={busy || !code.trim()}>
|
||||
{busy ? 'Claiming…' : 'Claim board'}
|
||||
{busy ? 'Binding…' : 'Bind board'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { MemberFields } from './MemberFields'
|
||||
|
||||
function Wrapper({ initial = [] as string[] }) {
|
||||
const [members, setMembers] = useState(initial)
|
||||
return (
|
||||
<>
|
||||
<MemberFields members={members} onChange={setMembers} />
|
||||
<output data-testid="committed">{members.join(',')}</output>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('MemberFields', () => {
|
||||
it('renders one empty row by default', () => {
|
||||
render(<Wrapper />)
|
||||
expect(screen.getByLabelText('Member 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('commits trimmed, non-empty names to the parent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
await user.type(screen.getByLabelText('Member 1'), ' A. Rossi ')
|
||||
expect(screen.getByTestId('committed')).toHaveTextContent('A. Rossi')
|
||||
})
|
||||
|
||||
it('adds a field below when "+" is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('button', { name: /add member/i }))
|
||||
expect(screen.getByLabelText('Member 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('seeds a row per existing member and can remove one', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper initial={['A. Rossi', 'K. Tanaka']} />)
|
||||
expect(screen.getByLabelText('Member 1')).toHaveValue('A. Rossi')
|
||||
await user.click(screen.getByRole('button', { name: /remove member 1/i }))
|
||||
expect(screen.getByTestId('committed')).toHaveTextContent('K. Tanaka')
|
||||
expect(screen.getByTestId('committed')).not.toHaveTextContent('A. Rossi')
|
||||
})
|
||||
|
||||
it('caps at 5 members', () => {
|
||||
render(<Wrapper initial={['A', 'B', 'C', 'D', 'E']} />)
|
||||
expect(screen.getByRole('button', { name: /add member/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const MAX_MEMBERS = 5
|
||||
|
||||
export interface MemberFieldsProps {
|
||||
members: string[]
|
||||
onChange: (next: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline member entry: one text field per member with a "+" to append another
|
||||
* row below, and a "×" to remove a row. The parent store only ever sees the
|
||||
* trimmed, non-empty names; empty rows are a local editing affordance.
|
||||
*/
|
||||
export function MemberFields({ members, onChange }: MemberFieldsProps) {
|
||||
// Seed local rows from the parent (always at least one row to type into).
|
||||
const [rows, setRows] = useState<string[]>(members.length ? members : [''])
|
||||
|
||||
const commit = (next: string[]) => {
|
||||
setRows(next)
|
||||
onChange(next.map((r) => r.trim()).filter(Boolean))
|
||||
}
|
||||
|
||||
const setRow = (i: number, value: string) => {
|
||||
const next = rows.slice()
|
||||
next[i] = value
|
||||
commit(next)
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
if (rows.length >= MAX_MEMBERS) return
|
||||
setRows([...rows, '']) // don't commit — empty row adds nothing to the store
|
||||
}
|
||||
|
||||
const removeRow = (i: number) => {
|
||||
const next = rows.length > 1 ? rows.filter((_, idx) => idx !== i) : ['']
|
||||
commit(next)
|
||||
}
|
||||
|
||||
const full = rows.length >= MAX_MEMBERS
|
||||
const filled = rows.filter((r) => r.trim()).length
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="member-fields">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<span className="font-mono text-[10px] text-muted-foreground w-4 shrink-0 text-right">
|
||||
{i + 1}
|
||||
</span>
|
||||
<Input
|
||||
aria-label={`Member ${i + 1}`}
|
||||
placeholder="e.g. A. Rossi"
|
||||
value={row}
|
||||
onChange={(e) => setRow(i, e.target.value)}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove member ${i + 1}`}
|
||||
onClick={() => removeRow(i)}
|
||||
className="text-muted-foreground hover:text-destructive transition leading-none px-1.5 text-lg shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between pl-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addRow}
|
||||
disabled={full}
|
||||
className="font-mono text-[11px] tracking-wider uppercase h-7 px-2"
|
||||
>
|
||||
+ Add member
|
||||
</Button>
|
||||
<span className="font-mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
||||
{filled} / {MAX_MEMBERS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-1
@@ -85,8 +85,10 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
|
||||
// --- board onboarding (participant) ---------------------------------------
|
||||
export interface ClaimInput {
|
||||
teamId: string
|
||||
kit: string
|
||||
/** Optional legacy sticker path; omit for code-first (board shows its code). */
|
||||
kit?: string
|
||||
teamName?: string
|
||||
members?: string[]
|
||||
code: string
|
||||
}
|
||||
export interface ClaimResult {
|
||||
|
||||
@@ -36,13 +36,6 @@ describe('TeamRegistration', () => {
|
||||
expect(useSession.getState().team.name).toBe('team_resonance')
|
||||
})
|
||||
|
||||
it('persists the selected kit to the session store on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await user.click(screen.getByRole('button', { name: 'KIT-04' }))
|
||||
expect(useSession.getState().team.kit).toBe('KIT-04')
|
||||
})
|
||||
|
||||
it('gates the Proceed button until name + member + board are ready', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
@@ -52,8 +45,8 @@ describe('TeamRegistration', () => {
|
||||
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
||||
expect(proceed).toBeDisabled()
|
||||
|
||||
const memberInput = screen.getByLabelText(/team member/i)
|
||||
await user.type(memberInput, 'A. Rossi{Enter}')
|
||||
const memberInput = screen.getByLabelText('Member 1')
|
||||
await user.type(memberInput, 'A. Rossi')
|
||||
expect(proceed).toBeDisabled()
|
||||
|
||||
// a claimed board satisfies the device requirement
|
||||
@@ -61,35 +54,37 @@ describe('TeamRegistration', () => {
|
||||
expect(proceed).toBeEnabled()
|
||||
})
|
||||
|
||||
it('claims a board with the kit code and marks it connected', async () => {
|
||||
it('binds a board by the matrix code and marks it connected', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ teamId: 't', kit: 'KIT-01', online: true }),
|
||||
json: async () => ({ teamId: 't', kit: 'crimson-otter', online: true }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByLabelText(/claim code/i), '418302')
|
||||
await user.click(screen.getByRole('button', { name: /claim board/i }))
|
||||
await user.type(screen.getByLabelText(/claim code/i), '4821')
|
||||
await user.click(screen.getByRole('button', { name: /bind board/i }))
|
||||
|
||||
expect(await screen.findByTestId('board-connected')).toBeInTheDocument()
|
||||
expect(useSession.getState().device.connected).toBe(true)
|
||||
// the claim went out with the typed code + selected kit
|
||||
// code-first: the claim carries the code, no kit
|
||||
const [, init] = fetchMock.mock.calls[0]
|
||||
expect(JSON.parse(init.body)).toMatchObject({ kit: 'KIT-01', code: '418302' })
|
||||
const body = JSON.parse(init.body)
|
||||
expect(body).toMatchObject({ code: '4821' })
|
||||
expect(body.kit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces a wrong-code error from the server', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong claim code' }) }),
|
||||
vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: 'wrong code — check your matrix' }) }),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await user.type(screen.getByLabelText(/claim code/i), '000000')
|
||||
await user.click(screen.getByRole('button', { name: /claim board/i }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong claim code/i)
|
||||
await user.type(screen.getByLabelText(/claim code/i), '0000')
|
||||
await user.click(screen.getByRole('button', { name: /bind board/i }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong code/i)
|
||||
expect(useSession.getState().device.connected).toBe(false)
|
||||
})
|
||||
|
||||
@@ -97,28 +92,18 @@ describe('TeamRegistration', () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
||||
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}')
|
||||
await user.type(screen.getByLabelText('Member 1'), 'A. Rossi')
|
||||
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
||||
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
||||
expect(useSession.getState().phases.reg).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-selects kit from the ?kit= URL param (QR sticker flow)', () => {
|
||||
it('pre-fills the claim code from the ?code= URL param', () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/workshop?kit=KIT-12']}>
|
||||
<MemoryRouter initialEntries={['/workshop?code=4821']}>
|
||||
<TeamRegistration />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(useSession.getState().team.kit).toBe('KIT-12')
|
||||
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')
|
||||
expect(screen.getByLabelText(/claim code/i)).toHaveValue('4821')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { MemberChips } from '@/components/MemberChips'
|
||||
import { KitSelector } from '@/components/KitSelector'
|
||||
import { MemberFields } from '@/components/MemberFields'
|
||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||
import { BoardClaim } from '@/components/BoardClaim'
|
||||
import { useSession } from '@/store/session'
|
||||
@@ -21,11 +19,6 @@ export function TeamRegistration() {
|
||||
const resumeTeam = useSession((s) => s.resumeTeam)
|
||||
const completePhase = useSession((s) => s.completePhase)
|
||||
|
||||
useEffect(() => {
|
||||
const k = params.get('kit')
|
||||
if (k && /^KIT-\d{2}$/.test(k)) setTeam({ kit: k })
|
||||
}, [params, setTeam])
|
||||
|
||||
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
|
||||
|
||||
const onProceed = () => {
|
||||
@@ -55,8 +48,8 @@ export function TeamRegistration() {
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Team registration</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2 max-w-xl">
|
||||
Name your team, add 3–5 members, pick up your kit, and claim your board.
|
||||
The QR sticker on your kit pre-selects the kit number for you.
|
||||
Name your team, add 3–5 members, then bind the board you already set up this
|
||||
week — run the setup script and enter the code it scrolls on its LED matrix.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +76,7 @@ export function TeamRegistration() {
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||
Members
|
||||
</div>
|
||||
<MemberChips
|
||||
<MemberFields
|
||||
members={team.members}
|
||||
onChange={(members) => setTeam({ members })}
|
||||
/>
|
||||
@@ -93,26 +86,20 @@ export function TeamRegistration() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kit & device</CardTitle>
|
||||
<CardTitle className="text-base">Your board</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||
Select kit
|
||||
</div>
|
||||
<KitSelector value={team.kit} onChange={(kit) => setTeam({ kit })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||
Board
|
||||
Bind your node
|
||||
</div>
|
||||
<BoardClaim
|
||||
teamId={teamId}
|
||||
kit={team.kit}
|
||||
teamName={team.name}
|
||||
members={team.members}
|
||||
connected={device.connected}
|
||||
port={device.port}
|
||||
initialCode={/^\d{6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||
onClaimed={(r) => {
|
||||
// Resume (a lost-browser re-claim): adopt the board's canonical
|
||||
// team + restore its progress instead of keeping this fresh id.
|
||||
|
||||
Reference in New Issue
Block a user