// frontend/src/pages/manufacturing/ProvisioningWizard.jsx // Provisions an ESP32 board via WebSerial + esptool-js. // Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done import { useState, useRef, useCallback, useEffect } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { ESPLoader, Transport } from 'esptool-js' import api from '@/lib/api' import PageHeader from '@/components/ui/PageHeader' import Button from '@/components/ui/Button' import Modal from '@/components/ui/Modal' import StatusBadge from '@/components/ui/StatusBadge' import SearchBar from '@/components/ui/SearchBar' import Spinner from '@/components/ui/Spinner' import Icon from '@/components/ui/Icon' // ─── Constants ──────────────────────────────────────────────────────────────── const BOARD_TYPES = [ { value: 'vesper_pro', name: 'VESPER PRO', uiName: 'Vesper Pro', codename: 'vesper-pro', desc: 'Full-featured pro controller', family: 'vesper' }, { value: 'vesper_plus', name: 'VESPER PLUS', uiName: 'Vesper Plus', codename: 'vesper-plus', desc: 'Extended output controller', family: 'vesper' }, { value: 'vesper', name: 'VESPER', uiName: 'Vesper', codename: 'vesper-basic', desc: 'Standard bell controller', family: 'vesper' }, { value: 'agnus', name: 'AGNUS', uiName: 'Agnus', codename: 'agnus-basic', desc: 'Standard carillon module', family: 'agnus' }, { value: 'agnus_mini', name: 'AGNUS MINI', uiName: 'Agnus Mini', codename: 'agnus-mini', desc: 'Compact carillon module', family: 'agnus' }, { value: 'chronos_pro', name: 'CHRONOS PRO', uiName: 'Chronos Pro', codename: 'chronos-pro', desc: 'Pro clock controller', family: 'chronos' }, { value: 'chronos', name: 'CHRONOS', uiName: 'Chronos', codename: 'chronos-basic', desc: 'Basic clock controller', family: 'chronos' }, ] const BOARD_TYPE_MAP = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b])) const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name])) // Color palette per board family (idle → selected → hover glow) const BOARD_FAMILY_COLORS = { vesper: { selectedBg: '#0a1929', selectedBorder: '#3b82f6', selectedText: '#60a5fa', hoverBorder: '#3b82f6', glowColor: 'rgba(59,130,246,0.35)', idleBorder: '#1d3a5c', idleText: '#7ca8d4', }, agnus: { selectedBg: '#1a1400', selectedBorder: '#f59e0b', selectedText: '#fbbf24', hoverBorder: '#f59e0b', glowColor: 'rgba(245,158,11,0.35)', idleBorder: '#4a3800', idleText: '#c79d3a', }, chronos: { selectedBg: '#1a0808', selectedBorder: '#ef4444', selectedText: '#f87171', hoverBorder: '#ef4444', glowColor: 'rgba(239,68,68,0.35)', idleBorder: '#5c1a1a', idleText: '#d47a7a', }, } const STATUS_VARIANT = { manufactured: 'neutral', flashed: 'info', provisioned: 'warning', sold: 'success', claimed: 'success', decommissioned: 'danger', } const FLASHABLE_STATUSES = ['manufactured', 'flashed', 'provisioned'] const FLASH_BAUD = 460800 const NVS_ADDRESS = 0x9000 const FW_ADDRESS = 0x10000 const VERIFY_POLL_MS = 5000 const VERIFY_TIMEOUT_MS = 120_000 // ─── Helpers ────────────────────────────────────────────────────────────────── function formatHwVersion(v) { if (!v) return '—' if (/^\d+\.\d+/.test(v)) return `Rev ${v}` const n = parseInt(v, 10) return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0` } // ─── Step Indicator ─────────────────────────────────────────────────────────── const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done'] function CheckeredFlagIcon() { return ( ) } function StepIndicator({ current }) { return (
{STEP_LABELS.map((label, i) => { const idx = i + 1 const done = idx < current const active = idx === current const pending = idx > current const isLast = i === STEP_LABELS.length - 1 const dotBg = done ? '#53b15786' : active ? '#22c55e' : '#251a1a' const dotColor = done ? '#cbedb9' : active ? '#ffffff' : '#555' const labelColor = active ? '#22c55e' : done ? '#53b15786' : '#555' const labelGlow = active ? '0 0 8px rgba(34,197,94,0.45)' : 'none' const lineColor = done ? '#22c55e' : 'var(--color-border)' const dotSize = active ? 38 : 30 return (
{done ? ( ) : isLast ? : idx}
{label}
{i < STEP_LABELS.length - 1 && (
)}
) })}
) } // ─── Board Tile ─────────────────────────────────────────────────────────────── function BoardTile({ bt, isSelected, onClick }) { const [hovered, setHovered] = useState(false) const pal = BOARD_FAMILY_COLORS[bt.family] const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder const boxShadow = isSelected ? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}` : hovered ? `0 0 12px 3px ${pal.glowColor}` : 'none' return ( ) } // ─── Progress Bar ───────────────────────────────────────────────────────────── function ProgressBar({ label, percent, flex = false }) { return (
{label} {Math.round(percent)}%
0 ? 'var(--shadow-primary-glow)' : 'none', }} />
) } // ─── Info Cell ──────────────────────────────────────────────────────────────── function InfoCell({ label, value, mono = false }) { return (

{label}

{typeof value === 'string' ?

{value || '—'}

: value}
) } // ─── Error Box ──────────────────────────────────────────────────────────────── function ErrorBox({ msg }) { if (!msg) return null return (
{msg}
) } // ─── Bespoke Picker Modal ───────────────────────────────────────────────────── function BespokePickerModal({ open, onConfirm, onClose }) { const [firmwares, setFirmwares] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [selected, setSelected] = useState(null) const [hwFamily, setHwFamily] = useState('') useEffect(() => { if (!open) return setLoading(true) api.get('/firmware?hw_type=bespoke') .then((data) => setFirmwares(data.firmware || [])) .catch((err) => setError(err.message)) .finally(() => setLoading(false)) }, [open]) const handleConfirm = () => { if (!selected) return onConfirm({ firmware: selected, hwFamily }) } return (

hw_revision will be set to 1.0 for bespoke devices.

} >

Choose a bespoke firmware and the hardware family to register in NVS.

{loading &&
} {error && } {!loading && !error && firmwares.length === 0 && (

No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.

)} {firmwares.length > 0 && (
{['UID', 'Version', 'Channel', 'Size'].map((h) => ( ))} {firmwares.map((fw) => { const isSel = selected?.id === fw.id return ( setSelected(fw)} style={{ borderBottom: '1px solid var(--color-border)', cursor: 'pointer', transition: 'background-color 0.1s', backgroundColor: isSel ? 'var(--color-info-bg)' : '' }} onMouseEnter={(e) => { if (!isSel) e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }} onMouseLeave={(e) => { if (!isSel) e.currentTarget.style.backgroundColor = '' }} > ) })}
{h}
{fw.bespoke_uid || '—'} {fw.version} {fw.channel} {fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : '—'}
)}

Written to NVS as hw_type.

setHwFamily(e.target.value)} placeholder="e.g. vesper_plus" style={{ width: '100%', padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-input)', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-sm)' }} />
) } // ─── Step 0: Mode Picker ────────────────────────────────────────────────────── function StepMode({ onPick }) { return (

What would you like to do?

Choose how to start the provisioning process.

{/* Flash Existing */} {/* Deploy New Device */}
) } // ─── Step 1: Select / Create Device ────────────────────────────────────────── function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) { const [search, setSearch] = useState(preloadSn || '') const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [picked, setPicked] = useState(null) // New device form const [boardType, setBoardType] = useState(null) const [boardVersion, setBoardVersion] = useState('1.0') const [creating, setCreating] = useState(false) const [showBespoke, setShowBespoke] = useState(false) const doSearch = useCallback(async (q) => { setLoading(true); setError('') try { const params = new URLSearchParams({ limit: '50' }) if (q) params.set('search', q) const data = await api.get(`/manufacturing/devices?${params}`) const all = data.devices || [] setResults(all.filter((d) => FLASHABLE_STATUSES.includes(d.mfg_status))) } catch (err) { setError(err.message) } finally { setLoading(false) } }, []) useEffect(() => { if (mode === 'existing') doSearch(preloadSn || '') }, [mode, doSearch, preloadSn]) const handleCreate = async () => { if (!boardType) return setCreating(true); setError('') try { const batch = await api.post('/manufacturing/batch', { board_type: boardType, board_version: boardVersion.trim(), quantity: 1 }) const sn = batch.serial_numbers[0] onCreatedSn(sn) const device = await api.get(`/manufacturing/devices/${sn}`) onSelected(device) } catch (err) { setError(err.message) } finally { setCreating(false) } } const handleBespokeConfirm = async ({ firmware, hwFamily }) => { setShowBespoke(false) setCreating(true); setError('') try { const batch = await api.post('/manufacturing/batch', { board_type: 'vesper', board_version: '1.0', quantity: 1 }) const sn = batch.serial_numbers[0] onCreatedSn(sn) const device = await api.get(`/manufacturing/devices/${sn}`) onSelected(device, { firmware, hwFamily }) } catch (err) { setError(err.message) } finally { setCreating(false) } } // ── Selected confirmation panel ── if (picked) { const boardInfo = BOARD_TYPE_MAP[picked.hw_type] const pal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper'] return (

Device Selected

{picked.mfg_status}} />
) } // ── Deploy New form ── if (mode === 'new') { const vesperBoards = BOARD_TYPES.filter((b) => b.family === 'vesper') const agnusBoards = BOARD_TYPES.filter((b) => b.family === 'agnus') const chronosBoards = BOARD_TYPES.filter((b) => b.family === 'chronos') return (
{/* Board type */}

Board Type

{/* Vesper row — 3 equal columns */}
{vesperBoards.map((bt) => ( setBoardType(bt.value)} /> ))}
{/* Agnus row — 2 boards centered inside the same 3-col grid */}
{agnusBoards.map((bt) => ( setBoardType(bt.value)} /> ))}
{/* Chronos row — 2 boards centered inside the same 3-col grid */}
{chronosBoards.map((bt) => ( setBoardType(bt.value)} /> ))}
{/* Board revision — narrow */}
Rev setBoardVersion(e.target.value)} placeholder="1.0" style={{ width: 72, padding: 'var(--space-2) var(--space-2)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-input)', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-sm)', textAlign: 'center', }} />

e.g. 1.0, 1.20

{/* Bespoke divider */}
or
{/* Bespoke option */}

Select Bespoke Firmware

Flash a one-off bespoke firmware with a custom hardware family written to NVS.

setShowBespoke(false)} />
) } // ── Existing device search ── return (
{ setSearch(v); doSearch(v) }} placeholder="Search serial, batch, type…" /> {error && } {loading ? (
) : results.length === 0 ? (

No flashable devices found.

) : (
{['Serial', 'Type', 'Revision', 'Status'].map((h) => ( ))} {results.map((d, i) => ( setPicked(d)} style={{ borderBottom: i < results.length - 1 ? '1px solid var(--color-border)' : 'none', cursor: 'pointer', transition: 'background-color 0.1s' }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)' }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '' }} > ))}
{h}
{d.serial_number} {BOARD_TYPE_LABELS[d.hw_type] || d.hw_type} {formatHwVersion(d.hw_version)} {d.mfg_status}
)}
) } // ─── Serial Log Modal ────────────────────────────────────────────────────────── function SerialLogModal({ open, onClose, logs }) { const [autoScroll, setAutoScroll] = useState(true) const endRef = useRef(null) useEffect(() => { if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [logs, autoScroll, open]) return (
} >
{logs.length === 0 ? ( No serial output yet. ) : ( logs.map((line, i) =>
{line}
) )}
) } // ─── Step 2: Flash ──────────────────────────────────────────────────────────── function StepFlash({ device, bespokeOverride, onFlashed }) { const [portConnected, setPortConnected] = useState(false) const [portName, setPortName] = useState('') const [connecting, setConnecting] = useState(false) const [flashing, setFlashing] = useState(false) const [done, setDone] = useState(false) const [blProgress, setBlProgress] = useState(0) const [partProgress, setPartProgress] = useState(0) const [nvsProgress, setNvsProgress] = useState(0) const [fwProgress, setFwProgress] = useState(0) const [log, setLog] = useState([]) const [serial, setSerial] = useState([]) const [nvsProfile, setNvsProfile] = useState('current') const [error, setError] = useState('') const loaderRef = useRef(null) const portRef = useRef(null) const serialReaderRef = useRef(null) const serialActiveRef = useRef(false) const logEndRef = useRef(null) const serialEndRef = useRef(null) const appendLog = (msg) => setLog((prev) => [...prev, String(msg)]) const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)]) const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' }) const fetchBinary = async (url) => { const token = localStorage.getItem('access_token') const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) if (!resp.ok) { const err = await resp.json().catch(() => ({})) throw new Error(err.detail || `Failed to fetch ${url}: ${resp.status}`) } return resp.arrayBuffer() } const arrayBufferToString = (buf) => { const bytes = new Uint8Array(buf) let str = '' for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]) return str } const startSerialMonitor = async (port) => { serialActiveRef.current = true await new Promise((r) => setTimeout(r, 1000)) try { await port.open({ baudRate: 115200 }) } catch (openErr) { appendSerial(`[Error opening port: ${openErr.message}]`); scrollSerial(); return } let reader try { reader = port.readable.getReader() } catch (readerErr) { appendSerial(`[Error getting reader: ${readerErr.message}]`); scrollSerial() try { await port.close() } catch (_) {} return } serialReaderRef.current = reader const dec = new TextDecoder() let buf = '' try { while (serialActiveRef.current) { const { value, done: streamDone } = await reader.read() if (streamDone) break buf += dec.decode(value, { stream: true }) const lines = buf.split(/\r?\n/) buf = lines.pop() for (const line of lines) { if (line.trim()) { appendSerial(line); scrollSerial() } } } } catch (_) {} finally { try { reader.releaseLock() } catch (_) {} } } const disconnectPort = async () => { serialActiveRef.current = false try { await serialReaderRef.current?.cancel() } catch (_) {} try { serialReaderRef.current?.releaseLock() } catch (_) {} try { await portRef.current?.close() } catch (_) {} portRef.current = null setPortConnected(false) setPortName('') appendSerial('[Port disconnected]') } const handleConnectPort = async () => { setError(''); setConnecting(true) try { const port = await navigator.serial.requestPort() portRef.current = port const info = port.getInfo?.() || {} const label = info.usbVendorId ? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}` : 'Serial Port' setPortName(label) setPortConnected(true) } catch (err) { setError(err.message || 'Port selection cancelled.') } finally { setConnecting(false) } } const handleStartFlash = async () => { if (!portRef.current) return setError(''); setLog([]); setSerial([]) setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0) setDone(false) const port = portRef.current const sn = device.serial_number try { const blUrl = bespokeOverride ? `/api/manufacturing/devices/${sn}/bootloader.bin?hw_type_override=${bespokeOverride.hwFamily}` : `/api/manufacturing/devices/${sn}/bootloader.bin` const partUrl = bespokeOverride ? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}` : `/api/manufacturing/devices/${sn}/partitions.bin` const nvsUrl = bespokeOverride ? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0&nvs_schema=${nvsProfile}` : `/api/manufacturing/devices/${sn}/nvs.bin?nvs_schema=${nvsProfile}` const fwUrl = bespokeOverride ? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin` : `/api/manufacturing/devices/${sn}/firmware.bin` appendLog('Fetching bootloader binary…') const blBuffer = await fetchBinary(blUrl); appendLog(`Bootloader: ${blBuffer.byteLength} bytes`) appendLog('Fetching partition table binary…') const partBuffer = await fetchBinary(partUrl); appendLog(`Partition table: ${partBuffer.byteLength} bytes`) appendLog('Fetching NVS binary…') const nvsBuffer = await fetchBinary(nvsUrl); appendLog(`NVS: ${nvsBuffer.byteLength} bytes`) appendLog('Fetching firmware binary…') const fwBuffer = await fetchBinary(fwUrl); appendLog(`Firmware: ${fwBuffer.byteLength} bytes`) setFlashing(true) appendLog('Connecting to ESP32…') const transport = new Transport(port, true) loaderRef.current = new ESPLoader({ transport, baudrate: FLASH_BAUD, terminal: { clean() {}, writeLine: (line) => { appendLog(line); scrollLog() }, write: (msg) => { appendLog(msg); scrollLog() }, }, }) await loaderRef.current.main() appendLog('ESP32 connected.') await loaderRef.current.writeFlash({ fileArray: [ { data: arrayBufferToString(blBuffer), address: 0x1000 }, { data: arrayBufferToString(partBuffer), address: 0x8000 }, { data: arrayBufferToString(nvsBuffer), address: NVS_ADDRESS }, { data: arrayBufferToString(fwBuffer), address: FW_ADDRESS }, ], flashSize: 'keep', flashMode: 'keep', flashFreq: 'keep', eraseAll: false, compress: true, reportProgress(fileIndex, written, total) { const pct = (written / total) * 100 if (fileIndex === 0) { setBlProgress(pct) } else if (fileIndex === 1) { setBlProgress(100); setPartProgress(pct) } else if (fileIndex === 2) { setPartProgress(100); setNvsProgress(pct) } else { setNvsProgress(100); setFwProgress(pct) } }, calculateMD5Hash: () => '', }) setBlProgress(100); setPartProgress(100); setNvsProgress(100); setFwProgress(100) appendLog('Flash complete. Resetting device…') try { const t = loaderRef.current.transport await t.setRTS(true); await new Promise((r) => setTimeout(r, 100)) await t.setRTS(false); await new Promise((r) => setTimeout(r, 100)) } catch (rstErr) { appendLog(`[Reset warning: ${rstErr.message}]`) } appendLog('Hard reset sent. Device is booting…') try { await loaderRef.current.transport.disconnect() } catch (_) {} appendLog('esptool disconnected. Opening serial monitor…') await api.request(`/manufacturing/devices/${sn}/status`, { method: 'PATCH', body: JSON.stringify({ status: 'flashed', note: 'Flashed via browser provisioning wizard' }), }) setFlashing(false) setDone(true) appendSerial('── Serial monitor started (115200 baud) ──') startSerialMonitor(port) } catch (err) { setError(err.message || String(err)) setFlashing(false) try { await loaderRef.current?.transport?.disconnect() } catch (_) {} } } const webSerialAvailable = typeof navigator !== 'undefined' && 'serial' in navigator const busy = connecting || flashing const boardInfo = BOARD_TYPE_MAP[device.hw_type] const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper'] // ── Left panel: device info + controls ──────────────────────────────────── const InfoPanel = (
{/* Header: title + COM status button */}

Device to Flash

{/* Device info */}
{bespokeOverride ? (

Firmware

BESPOKE · {bespokeOverride.firmware.bespoke_uid}

v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}

) : ( <> {/* Row 1: serial number + status badge */}

{device.serial_number}

{device.mfg_status}
{/* Row 2: board type + codename */}

Board Type: {boardInfo?.uiName || boardInfo?.name || device.hw_type} {boardInfo?.codename && ( | {boardInfo.codename} )}

{/* Row 3: revision */}

Revision: {formatHwVersion(device.hw_version)}

)}
{/* NVS profile toggle */}
{[['current', 'Current Gen NVS'], ['legacy', 'Legacy NVS']].map(([val, lbl]) => ( ))}
{!webSerialAvailable && (
Web Serial API not available. Use Chrome or Edge on a desktop system.
)} {error &&
} {/* Progress bars — always visible, idle at 0% */}
{/* Spacer */}
{/* Bottom bar */}
{/* Left: status hint */}
{portConnected && !flashing && !done && log.length === 0 && (
Ready to flash.
)} {flashing && (

Flashing — do not disconnect…

)}

NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud

{/* Right: action buttons */} {!busy && (
{!portConnected && ( )} {portConnected && done && ( )} {done && ( )} {portConnected && !done && ( )}
)} {busy && (
)}
) // ── Right panel: flash output log ───────────────────────────────────────── const FlashOutputPanel = (
Flash Output
{log.length === 0 ? {flashing ? 'Connecting…' : 'Output will appear here once flashing starts.'} : log.map((line, i) =>
{line}
) }
) return (
{/* Info panel (left) | Flash output (right) */}
{InfoPanel} {FlashOutputPanel}
) } // ─── Step 3: Verify ─────────────────────────────────────────────────────────── function StepVerify({ device, onVerified }) { const [polling, setPolling] = useState(false) const [timedOut, setTimedOut] = useState(false) const [verified, setVerified] = useState(false) const [heartbeatData, setHeartbeatData] = useState(null) const [error, setError] = useState('') const intervalRef = useRef(null) const timeoutRef = useRef(null) const startPolling = useCallback(() => { if (polling) return setPolling(true); setTimedOut(false); setError('') const startTime = Date.now() intervalRef.current = setInterval(async () => { try { const hbData = await api.get(`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`) if (hbData.heartbeats?.length > 0) { const latest = hbData.heartbeats[0] const receivedMs = latest.received_at ? Date.parse(latest.received_at.replace(' ', 'T') + 'Z') : NaN if (!isNaN(receivedMs) && receivedMs > startTime) { clearInterval(intervalRef.current) clearTimeout(timeoutRef.current) try { await api.request(`/manufacturing/devices/${device.serial_number}/status`, { method: 'PATCH', body: JSON.stringify({ status: 'provisioned', note: 'Auto-verified via wizard' }), }) } catch (_) {} const deviceData = await api.get(`/manufacturing/devices/${device.serial_number}`) setHeartbeatData(latest) setPolling(false) setVerified(true) onVerified({ ...deviceData, mfg_status: 'provisioned' }) } } } catch (err) { setError(err.message) } }, VERIFY_POLL_MS) timeoutRef.current = setTimeout(() => { clearInterval(intervalRef.current) setPolling(false); setTimedOut(true) }, VERIFY_TIMEOUT_MS) }, [polling, device.serial_number, onVerified]) useEffect(() => { startPolling() return () => { clearInterval(intervalRef.current); clearTimeout(timeoutRef.current) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const stopPolling = () => { clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false) } return (

Waiting for Device

{polling && !verified && (

Waiting for device to connect…
Power cycle the device and ensure it can reach the MQTT broker.

)} {verified && heartbeatData && (

Device is live!

)} {timedOut && !verified && (
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
)} {error && !timedOut && !verified && (
)}

Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s

) } // ─── Step 4: Done ───────────────────────────────────────────────────────────── function StepDone({ device, onProvisionNext }) { const navigate = useNavigate() const [showSerialLogs, setShowSerialLogs] = useState(false) return (

Device Provisioned

{device?.serial_number} is live.

{/* Device summary grid */}

Serial Number

{device?.serial_number}

Status

{device?.mfg_status}

Board Type

{BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type}

HW Version

{formatHwVersion(device?.hw_version)}

setShowSerialLogs(false)} logs={[]} />
) } // ─── Main Wizard ────────────────────────────────────────────────────────────── // Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done export default function ProvisioningWizard() { const navigate = useNavigate() const [searchParams] = useSearchParams() const preloadSn = searchParams.get('sn') || '' const [step, setStep] = useState(preloadSn ? 1 : 0) const [mode, setMode] = useState(preloadSn ? 'existing' : null) const [device, setDevice] = useState(null) const [bespokeOverride, setBespokeOverride] = useState(null) const createdSnRef = useRef(null) const handleModePick = (m) => { setMode(m); setStep(1) } const handleDeviceSelected = (d, bespoke = null) => { setBespokeOverride(bespoke) setDevice(d) setStep(2) } const handleCreatedSn = (sn) => { createdSnRef.current = sn } const handleFlashed = () => { setStep(3) } const handleVerified = (updatedDevice) => { setDevice(updatedDevice); setStep(4) } const handleProvisionNext = () => { setStep(0); setMode(null); setDevice(null); setBespokeOverride(null); createdSnRef.current = null } return (
{/* Step indicator */}
{/* Step content — floating on page background, no card wrapper */}
{step === 0 && } {step === 1 && ( )} {step === 2 && device && ( )} {step === 3 && device && ( )} {step === 4 && device && ( )}
) }