Compare commits

..
2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 48b88b0f0d docs(uno-q): REQUIREMENTS.md for the self-host USB workshop
Student pre-workshop checklist for the confirmed model: each team runs the Docker
stack locally + board over USB, everything localhost, board auto-connects (no
code, no accounts). Lists the board-laptop prereqs (Docker, adb, git, disk,
pre-fetched bundle), the on-the-day flow, a pre-flight self-check, and an
instructor checklist for the remaining decisions.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 10:46:28 -07:00
Omar SobhandClaude Opus 4.8 d91b9b46f0 feat: self-host USB auto-connect (LOCAL_MODE) — no claim code
In the self-host model each team runs the stack locally with one board 1:1 over
USB, so the claim code (which only disambiguated boards in a shared pool) is
unnecessary. Add a LOCAL_MODE that auto-registers + auto-binds the board:

- API: `LOCAL_MODE` env + `GET /mode`; `POST /claim {local:true}` codelessly
  binds the single board (`claimLocal`, rebinding a stale claim on a fresh
  single-team stack); `POST /nodes/:team/disconnect` for the participant's own
  release. Shared/LAN code path is unchanged.
- Frontend: `useLocalMode()` + `LocalBoardConnect` — detect → auto-bind →
  "connected"; USB drop keeps the binding and auto-reconnects; explicit
  Disconnect releases and waits for Reconnect. TeamRegistration swaps the code
  card for it only when the API reports localMode.
- deploy/lan: compose sets `LOCAL_MODE=true` + `host.docker.internal:host-gateway`
  (so the containerized API reaches the adb-forwarded board); `connect-board.sh`
  forwards the tunnels and registers the board with the local stack (`--watch`
  re-attaches on every reconnect).

Validated end-to-end through the Docker stack: mode → auto-bind → live status +
matrix mirror → disconnect → reconnect. 222 tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-23 10:46:28 -07:00
10 changed files with 541 additions and 29 deletions
+40 -6
View File
@@ -20,6 +20,9 @@ export interface AppOptions {
boards?: BoardRegistry boards?: BoardRegistry
/** Shared fleet secret boards present when self-registering. */ /** Shared fleet secret boards present when self-registering. */
fleetSecret?: string fleetSecret?: string
/** Self-host / USB single-board mode: the API is private to one laptop with one
* board 1:1 over USB, so the board auto-binds to the team with no claim code. */
localMode?: boolean
} }
const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false } const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false }
@@ -27,7 +30,7 @@ const emptyStats = { calls: 0, nominal: 0, anomalous: 0, critical: 0 }
/** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */ /** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
export function createApp(opts: AppOptions): Express { export function createApp(opts: AppOptions): Express {
const { store, broadcast, adminCode, judgeCode, nodes, boards, fleetSecret } = opts const { store, broadcast, adminCode, judgeCode, nodes, boards, fleetSecret, localMode } = opts
const now = opts.now ?? (() => new Date().toISOString()) const now = opts.now ?? (() => new Date().toISOString())
const app = express() const app = express()
app.use(cors({ origin: opts.corsOrigin ?? true })) app.use(cors({ origin: opts.corsOrigin ?? true }))
@@ -37,6 +40,12 @@ export function createApp(opts: AppOptions): Express {
res.type('text/plain').send('ok') res.type('text/plain').send('ok')
}) })
// Runtime flags the client reads once on load (e.g. to auto-connect the board
// with no claim code in self-host/USB mode).
app.get('/mode', (_req, res) => {
res.json({ localMode: !!localMode })
})
// --- public participant sync ------------------------------------------- // --- public participant sync -------------------------------------------
app.put('/teams/:id', (req, res) => { app.put('/teams/:id', (req, res) => {
const b = req.body ?? {} const b = req.body ?? {}
@@ -219,18 +228,24 @@ export function createApp(opts: AppOptions): Express {
app.post('/claim', async (req, res) => { app.post('/claim', async (req, res) => {
if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' }) if (!boards || !nodes) return res.status(503).json({ error: 'claim unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
if (typeof b.teamId !== 'string' || typeof b.code !== 'string') { if (typeof b.teamId !== 'string') {
return res.status(400).json({ error: 'teamId and code are required' }) return res.status(400).json({ error: 'teamId is required' })
}
// LOCAL_MODE (self-host/USB): codeless auto-bind of the single local board.
const useLocal = !!localMode && b.local === true
if (!useLocal && typeof b.code !== 'string') {
return res.status(400).json({ error: 'code is required' })
} }
// Code-first (board scrolls its code on the matrix, no kit picked) is the // 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. // default; a supplied `kit` keeps the legacy sticker-claim path working.
const result = const result = useLocal
typeof b.kit === 'string' && b.kit ? boards.claimLocal(b.teamId)
: typeof b.kit === 'string' && b.kit
? boards.claim(b.kit, b.code, b.teamId, Date.parse(now())) ? boards.claim(b.kit, b.code, b.teamId, Date.parse(now()))
: boards.claimByCode(b.code, b.teamId, Date.parse(now())) : boards.claimByCode(b.code, b.teamId, Date.parse(now()))
if (!result.ok) { if (!result.ok) {
if (result.reason === 'unknown') { if (result.reason === 'unknown') {
return res.status(404).json({ error: 'no board found for that kit — is it powered on?' }) return res.status(404).json({ error: useLocal ? 'no board detected yet — plug it in' : 'no board found for that kit — is it powered on?' })
} }
if (result.reason === 'rate_limited') { if (result.reason === 'rate_limited') {
return res.status(429).json({ error: 'too many attempts — wait a minute and try again' }) return res.status(429).json({ error: 'too many attempts — wait a minute and try again' })
@@ -282,6 +297,25 @@ export function createApp(opts: AppOptions): Express {
res.status(204).end() res.status(204).end()
}) })
// LOCAL_MODE: the participant's own "disconnect" — unbind this team's board and
// drop its node so it returns to the auto-connect pool (re-binds on reconnect).
// Public (private single-laptop API); no-op unless localMode.
app.post('/nodes/:teamId/disconnect', (req, res) => {
if (!localMode) return res.status(403).json({ error: 'local mode only' })
if (!boards) return res.status(503).json({ error: 'claim unavailable' })
const teamId = String(req.params.teamId)
const freed = boards.releaseByTeam(teamId)
if (nodes) nodes.remove(teamId)
const prev = store.getTeam(teamId)
if (prev) {
const team: TeamSnapshot = { ...prev, deviceConnected: false, updatedAt: now() }
store.upsertTeam(team)
broadcast({ type: 'team:update', team })
}
broadcastUnclaimed()
res.json({ teamId, released: !!freed })
})
app.post('/nodes/:teamId/prompt', async (req, res) => { app.post('/nodes/:teamId/prompt', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' }) if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {} const b = req.body ?? {}
+29
View File
@@ -59,6 +59,16 @@ export interface BoardRegistry {
claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome claimByCode(code: string, teamId: string, nowMs: number): ClaimOutcome
/** Release a kit back to unclaimed; returns the freed teamId (or null). */ /** Release a kit back to unclaimed; returns the freed teamId (or null). */
release(kitId: string): string | null release(kitId: string): string | null
/**
* LOCAL_MODE only: bind the single local board to `teamId` with NO code. The
* API is private to one laptop and the board is 1:1 over USB, so there's
* nothing to disambiguate. Prefers a board already this team's (resume), else
* the sole unclaimed board, else the most-recent board (re-binding a stale
* claim from a fresh single-team stack). `unknown` if no board has registered.
*/
claimLocal(teamId: string): ClaimOutcome
/** Release whatever board is bound to `teamId` (the UI "disconnect"). */
releaseByTeam(teamId: string): string | null
get(kitId: string): Board | undefined get(kitId: string): Board | undefined
} }
@@ -155,6 +165,25 @@ export function createBoardRegistry(
onChange(board) onChange(board)
return freed return freed
}, },
claimLocal(teamId) {
const list = [...boards.values()]
if (list.length === 0) return { ok: false, reason: 'unknown' }
const target =
list.find((b) => b.claimedBy === teamId) ?? // already ours → resume
list.find((b) => b.claimedBy === null) ?? // the sole unclaimed board
list[list.length - 1] // single-team stack: (re)bind the most-recent board
const wasMine = target.claimedBy === teamId
target.claimedBy = teamId
onChange(target)
return { ok: true, board: target, resumed: wasMine }
},
releaseByTeam(teamId) {
const board = [...boards.values()].find((b) => b.claimedBy === teamId)
if (!board) return null
board.claimedBy = null
onChange(board)
return board.kitId
},
get(kitId) { get(kitId) {
return byKit(kitId) return byKit(kitId)
}, },
+3
View File
@@ -12,6 +12,8 @@ const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
const JUDGE_CODE = process.env.JUDGE_CODE ?? '' const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
const FLEET_SECRET = process.env.FLEET_SECRET ?? '' const FLEET_SECRET = process.env.FLEET_SECRET ?? ''
const CORS_ORIGIN = process.env.CORS_ORIGIN const CORS_ORIGIN = process.env.CORS_ORIGIN
// Self-host / USB mode: one private API, one board 1:1 over USB → auto-bind, no code.
const LOCAL_MODE = /^(1|true|yes)$/i.test(process.env.LOCAL_MODE ?? '')
if (!ADMIN_CODE || !JUDGE_CODE) { if (!ADMIN_CODE || !JUDGE_CODE) {
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests') console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
@@ -33,6 +35,7 @@ const app = createApp({
nodes, nodes,
boards, boards,
fleetSecret: FLEET_SECRET, fleetSecret: FLEET_SECRET,
localMode: LOCAL_MODE,
}) })
const server = http.createServer(app) const server = http.createServer(app)
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# connect-board.sh — attach the USB board to your LOCAL self-host stack.
#
# The stack runs in Docker on this laptop; the board is on USB, reached over adb.
# This forwards the tunnels and registers the board with the containerized API
# (which reaches it at host.docker.internal). In LOCAL_MODE the API auto-binds the
# board to your team in the browser — no claim code. Run with --watch to re-attach
# automatically on every (re)connect.
#
# Usage:
# ./connect-board.sh # attach once
# ./connect-board.sh --watch # attach on every (re)connect (leave it running)
#
# Env: SERIAL (auto-detected if unset) · WEB_URL (default http://localhost:8090)
# FLEET_SECRET (default apess2026) · KIT_ID · NODE_URL
set -uo pipefail
WEB="${WEB_URL:-http://localhost:8090}"
API="$WEB/api"
FLEET_SECRET="${FLEET_SECRET:-apess2026}"
KIT_ID="${KIT_ID:-crimson-node}"
# How the API *container* reaches the board (adb binds host loopback; the API is
# in Docker, so it uses the host gateway alias — see docker-compose extra_hosts).
NODE_URL="${NODE_URL:-http://host.docker.internal:8080}"
PORTS=(8080 9999)
ADB="$(command -v adb || true)"
for c in /opt/homebrew/bin/adb /usr/local/bin/adb "$HOME/Library/Android/sdk/platform-tools/adb"; do
[ -n "$ADB" ] && break
[ -x "$c" ] && ADB="$c"
done
[ -n "$ADB" ] || { echo "connect-board: adb not found in PATH"; exit 127; }
log() { printf '\033[36m[connect]\033[0m %s\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
# First attached device unless SERIAL is pinned.
detect_serial() {
[ -n "${SERIAL:-}" ] && { echo "$SERIAL"; return; }
"$ADB" devices | awk '/\tdevice$/{print $1; exit}'
}
connect_once() {
local serial; serial="$(detect_serial)"
[ -n "$serial" ] || { warn "no board attached over USB"; return 1; }
ok "board $serial attached"
# 1 · forward tunnels (vanish on re-plug)
for p in "${PORTS[@]}"; do
"$ADB" -s "$serial" forward --list 2>/dev/null | grep -q "tcp:$p" \
|| "$ADB" -s "$serial" forward "tcp:$p" "tcp:$p" >/dev/null
done
ok "tunnels forwarded (${PORTS[*]})"
# 2 · wait for the board daemon (App Lab app auto-starts on boot)
local n=0
until curl -s -m2 http://127.0.0.1:8080/health -o /dev/null 2>/dev/null; do
n=$((n + 1)); [ "$n" -gt 90 ] && { warn "board daemon never came up"; return 1; }
sleep 2
done
ok "board daemon healthy"
# 3 · register with the LOCAL stack (LOCAL_MODE auto-binds it in the browser).
# The claim code is irrelevant in local mode but the endpoint wants one.
local r
r="$(curl -s -m5 -X POST "$API/nodes/self-register" \
-H "x-fleet-secret: $FLEET_SECRET" -H 'content-type: application/json' \
-d "{\"kitId\":\"$KIT_ID\",\"claimCode\":\"local\",\"url\":\"$NODE_URL\",\"token\":\"open-lan\"}" 2>/dev/null)"
if echo "$r" | grep -q '"url"'; then
ok "registered with the local stack"
log "attached — it auto-connects in the browser (no code needed)"
else
warn "register failed — is the stack up? ($WEB) · $r"
return 1
fi
}
watch_loop() {
log "watching for the board — will attach on every (re)connect (Ctrl-C to stop)"
while true; do
"$ADB" wait-for-device
sleep 3 # let Linux + the App Lab app finish booting
connect_once || warn "attach incomplete; will retry on next reconnect"
# wait until it disconnects
while [ -n "$(detect_serial)" ]; do sleep 2; done
log "board disconnected — waiting for re-plug"
done
}
case "${1:-}" in
--watch | -w) watch_loop ;;
*) connect_once ;;
esac
+9
View File
@@ -47,9 +47,18 @@ services:
# Must match the FLEET_SECRET baked into each board's apess-node.env. # Must match the FLEET_SECRET baked into each board's apess-node.env.
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET} FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
DB_PATH: /data/apess.db DB_PATH: /data/apess.db
# Self-host / USB single-board mode: the board auto-binds to the team with
# no claim code (one private API, one board 1:1 over USB).
LOCAL_MODE: ${LOCAL_MODE:-true}
# Same-origin via the /api proxy → no CORS needed (API default is permissive). # Same-origin via the /api proxy → no CORS needed (API default is permissive).
volumes: volumes:
- apess-lan-data:/data - apess-lan-data:/data
# USB self-host: the board is attached to THIS laptop and reached over adb
# (`adb forward tcp:8080/tcp:9999`), which binds host loopback. The API runs
# in a container, so it reaches the board at host.docker.internal — mapped to
# the host gateway here (built-in on Docker Desktop; required on Linux).
extra_hosts:
- 'host.docker.internal:host-gateway'
# Not published: boards + browsers reach the API through the web's /api proxy. # Not published: boards + browsers reach the API through the web's /api proxy.
networks: [apess-lan] networks: [apess-lan]
+102
View File
@@ -0,0 +1,102 @@
# APESS 2026 Workshop — Laptop Prerequisites
**"Design the Agent Your Building Deserves" · 27 July · a 5-hour build session.**
Do these **before you arrive** so we spend the session building, not installing.
> Companion docs: [`WORKSHOP-FLOW.md`](./WORKSHOP-FLOW.md) (what you'll do) ·
> [`ONBOARDING.md`](./ONBOARDING.md) (how the board comes up).
---
## How it runs (so the prerequisites make sense)
Each team runs the **whole platform on its own laptop** — a small Docker stack (web + API) that
comes up with **one command**. Your **Arduino Uno Q** plugs into that same laptop over **USB**.
Everything is **localhost**: the browser, the API, and the board all talk on your machine. Once
the stack is up and the board is plugged in, it **auto-connects to your team — no codes, no
accounts, nothing over the network.** (WiFi isn't used during the workshop; it's only for a future
step that registers boards with our production cloud.)
So each team needs **one "board laptop"** with a few things pre-installed. Extra teammates just
need a browser pointed at that laptop.
---
## TL;DR
- **Board laptop:** install **Docker**, **adb**, and **git**; pull the workshop bundle ahead of time.
- **Everyone else:** a modern browser is enough.
- **No accounts, no API keys** — the AI cloud access is baked into the board app.
---
## What the workshop provides (do NOT install)
- **Arduino Uno Q (4 GB)** board + **USB-C cable** — one per team.
- **ADXL355 accelerometer(s)** + wiring — the FabLab kit.
- **Cloud AI access** — baked into the board app. **No Anthropic/Claude account needed.**
- The **web app** itself (you run it locally from the bundle below).
---
## The team "board laptop" — pre-install these (large downloads, do them at home)
1. **Docker** — Docker Desktop (macOS/Windows) or Docker Engine + Compose (Linux).
Verify: `docker run hello-world` succeeds.
2. **adb** (Android platform-tools) — the USB bridge to the board.
Verify: `adb version` prints a version. (macOS: `brew install android-platform-tools`.)
3. **git** — to fetch the workshop bundle. Verify: `git --version`.
4. **~10 GB free disk** — Docker images (web + API) + the board's App Lab base image (~0.9 GB).
5. **The workshop bundle, pre-fetched** so you're not downloading on the WiFi at 14:00:
```sh
git clone <workshop-repo-url> # [instructor: final repo/bundle URL]
cd <repo>/deploy/lan
docker compose up -d --build # pre-build the images once, at home
docker compose down # then stop until the day
```
(Also pre-pull the App Lab base image on the board — it's fetched on first Run.)
## Everyone else on the team
- A **current browser** (Chrome/Edge recommended; Firefox works). That's it — you'll open the board
laptop's local URL.
---
## What you'll do on the day (no accounts, no claim codes)
1. **Bring the stack up:** `cd deploy/lan && docker compose up -d` → open **`http://localhost:8090/`**.
2. **Get the board app:** in Team Registration, click **Download the board app** (served by your own
stack), then on the Uno Q: **App Lab → Import an app → pick the zip → Run.** `[instructor: confirm the App Lab access flow for the room]`
3. **Attach the board:** plug the Uno Q into the board laptop over USB and run
**`./deploy/lan/connect-board.sh`** (or `--watch` to keep it auto-attaching).
4. **It just connects:** type your team name and the board **auto-binds to your team** — no code.
Unplug/replug is handled automatically; a **Disconnect / Reconnect** control is there if you need it.
5. **Build:** walk Modules 1–3, submit your Agent Design Document.
---
## Pre-flight self-check (before you travel)
- [ ] **(Board laptop)** `docker run hello-world` works.
- [ ] **(Board laptop)** `adb version` and `git --version` work.
- [ ] **(Board laptop)** Ran `docker compose up -d --build` once (images built) and opened `localhost:8090`.
- [ ] Laptop charged + charger packed (5-hour session).
- [ ] A current browser.
---
## No accounts to create
- ❌ No Anthropic / Claude account or API key — the cloud key is baked into the board app.
- ❌ No claim codes — in this local USB setup the board auto-connects.
- ✅ Everything is localhost; nothing depends on the room WiFi.
---
## Notes for the curious (why these specific tools)
- **Docker** runs the web + API stack in one command, identically on every laptop.
- **adb** carries the board over USB; the API (in a container) reaches it via `host.docker.internal`.
- The web runs on **`:8090`** (not `:8080`) because `:8080` is the board's own port, forwarded over adb.
---
## Instructor checklist (finalize before publishing to students)
- [ ] **The workshop bundle URL** (git repo or a downloadable archive) students clone/pull.
- [ ] **Pre-built image distribution** — consider publishing `apess-web`/`apess-api` to a registry (or a
USB `docker load` bundle) so teams `docker compose up` without a source build on the day.
- [ ] **Exact Arduino App Lab access** on the Uno Q for the room (and whether it needs any login).
- [ ] **Pre-seed** the ~0.9 GB App Lab base image locally so 9 teams don't each pull it live.
- [ ] Decide whether `connect-board.sh --watch` runs via a small launchd/systemd unit (hands-free re-plug).
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useRef, useState } from 'react'
import { autoClaimLocal, disconnectLocalBoard, getNodeStatus, type ClaimResult } from '@/lib/api'
import { cn } from '@/lib/utils'
export interface LocalBoardConnectProps {
teamId: string
teamName: string
members: string[]
connected: boolean
port: string | null
/** Bind succeeded — parent stores the device (same handler as BoardClaim). */
onClaimed: (result: ClaimResult) => void
/** Drop the local device binding in the store. */
onDisconnect: () => void
}
/**
* Self-host / USB auto-connect (LOCAL_MODE). One private API + one board over USB,
* so there's no claim code: once the team is named and the board is detected, it
* binds automatically. A USB drop keeps the binding and reconnects on its own; an
* explicit Disconnect releases it and waits for a Reconnect click.
*/
export function LocalBoardConnect({
teamId,
teamName,
members,
connected,
port,
onClaimed,
onDisconnect,
}: LocalBoardConnectProps) {
const [paused, setPaused] = useState(false)
const [online, setOnline] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasName = teamName.trim().length > 0
// Keep latest values without re-subscribing the poll loops.
const onClaimedRef = useRef(onClaimed)
onClaimedRef.current = onClaimed
const payloadRef = useRef({ teamName, members })
payloadRef.current = { teamName, members }
// Auto-detect + auto-bind while disconnected, not paused, and named.
useEffect(() => {
if (connected || paused || !hasName) return
let cancelled = false
const tick = async () => {
try {
const r = await autoClaimLocal({ teamId, ...payloadRef.current })
if (!cancelled && r) {
setError(null)
onClaimedRef.current(r)
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : 'could not connect')
}
}
void tick()
const id = window.setInterval(() => void tick(), 2000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [connected, paused, hasName, teamId])
// Heartbeat while connected → drives the "reconnecting" indicator + auto-recovery.
useEffect(() => {
if (!connected) return
let cancelled = false
const tick = () =>
getNodeStatus(teamId)
.then((s) => !cancelled && setOnline(s.online))
.catch(() => !cancelled && setOnline(false))
tick()
const id = window.setInterval(tick, 3000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [connected, teamId])
const disconnect = async () => {
setPaused(true)
await disconnectLocalBoard(teamId)
onDisconnect()
}
// ── connected ──
if (connected) {
const live = online
return (
<div
className={cn(
'rounded-md border px-4 py-3',
live ? 'border-[var(--green-border)] bg-[var(--green-bg)]' : 'border-[color-mix(in_srgb,var(--amber)_40%,transparent)] bg-[var(--surface-soft)]',
)}
data-testid="board-connected"
data-state={live ? 'online' : 'reconnecting'}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('h-2 w-2 shrink-0 rounded-full', live ? 'bg-[var(--green)] animate-pulse' : 'bg-[var(--amber)] animate-pulse')} />
<span className="text-sm font-medium">
{live ? 'Board connected' : 'Board unplugged — reconnecting…'}
</span>
</div>
<button
type="button"
onClick={disconnect}
className="shrink-0 font-mono text-[10px] uppercase tracking-widest text-[var(--muted)] hover:text-rose"
>
Disconnect
</button>
</div>
<div className="mt-1 font-mono text-[10px] text-[var(--muted)]">{port ?? 'usb · local'}</div>
</div>
)
}
// ── manually disconnected → wait for Reconnect ──
if (paused) {
return (
<div className="rounded-md border border-line bg-surface-soft px-4 py-3" data-testid="board-paused">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-ink-2">Board disconnected.</span>
<button
type="button"
onClick={() => setPaused(false)}
className="rounded-md border border-line-2 bg-surface px-3 py-1.5 text-[13px] text-ink transition-colors hover:border-blue hover:text-blue-ink"
>
Reconnect
</button>
</div>
</div>
)
}
// ── detecting (or waiting for a team name) ──
return (
<div className="rounded-md border border-line bg-surface-soft px-4 py-3" data-testid="board-detecting">
{hasName ? (
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-blue" />
<span className="text-sm text-ink-2">Detecting your board over USB…</span>
</div>
) : (
<span className="text-sm text-ink-3">Enter your team name above — your board connects automatically.</span>
)}
{error && <p className="mt-2 font-mono text-[11px] text-rose">{error}</p>}
<p className="mt-2 text-[12px] leading-relaxed text-ink-3">
Plugged in over USB and running the board app? It binds on its own — no code needed.
</p>
</div>
)
}
+37
View File
@@ -149,6 +149,43 @@ export async function claimBoard(input: ClaimInput): Promise<ClaimResult> {
return (await res.json()) as ClaimResult return (await res.json()) as ClaimResult
} }
/** Runtime flags from the API (localMode drives the codeless USB auto-connect). */
export async function getMode(): Promise<{ localMode: boolean }> {
try {
const res = await fetch(`${API_BASE}/mode`)
if (!res.ok) return { localMode: false }
return (await res.json()) as { localMode: boolean }
} catch {
return { localMode: false }
}
}
/**
* LOCAL_MODE (self-host/USB): bind the single attached board to this team with
* NO claim code. Returns the ClaimResult, or `null` when no board has registered
* yet (so the caller can keep polling / show "detecting").
*/
export async function autoClaimLocal(
input: { teamId: string; teamName?: string; members?: string[] },
): Promise<ClaimResult | null> {
const res = await fetch(`${API_BASE}/claim`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...input, local: true }),
})
if (res.status === 404) return null // no board detected yet
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new ClaimError(res.status, body.error ?? `claim ${res.status}`)
}
return (await res.json()) as ClaimResult
}
/** LOCAL_MODE: the participant's own disconnect — unbind this team's board. */
export async function disconnectLocalBoard(teamId: string): Promise<void> {
await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/disconnect`, { method: 'POST' }).catch(() => {})
}
/** Poll a team's board liveness (used by the setup self-test). */ /** Poll a team's board liveness (used by the setup self-test). */
export async function getNodeStatus(teamId: string): Promise<{ teamId: string; url?: string; online: boolean }> { export async function getNodeStatus(teamId: string): Promise<{ teamId: string; url?: string; online: boolean }> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`) const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`)
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
import { getMode } from './api'
/**
* Whether the API is in self-host / USB "local mode" (one private API, one board
* 1:1 over USB → codeless auto-connect). `null` while the one-shot `/mode` fetch
* is in flight. Cached for the session after the first resolve.
*/
let cached: boolean | null = null
export function useLocalMode(): boolean | null {
const [mode, setMode] = useState<boolean | null>(cached)
useEffect(() => {
if (cached !== null) return
let cancelled = false
getMode()
.then((r) => {
cached = r.localMode
if (!cancelled) setMode(r.localMode)
})
.catch(() => {
cached = false
if (!cancelled) setMode(false)
})
return () => {
cancelled = true
}
}, [])
return mode
}
+33 -14
View File
@@ -2,10 +2,13 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { MemberFields } from '@/components/MemberFields' import { MemberFields } from '@/components/MemberFields'
import { BoardClaim } from '@/components/BoardClaim' import { BoardClaim } from '@/components/BoardClaim'
import { LocalBoardConnect } from '@/components/LocalBoardConnect'
import { SayHiCard } from '@/components/SayHiCard' import { SayHiCard } from '@/components/SayHiCard'
import { TelegramSetup } from '@/components/TelegramSetup' import { TelegramSetup } from '@/components/TelegramSetup'
import { VoiceSetup } from '@/components/VoiceSetup' import { VoiceSetup } from '@/components/VoiceSetup'
import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome' import { PanelHeading, PanelCard, ProceedButton, FieldLabel } from '@/components/cockpit/PanelChrome'
import { useLocalMode } from '@/lib/useLocalMode'
import type { ClaimResult } from '@/lib/api'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function TeamRegistration() { export function TeamRegistration() {
@@ -20,6 +23,7 @@ export function TeamRegistration() {
const resumeTeam = useSession((s) => s.resumeTeam) const resumeTeam = useSession((s) => s.resumeTeam)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const localMode = useLocalMode()
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
const onProceed = () => { const onProceed = () => {
@@ -27,6 +31,21 @@ export function TeamRegistration() {
navigate('/workshop/setup') navigate('/workshop/setup')
} }
// Shared bind handler for both the code path and the local auto-connect.
const handleClaimed = (r: ClaimResult) => {
if (r.resumed && r.team) {
resumeTeam({
id: r.team.id,
name: r.team.name,
kit: r.team.kit,
members: r.team.members,
phases: r.team.phases,
stats: r.team.stats,
})
}
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
}
return ( return (
<section> <section>
<PanelHeading <PanelHeading
@@ -57,7 +76,18 @@ export function TeamRegistration() {
<PanelCard> <PanelCard>
<div className="text-[17px] font-semibold">Your board</div> <div className="text-[17px] font-semibold">Your board</div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
<FieldLabel>Bind your node</FieldLabel> <FieldLabel>{localMode ? 'Connect your board (USB)' : 'Bind your node'}</FieldLabel>
{localMode === true ? (
<LocalBoardConnect
teamId={teamId}
teamName={team.name}
members={team.members}
connected={device.connected}
port={device.port}
onDisconnect={disconnect}
onClaimed={handleClaimed}
/>
) : (
<BoardClaim <BoardClaim
teamId={teamId} teamId={teamId}
teamName={team.name} teamName={team.name}
@@ -66,20 +96,9 @@ export function TeamRegistration() {
port={device.port} port={device.port}
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined} initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
onDisconnect={disconnect} onDisconnect={disconnect}
onClaimed={(r) => { onClaimed={handleClaimed}
if (r.resumed && r.team) {
resumeTeam({
id: r.team.id,
name: r.team.name,
kit: r.team.kit,
members: r.team.members,
phases: r.team.phases,
stats: r.team.stats,
})
}
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
}}
/> />
)}
</div> </div>
</PanelCard> </PanelCard>
</div> </div>