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]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c761c510a6
commit
d91b9b46f0
+40
-6
@@ -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 ?? {}
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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.
|
# 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]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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`)
|
||||||
|
|||||||
@@ -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 { 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,29 +76,29 @@ 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>
|
||||||
<BoardClaim
|
{localMode === true ? (
|
||||||
teamId={teamId}
|
<LocalBoardConnect
|
||||||
teamName={team.name}
|
teamId={teamId}
|
||||||
members={team.members}
|
teamName={team.name}
|
||||||
connected={device.connected}
|
members={team.members}
|
||||||
port={device.port}
|
connected={device.connected}
|
||||||
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
port={device.port}
|
||||||
onDisconnect={disconnect}
|
onDisconnect={disconnect}
|
||||||
onClaimed={(r) => {
|
onClaimed={handleClaimed}
|
||||||
if (r.resumed && r.team) {
|
/>
|
||||||
resumeTeam({
|
) : (
|
||||||
id: r.team.id,
|
<BoardClaim
|
||||||
name: r.team.name,
|
teamId={teamId}
|
||||||
kit: r.team.kit,
|
teamName={team.name}
|
||||||
members: r.team.members,
|
members={team.members}
|
||||||
phases: r.team.phases,
|
connected={device.connected}
|
||||||
stats: r.team.stats,
|
port={device.port}
|
||||||
})
|
initialCode={/^\d{4,6}$/.test(params.get('code') ?? '') ? params.get('code')! : undefined}
|
||||||
}
|
onDisconnect={disconnect}
|
||||||
setDevice({ connected: true, port: `board · ${r.kit}`, uptimeS: 0, nodeUrl: r.url ?? null })
|
onClaimed={handleClaimed}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user