// 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) => (
| {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 = '' }}
>
| {fw.bespoke_uid || '—'} |
{fw.version} |
{fw.channel} |
{fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : '—'} |
)
})}
)}
)
}
// ─── 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 */}
{/* Bespoke divider */}
{/* 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) => (
| {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 = '' }}
>
| {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 && (
)}
{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 && (
)}
)
}