Compare commits
2
Commits
c761c510a6
...
48b88b0f0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b88b0f0d | ||
|
|
d91b9b46f0 |
+40
-6
@@ -20,6 +20,9 @@ export interface AppOptions {
|
||||
boards?: BoardRegistry
|
||||
/** Shared fleet secret boards present when self-registering. */
|
||||
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 }
|
||||
@@ -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). */
|
||||
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 app = express()
|
||||
app.use(cors({ origin: opts.corsOrigin ?? true }))
|
||||
@@ -37,6 +40,12 @@ export function createApp(opts: AppOptions): Express {
|
||||
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 -------------------------------------------
|
||||
app.put('/teams/:id', (req, res) => {
|
||||
const b = req.body ?? {}
|
||||
@@ -219,18 +228,24 @@ 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.teamId !== 'string' || typeof b.code !== 'string') {
|
||||
return res.status(400).json({ error: 'teamId and code are required' })
|
||||
if (typeof b.teamId !== 'string') {
|
||||
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
|
||||
// default; a supplied `kit` keeps the legacy sticker-claim path working.
|
||||
const result =
|
||||
typeof b.kit === 'string' && b.kit
|
||||
const result = useLocal
|
||||
? boards.claimLocal(b.teamId)
|
||||
: 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?' })
|
||||
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') {
|
||||
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()
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
|
||||
const b = req.body ?? {}
|
||||
|
||||
@@ -59,6 +59,16 @@ export interface BoardRegistry {
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@@ -155,6 +165,25 @@ export function createBoardRegistry(
|
||||
onChange(board)
|
||||
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) {
|
||||
return byKit(kitId)
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
|
||||
const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
|
||||
const FLEET_SECRET = process.env.FLEET_SECRET ?? ''
|
||||
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) {
|
||||
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
|
||||
@@ -33,6 +35,7 @@ const app = createApp({
|
||||
nodes,
|
||||
boards,
|
||||
fleetSecret: FLEET_SECRET,
|
||||
localMode: LOCAL_MODE,
|
||||
})
|
||||
|
||||
const server = http.createServer(app)
|
||||
|
||||
Executable
+94
@@ -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
|
||||
@@ -47,9 +47,18 @@ services:
|
||||
# Must match the FLEET_SECRET baked into each board's apess-node.env.
|
||||
FLEET_SECRET: ${FLEET_SECRET:?set FLEET_SECRET}
|
||||
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).
|
||||
volumes:
|
||||
- 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.
|
||||
networks: [apess-lan]
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -149,6 +149,43 @@ export async function claimBoard(input: ClaimInput): Promise<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). */
|
||||
export async function getNodeStatus(teamId: string): Promise<{ teamId: string; url?: string; online: boolean }> {
|
||||
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/status`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,10 +2,13 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { MemberFields } from '@/components/MemberFields'
|
||||
import { BoardClaim } from '@/components/BoardClaim'
|
||||
import { LocalBoardConnect } from '@/components/LocalBoardConnect'
|
||||
import { SayHiCard } from '@/components/SayHiCard'
|
||||
import { TelegramSetup } from '@/components/TelegramSetup'
|
||||
import { VoiceSetup } from '@/components/VoiceSetup'
|
||||
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'
|
||||
|
||||
export function TeamRegistration() {
|
||||
@@ -20,6 +23,7 @@ export function TeamRegistration() {
|
||||
const resumeTeam = useSession((s) => s.resumeTeam)
|
||||
const completePhase = useSession((s) => s.completePhase)
|
||||
|
||||
const localMode = useLocalMode()
|
||||
const ready = team.name.trim().length > 0 && team.members.length > 0 && device.connected
|
||||
|
||||
const onProceed = () => {
|
||||
@@ -27,6 +31,21 @@ export function TeamRegistration() {
|
||||
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 (
|
||||
<section>
|
||||
<PanelHeading
|
||||
@@ -57,29 +76,29 @@ export function TeamRegistration() {
|
||||
<PanelCard>
|
||||
<div className="text-[17px] font-semibold">Your board</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<FieldLabel>Bind your node</FieldLabel>
|
||||
<BoardClaim
|
||||
teamId={teamId}
|
||||
teamName={team.name}
|
||||
members={team.members}
|
||||
connected={device.connected}
|
||||
port={device.port}
|
||||
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||
onDisconnect={disconnect}
|
||||
onClaimed={(r) => {
|
||||
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 })
|
||||
}}
|
||||
/>
|
||||
<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
|
||||
teamId={teamId}
|
||||
teamName={team.name}
|
||||
members={team.members}
|
||||
connected={device.connected}
|
||||
port={device.port}
|
||||
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||
onDisconnect={disconnect}
|
||||
onClaimed={handleClaimed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user