feat: Web Serial bridge + harness/submission libs — TDD
- src/lib/serial.ts: serialSupported, parseLine, classifyFrame (pure), requestPort with deterministic mock fallback for tests/unsupported browsers - src/lib/useSerial.ts: React hook streaming frames into stats via recordEvent, classifying against the live (re-readable) harness; inject() for test triggers - src/lib/harness.ts: harnessToToml renderer - src/lib/submission.ts: deterministic makeSubmissionCode - TeamRegistration.onConnect now uses requestPort (sync mock fallback keeps the existing flow test green) 20 new tests; suite 49/49 green, typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8163dd7829
commit
66191217ec
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { harnessToToml } from './harness'
|
||||||
|
import type { Harness } from '@/store/session'
|
||||||
|
|
||||||
|
const harness: Harness = {
|
||||||
|
thresholdG: 0.8,
|
||||||
|
thresholdDb: 65,
|
||||||
|
callsPerMinute: 8,
|
||||||
|
provider: 'anthropic',
|
||||||
|
model: 'claude-haiku-4-5',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('harnessToToml', () => {
|
||||||
|
it('renders a [harness] section with the tuned thresholds', () => {
|
||||||
|
const toml = harnessToToml(harness)
|
||||||
|
expect(toml).toContain('[harness]')
|
||||||
|
expect(toml).toContain('threshold_g = 0.8')
|
||||||
|
expect(toml).toContain('threshold_db = 65')
|
||||||
|
expect(toml).toContain('calls_per_minute = 8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a [provider] section with quoted string values', () => {
|
||||||
|
const toml = harnessToToml(harness)
|
||||||
|
expect(toml).toContain('[provider]')
|
||||||
|
expect(toml).toContain('name = "anthropic"')
|
||||||
|
expect(toml).toContain('model = "claude-haiku-4-5"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects live edits to the threshold values', () => {
|
||||||
|
expect(harnessToToml({ ...harness, thresholdG: 1.25 })).toContain('threshold_g = 1.25')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Harness } from '@/store/session'
|
||||||
|
|
||||||
|
/** Render the live harness config as a `harness.toml`-style document. */
|
||||||
|
export function harnessToToml(h: Harness): string {
|
||||||
|
return [
|
||||||
|
'[harness]',
|
||||||
|
`threshold_g = ${h.thresholdG}`,
|
||||||
|
`threshold_db = ${h.thresholdDb}`,
|
||||||
|
`calls_per_minute = ${h.callsPerMinute}`,
|
||||||
|
'',
|
||||||
|
'[provider]',
|
||||||
|
`name = "${h.provider}"`,
|
||||||
|
`model = "${h.model}"`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { serialSupported, parseLine, classifyFrame, requestPort } from './serial'
|
||||||
|
|
||||||
|
describe('serialSupported', () => {
|
||||||
|
it('is false when navigator.serial is absent (jsdom)', () => {
|
||||||
|
expect(serialSupported()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseLine', () => {
|
||||||
|
it('parses a 4-field CSV frame', () => {
|
||||||
|
expect(parseLine('0.10,0.00,0.98,42')).toEqual({ ax: 0.1, ay: 0, az: 0.98, db: 42, t: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolerates surrounding whitespace and trailing newline', () => {
|
||||||
|
expect(parseLine(' 1,2,3,4 \r')).toEqual({ ax: 1, ay: 2, az: 3, db: 4, t: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for junk, partial, or non-numeric lines', () => {
|
||||||
|
expect(parseLine('garbage')).toBeNull()
|
||||||
|
expect(parseLine('1,2,3')).toBeNull()
|
||||||
|
expect(parseLine('1,2,3,x')).toBeNull()
|
||||||
|
expect(parseLine('')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('classifyFrame', () => {
|
||||||
|
const h = { thresholdG: 0.8, thresholdDb: 65 }
|
||||||
|
const frame = (ax: number, db: number) => ({ ax, ay: 0, az: 0, db, t: 0 })
|
||||||
|
|
||||||
|
it('is nominal below both thresholds', () => {
|
||||||
|
expect(classifyFrame(frame(0.2, 40), h)).toBe('nominal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is anomalous when only the g-magnitude exceeds threshold', () => {
|
||||||
|
expect(classifyFrame(frame(1.5, 40), h)).toBe('anomalous')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is anomalous when only the dB level exceeds threshold', () => {
|
||||||
|
expect(classifyFrame(frame(0.2, 80), h)).toBe('anomalous')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is critical when both g-magnitude and dB exceed their thresholds', () => {
|
||||||
|
expect(classifyFrame(frame(1.5, 80), h)).toBe('critical')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('requestPort (mock fallback)', () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers())
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
it('returns a mocked connection when Web Serial is unsupported', async () => {
|
||||||
|
const conn = await requestPort()
|
||||||
|
expect(conn.mocked).toBe(true)
|
||||||
|
expect(conn.port).toBe('mock-serial://uno-r4-wifi')
|
||||||
|
await conn.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits synthetic frames on a timer and stops on unsubscribe/close', async () => {
|
||||||
|
const conn = await requestPort()
|
||||||
|
const seen: number[] = []
|
||||||
|
const unsub = conn.onFrame((f) => seen.push(f.t))
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1200)
|
||||||
|
expect(seen.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const afterUnsub = seen.length
|
||||||
|
unsub()
|
||||||
|
await vi.advanceTimersByTimeAsync(1200)
|
||||||
|
expect(seen.length).toBe(afterUnsub)
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Web Serial bridge to the Arduino UNO R4 WiFi, with a deterministic mock
|
||||||
|
// fallback so the workshop flow + tests run without hardware (or in browsers
|
||||||
|
// that lack the Web Serial API). Modules 1 & 2 consume `requestPort` via the
|
||||||
|
// `useSerial` hook; the classifier is pure so it is trivially unit-tested.
|
||||||
|
|
||||||
|
export interface ImuFrame {
|
||||||
|
ax: number
|
||||||
|
ay: number
|
||||||
|
az: number
|
||||||
|
db: number
|
||||||
|
t: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerialEvent = 'nominal' | 'anomalous' | 'critical'
|
||||||
|
|
||||||
|
export interface HarnessThresholds {
|
||||||
|
thresholdG: number
|
||||||
|
thresholdDb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerialConn {
|
||||||
|
port: string
|
||||||
|
mocked: boolean
|
||||||
|
onFrame(cb: (f: ImuFrame) => void): () => void
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_PORT = 'mock-serial://uno-r4-wifi'
|
||||||
|
const BAUD_RATE = 115200
|
||||||
|
|
||||||
|
export function serialSupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && 'serial' in navigator
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse one line of the firmware wire format: `ax,ay,az,db` (CSV). */
|
||||||
|
export function parseLine(line: string): ImuFrame | null {
|
||||||
|
const parts = line.trim().split(',')
|
||||||
|
if (parts.length !== 4) return null
|
||||||
|
const nums = parts.map((p) => Number(p.trim()))
|
||||||
|
if (nums.some((n) => !Number.isFinite(n))) return null
|
||||||
|
const [ax, ay, az, db] = nums
|
||||||
|
return { ax, ay, az, db, t: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a frame to a verdict against the tuned harness.
|
||||||
|
* Rule (pending firmware sign-off): magnitude is the raw acceleration vector
|
||||||
|
* length; critical requires BOTH the g-magnitude and the dB level to exceed
|
||||||
|
* their thresholds, anomalous requires either, otherwise nominal.
|
||||||
|
*/
|
||||||
|
export function classifyFrame(f: ImuFrame, h: HarnessThresholds): SerialEvent {
|
||||||
|
const mag = Math.sqrt(f.ax * f.ax + f.ay * f.ay + f.az * f.az)
|
||||||
|
const overG = mag > h.thresholdG
|
||||||
|
const overDb = f.db > h.thresholdDb
|
||||||
|
if (overG && overDb) return 'critical'
|
||||||
|
if (overG || overDb) return 'anomalous'
|
||||||
|
return 'nominal'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockOptions {
|
||||||
|
intervalMs?: number
|
||||||
|
// deterministic synthetic frame generator keyed by tick count
|
||||||
|
frameAt?: (tick: number) => ImuFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockConn(opts: MockOptions = {}): SerialConn {
|
||||||
|
const intervalMs = opts.intervalMs ?? 500
|
||||||
|
const frameAt =
|
||||||
|
opts.frameAt ??
|
||||||
|
((tick: number): ImuFrame => {
|
||||||
|
// Frames are gravity-compensated (linear) acceleration, so rest sits near
|
||||||
|
// zero. Mostly-nominal wander with a periodic event every 7th tick — a
|
||||||
|
// lively-but-deterministic demo stream when no board is attached.
|
||||||
|
const spike = tick % 7 === 0
|
||||||
|
const wobble = Math.sin(tick / 3) * 0.08
|
||||||
|
return {
|
||||||
|
ax: spike ? 1.4 : 0.06 + wobble,
|
||||||
|
ay: spike ? 0.5 : wobble / 2,
|
||||||
|
az: spike ? 0.3 : 0.04 + wobble / 4,
|
||||||
|
db: spike ? 72 : 40 + (tick % 4),
|
||||||
|
t: tick,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const subs = new Set<(f: ImuFrame) => void>()
|
||||||
|
let tick = 0
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const f = frameAt(++tick)
|
||||||
|
subs.forEach((cb) => cb(f))
|
||||||
|
}, intervalMs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: MOCK_PORT,
|
||||||
|
mocked: true,
|
||||||
|
onFrame(cb) {
|
||||||
|
subs.add(cb)
|
||||||
|
return () => subs.delete(cb)
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
clearInterval(timer)
|
||||||
|
subs.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRealConn(): Promise<SerialConn> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const serial = (navigator as any).serial
|
||||||
|
const port = await serial.requestPort()
|
||||||
|
await port.open({ baudRate: BAUD_RATE })
|
||||||
|
|
||||||
|
const subs = new Set<(f: ImuFrame) => void>()
|
||||||
|
let closed = false
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let reader: any = null
|
||||||
|
|
||||||
|
const pump = async () => {
|
||||||
|
const decoder = new TextDecoderStream()
|
||||||
|
const readable = port.readable.pipeThrough(decoder)
|
||||||
|
reader = readable.getReader()
|
||||||
|
let buf = ''
|
||||||
|
try {
|
||||||
|
while (!closed) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
buf += value
|
||||||
|
let nl: number
|
||||||
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, nl)
|
||||||
|
buf = buf.slice(nl + 1)
|
||||||
|
const frame = parseLine(line)
|
||||||
|
if (frame) {
|
||||||
|
frame.t = Date.now()
|
||||||
|
subs.forEach((cb) => cb(frame))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// read loop ended (port closed / unplugged) — surfaced via close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void pump()
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: typeof port.getInfo === 'function' ? `usb-serial://uno-r4-wifi` : 'usb-serial',
|
||||||
|
mocked: false,
|
||||||
|
onFrame(cb) {
|
||||||
|
subs.add(cb)
|
||||||
|
return () => subs.delete(cb)
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
closed = true
|
||||||
|
subs.clear()
|
||||||
|
try {
|
||||||
|
await reader?.cancel()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await port.close()
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a serial connection. Uses the real Web Serial API when available
|
||||||
|
* (prompts the user to pick the board), otherwise returns a deterministic
|
||||||
|
* mock connection so tests and unsupported browsers still drive the flow.
|
||||||
|
*/
|
||||||
|
export async function requestPort(opts?: MockOptions): Promise<SerialConn> {
|
||||||
|
if (!serialSupported()) return createMockConn(opts)
|
||||||
|
try {
|
||||||
|
return await createRealConn()
|
||||||
|
} catch {
|
||||||
|
// user cancelled the port picker or open failed → fall back to mock
|
||||||
|
return createMockConn(opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { makeSubmissionCode } from './submission'
|
||||||
|
import type { Team, AddLayers } from '@/store/session'
|
||||||
|
|
||||||
|
const team: Team = { name: 'team_resonance', members: ['A', 'B'], kit: 'KIT-07' }
|
||||||
|
const add: AddLayers = { L1: { goal: 'x' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' }
|
||||||
|
|
||||||
|
describe('makeSubmissionCode', () => {
|
||||||
|
it('is prefixed with the team kit', () => {
|
||||||
|
expect(makeSubmissionCode(team, add)).toMatch(/^KIT-07-/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic for the same team + ADD', () => {
|
||||||
|
expect(makeSubmissionCode(team, add)).toBe(makeSubmissionCode(team, add))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes when the ADD content changes', () => {
|
||||||
|
const changed = makeSubmissionCode(team, { ...add, L4: 'different failure mode' })
|
||||||
|
expect(changed).not.toBe(makeSubmissionCode(team, add))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changes when the team changes', () => {
|
||||||
|
const other = makeSubmissionCode({ ...team, name: 'team_other' }, add)
|
||||||
|
expect(other).not.toBe(makeSubmissionCode(team, add))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Team, AddLayers } from '@/store/session'
|
||||||
|
|
||||||
|
/** Small, stable string hash (djb2 → base36). */
|
||||||
|
function hash(input: string): string {
|
||||||
|
let h = 5381
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
h = ((h << 5) + h + input.charCodeAt(i)) >>> 0
|
||||||
|
}
|
||||||
|
return h.toString(36).toUpperCase().padStart(7, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic submission code derived from the team and the full ADD.
|
||||||
|
* Same inputs → same code, so a team that re-submits an unchanged document
|
||||||
|
* gets a stable identifier; any edit produces a new one.
|
||||||
|
*/
|
||||||
|
export function makeSubmissionCode(team: Team, add: AddLayers): string {
|
||||||
|
const payload = JSON.stringify({ name: team.name, add })
|
||||||
|
return `${team.kit}-${hash(payload)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import { useSerial } from './useSerial'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
|
||||||
|
describe('useSerial', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSession.getState().reset()
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
it('connects via the mock fallback and reports mocked state', async () => {
|
||||||
|
const { result } = renderHook(() => useSerial())
|
||||||
|
expect(result.current.supported).toBe(false)
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect()
|
||||||
|
})
|
||||||
|
expect(result.current.connected).toBe(true)
|
||||||
|
expect(result.current.mocked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('streams frames into stats via recordEvent', async () => {
|
||||||
|
const { result } = renderHook(() => useSerial())
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect()
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
})
|
||||||
|
expect(useSession.getState().stats.calls).toBeGreaterThan(0)
|
||||||
|
expect(result.current.last).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inject() classifies against the live tuned harness', async () => {
|
||||||
|
const { result } = renderHook(() => useSerial())
|
||||||
|
useSession.getState().setHarness({ thresholdG: 0.5, thresholdDb: 50 })
|
||||||
|
act(() => {
|
||||||
|
result.current.inject({ ax: 2, ay: 0, az: 0, db: 90, t: 1 })
|
||||||
|
})
|
||||||
|
expect(useSession.getState().stats.critical).toBe(1)
|
||||||
|
expect(useSession.getState().stats.calls).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSession } from '@/store/session'
|
||||||
|
import {
|
||||||
|
requestPort,
|
||||||
|
classifyFrame,
|
||||||
|
serialSupported,
|
||||||
|
type SerialConn,
|
||||||
|
type ImuFrame,
|
||||||
|
} from './serial'
|
||||||
|
|
||||||
|
const FRAME_BUFFER = 50
|
||||||
|
|
||||||
|
export interface UseSerial {
|
||||||
|
supported: boolean
|
||||||
|
connected: boolean
|
||||||
|
mocked: boolean
|
||||||
|
last: ImuFrame | null
|
||||||
|
frames: ImuFrame[]
|
||||||
|
connect: () => Promise<SerialConn>
|
||||||
|
disconnect: () => Promise<void>
|
||||||
|
/** push a synthetic frame through the same classify→record path (test triggers) */
|
||||||
|
inject: (f: ImuFrame) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React binding over the serial bridge. Opens a connection on demand, keeps a
|
||||||
|
* rolling frame buffer, and records each frame's verdict into the session
|
||||||
|
* `stats` using the *live* harness thresholds (read at emit time, so Module 2's
|
||||||
|
* tuning takes effect immediately). Pages consume this; never `navigator.serial`.
|
||||||
|
*/
|
||||||
|
export function useSerial(): UseSerial {
|
||||||
|
const recordEvent = useSession((s) => s.recordEvent)
|
||||||
|
const connRef = useRef<SerialConn | null>(null)
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [mocked, setMocked] = useState(false)
|
||||||
|
const [last, setLast] = useState<ImuFrame | null>(null)
|
||||||
|
const [frames, setFrames] = useState<ImuFrame[]>([])
|
||||||
|
|
||||||
|
const handleFrame = useCallback(
|
||||||
|
(f: ImuFrame) => {
|
||||||
|
setLast(f)
|
||||||
|
setFrames((prev) => [...prev.slice(-(FRAME_BUFFER - 1)), f])
|
||||||
|
// read the harness fresh so tuned thresholds apply without re-subscribing
|
||||||
|
recordEvent(classifyFrame(f, useSession.getState().harness))
|
||||||
|
},
|
||||||
|
[recordEvent],
|
||||||
|
)
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
if (connRef.current) return connRef.current
|
||||||
|
const conn = await requestPort()
|
||||||
|
connRef.current = conn
|
||||||
|
conn.onFrame(handleFrame)
|
||||||
|
setConnected(true)
|
||||||
|
setMocked(conn.mocked)
|
||||||
|
return conn
|
||||||
|
}, [handleFrame])
|
||||||
|
|
||||||
|
const disconnect = useCallback(async () => {
|
||||||
|
await connRef.current?.close()
|
||||||
|
connRef.current = null
|
||||||
|
setConnected(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const inject = useCallback((f: ImuFrame) => handleFrame(f), [handleFrame])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
void connRef.current?.close()
|
||||||
|
connRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: serialSupported(),
|
||||||
|
connected,
|
||||||
|
mocked,
|
||||||
|
last,
|
||||||
|
frames,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
inject,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { MemberChips } from '@/components/MemberChips'
|
|||||||
import { KitSelector } from '@/components/KitSelector'
|
import { KitSelector } from '@/components/KitSelector'
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||||
import { useSession } from '@/store/session'
|
import { useSession } from '@/store/session'
|
||||||
|
import { requestPort, serialSupported } from '@/lib/serial'
|
||||||
|
|
||||||
export function TeamRegistration() {
|
export function TeamRegistration() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -30,8 +31,15 @@ export function TeamRegistration() {
|
|||||||
navigate('/workshop/setup')
|
navigate('/workshop/setup')
|
||||||
}
|
}
|
||||||
|
|
||||||
const onConnect = () => {
|
const onConnect = async () => {
|
||||||
|
// Without Web Serial (tests, unsupported browsers) connect synchronously to
|
||||||
|
// the mock so the flow stays usable; otherwise prompt for the real board.
|
||||||
|
if (!serialSupported()) {
|
||||||
setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 })
|
setDevice({ connected: true, port: 'mock-serial://uno-r4-wifi', uptimeS: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const conn = await requestPort()
|
||||||
|
setDevice({ connected: true, port: conn.port, uptimeS: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +130,8 @@ export function TeamRegistration() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
|
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
|
||||||
Web Serial wiring lands in the next slice; this stub flips the device flag so flow tests pass.
|
Uses the browser's Web Serial API to talk to the board over USB. Falls back to a
|
||||||
|
simulated device on browsers without Web Serial support.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user