// 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, useMemo } 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'
import { useSerialConnection } from '@/hooks/useSerialConnection'
// ─── 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`
}
// Parses one line of serial output from Logging.cpp's fixed format:
// 🔴 [ERROR][TagName] message | 🟡 [WARN][TagName] message | etc.
// The leading icon may or may not survive the terminal's encoding, so the
// match is anchored on the bracketed level/tag text instead. Anything that
// doesn't match (ROM/bootloader output, other libraries) is untagged system
// output — still shown, just without a level or tag chip.
const SERIAL_LEVEL_MAP = { ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBG: 'DEBUG', VERB: 'VERBOSE' }
const SERIAL_LINE_RE = /^(?:\S+\s+)?\[(ERROR|WARN|INFO|DEBG|VERB)\]\[([^\]]+)\]\s*(.*)$/
function parseSerialLine(raw) {
const m = SERIAL_LINE_RE.exec(raw)
if (!m) return { level: null, tag: null, message: raw }
return { level: SERIAL_LEVEL_MAP[m[1]] || null, tag: m[2], message: m[3] }
}
// ─── 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 (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
aria-pressed={isSelected}
style={{
backgroundColor: isSelected ? pal.selectedBg : 'var(--color-bg-elevated)',
border: `1px solid ${borderColor}`,
borderRadius: 'var(--radius-md)',
padding: 'var(--space-3)',
textAlign: 'left', cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, background-color 0.15s',
boxShadow,
width: '100%',
}}
>
{bt.name}
{bt.codename}
{bt.desc}
)
}
// ─── 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.
Cancel
Continue
}
>
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 */}
onPick('existing')}
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-5)',
textAlign: 'left', cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-primary)'
e.currentTarget.style.boxShadow = '0 0 16px rgba(34,197,94,0.18)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.boxShadow = 'none'
}}
>
Flash Existing
Re-flash a device already in inventory — manufactured, flashed, or provisioned.
{/* Deploy New Device */}
onPick('new')}
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-5)',
textAlign: 'left', cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-success)'
e.currentTarget.style.boxShadow = '0 0 16px rgba(34,197,94,0.18)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.boxShadow = 'none'
}}
>
Deploy New Device
Generate a new serial number, select board type and revision, then flash and provision.
)
}
// ─── 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}} />
onSelected(picked)}>
Continue to Flash
setPicked(null)}>Change Device
)
}
// ── 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 */}
Generate Serial & Continue
{/* Bespoke divider */}
{/* Bespoke option */}
Select Bespoke Firmware
Flash a one-off bespoke firmware with a custom hardware family written to NVS.
setShowBespoke(true)} style={{ flexShrink: 0 }}>
Select Bespoke →
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 ──────────────────────────────────────────────────────────
// Reuses SerialLogPanel (level filters, search, live indicator — same as the
// Verify step's serial card) inside a near-fullscreen modal, since this is
// the "full history so far" view rather than a small at-a-glance card.
function SerialLogModal({ open, onClose, logs, monitoring }) {
if (!open) return null
return (
<>
{/* .modal-body is a plain padded/scrolling block by default (the shape
every other modal's content wants). This instance needs it to be a
non-scrolling flex column instead, so SerialLogPanel's own internal
scroll area is the only thing that scrolls and can stretch to fill
the near-fullscreen modal. */}
Close
}
>
>
)
}
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, bespokeOverride, onFlashed, serialConn }) {
const { portRef, portConnected, portName, requestPort, disconnect: disconnectPort, startMonitor, appendLine: appendSerial, clearLines: clearSerialLines } = serialConn
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 [nvsProfile, setNvsProfile] = useState('current')
const [error, setError] = useState('')
const loaderRef = useRef(null)
const logEndRef = useRef(null)
const infoPanelRef = useRef(null)
// Flash Output must follow the left (info) panel's height one-way — it
// should never influence the left panel's own height in return. CSS Grid's
// `align-items: stretch` computes the row height from BOTH cells' intrinsic
// sizes, so a tall flash log still grows the row (and the left panel with
// it). Measuring the left panel directly and applying that as an explicit
// pixel height on the right panel is the only way to make sizing one-way.
//
// Read getBoundingClientRect().height (the true border-box/visual height),
// NOT ResizeObserver's own contentRect — contentRect always excludes
// border+padding regardless of box-sizing, so applying it as a plain
// `height` on the (border-box) right panel came out 2px short (one
// border-width per side) and made the two cards visibly mismatched.
const [infoPanelHeight, setInfoPanelHeight] = useState(null)
useEffect(() => {
const el = infoPanelRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setInfoPanelHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
// Scrolling on the *next* render (after the DOM has the new line) rather
// than synchronously in the append call — scrolling immediately after
// setLog targets the sentinel's position from BEFORE the new line was
// committed, so the view lands one line short of the actual end.
useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [log])
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 handleConnectPort = async () => {
setError(''); setConnecting(true)
try {
await requestPort()
} catch (err) {
setError(err.message || 'Port selection cancelled.')
} finally {
setConnecting(false)
}
}
const handleStartFlash = async () => {
if (!portRef.current) return
setError(''); setLog([]); clearSerialLines()
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),
write: (msg) => appendLog(msg),
},
})
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) ──')
startMonitor(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']
const readyToFlash = portConnected && !flashing && !done && log.length === 0
// ── Left panel: device info + controls ────────────────────────────────────
const InfoPanel = (
{/* Header: title + COM status button */}
Device to Flash
{ if (portConnected) e.currentTarget.style.opacity = '0.75' }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1' }}
>
{portConnected ? portName || 'Connected' : 'No Port'}
{portConnected && ✕ }
{/* 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]) => (
setNvsProfile(val)}
style={{
padding: '2px 10px',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)',
cursor: 'pointer', border: 'none',
backgroundColor: nvsProfile === val ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
color: nvsProfile === val ? 'var(--color-bg-base)' : 'var(--color-text-muted)',
transition: 'background-color 0.15s, color 0.15s',
}}
>{lbl}
))}
{/* Web Serial warning — space always reserved so its appearance never
shifts panel height; hidden via visibility, not removed from flow. */}
Web Serial API not available. Use Chrome or Edge on a desktop system.
{/* Error box — same reserved-space treatment. */}
{/* Progress bars — always visible, idle at 0% */}
{/* Spacer */}
{/* Bottom bar */}
{/* Left: status hint — the "ready/flashing" line always occupies its
slot (visibility toggle, not conditional render) so this column's
height never changes as flash state advances. */}
{flashing ? 'Flashing — do not disconnect…' : 'Ready to flash.'}
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
{/* Right: action buttons — every possible button always renders (in a
fixed order/slot) so the row's height and the buttons' own sizes
never shift; only the applicable ones are visible at a time. */}
Select COM Port
Flash Again
Start Flashing
Proceed to Verify
)
// ── Right panel: flash output log ─────────────────────────────────────────
// Height is pinned to the measured InfoPanel height (one-way: left drives,
// right follows) — see infoPanelHeight/ResizeObserver above. minHeight: 0
// lets the log body's flex: 1 + overflowY: auto scroll internally within
// that fixed height instead of growing the panel (and the row) with it.
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) — align-items: start so the
row's height is never driven by the (explicitly-sized) right panel,
only ever by the left panel's natural content height. */}
{InfoPanel}
{FlashOutputPanel}
)
}
// ─── Serial Log Panel (Verify step's right panel) ─────────────────────────────
// Same stepped-severity filter pattern as LogsTab (frontend/src/pages/bellcloud/
// devices/tabs/LogsTab.jsx), applied to raw serial lines instead of MQTT rows.
const SERIAL_LEVEL_STEPS = [
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
{ value: 'INFO', label: 'INFO', color: 'var(--color-info)' },
{ value: 'WARN', label: 'WARN', color: 'var(--color-warning)' },
{ value: 'ERROR', label: 'ERROR', color: 'var(--color-danger)' },
]
const SERIAL_LEVEL_RANK = { VERBOSE: -1, DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }
const SERIAL_LEVEL_COLORS = {
ERROR: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
WARN: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
DEBUG: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' },
VERBOSE: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' },
}
function SerialLogPanel({ lines, monitoring, height }) {
const [levelStep, setLevelStep] = useState(0)
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef(null)
const levelFilter = SERIAL_LEVEL_STEPS[levelStep].value
const parsed = useMemo(() => lines.map((raw) => ({ raw, ...parseSerialLine(raw) })), [lines])
const filtered = useMemo(() => parsed.filter((l) => {
if (levelFilter) {
const rank = l.level ? SERIAL_LEVEL_RANK[l.level] : -1
if (rank < SERIAL_LEVEL_RANK[levelFilter]) return false
}
if (searchText && !l.raw.toLowerCase().includes(searchText.toLowerCase())) return false
return true
}), [parsed, levelFilter, searchText])
const endRef = useRef(null)
useEffect(() => {
if (autoScroll) endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [filtered, autoScroll])
return (
Serial Output
{monitoring && (
LIVE
)}
{SERIAL_LEVEL_STEPS.map((step, idx) => (
setLevelStep(idx)}
title={step.label}
style={{
height: 22, paddingInline: 'var(--space-2)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${levelStep === idx ? step.color : 'var(--color-border)'}`,
background: levelStep === idx ? `${step.color}22` : 'transparent',
color: levelStep === idx ? step.color : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)', cursor: 'pointer',
}}
>{step.label}
))}
setSearchText(e.target.value)}
placeholder="Filter…"
style={{
height: 22, padding: '0 var(--space-2)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-base)',
color: 'var(--color-text-primary)', fontSize: 'var(--font-size-xs)', width: 120, outline: 'none',
}}
/>
setAutoScroll((v) => !v)}
style={{
height: 22, paddingInline: 'var(--space-2)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${autoScroll ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
background: autoScroll ? 'var(--color-primary-subtle)' : 'transparent',
color: autoScroll ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
}}
>AUTO-SCROLL
{filtered.length === 0 ? (
{lines.length === 0 ? 'No serial output yet.' : 'No lines match this filter.'}
) : filtered.map((l, i) => {
const s = l.level ? SERIAL_LEVEL_COLORS[l.level] : null
return (
{l.level && (
{l.level}
)}
{l.tag && [{l.tag}] }
{l.message}
)
})}
)
}
// ─── Step 3: Verify ───────────────────────────────────────────────────────────
function StepVerify({ device, onVerified, serialConn }) {
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 statusPanelRef = useRef(null)
const [statusPanelHeight, setStatusPanelHeight] = useState(null)
// getBoundingClientRect().height (border-box/visual), not contentRect —
// see the matching note on StepFlash's infoPanelHeight above.
useEffect(() => {
const el = statusPanelRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setStatusPanelHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
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]
// received_at already comes back as a full ISO-8601 string with an
// explicit UTC offset (e.g. "2026-07-14T16:03:21+00:00") — see
// backend/database/pg_mqtt.py::_row_to_dict. Parsing it directly is
// correct; appending a literal "Z" (the old code) turns it into an
// invalid string like "...+00:00Z", so Date.parse always returned
// NaN and a live device could never be detected as verified.
const receivedMs = latest.received_at ? Date.parse(latest.received_at) : 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)
}
const StatusPanel = (
Waiting for Device
{polling && !verified && (
Waiting for device to connect…
Power cycle the device and ensure it can reach the MQTT broker.
Stop
)}
{verified && heartbeatData && (
)}
{timedOut && !verified && (
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
Retry Verification
)}
{error && !timedOut && !verified && (
)}
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
)
return (
{StatusPanel}
)
}
// ─── Step 4: Done ─────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext, serialConn }) {
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)}
Onboard this device now, or stop here and provision another.
navigate(`/devices/onboard?sn=${encodeURIComponent(device?.serial_number || '')}`)}
>
Onboard This Device
Provision Next Device
navigate(`/manufacturing/devices/${device?.serial_number}`)}>
View in Inventory
setShowSerialLogs(true)}>
View Serial Logs
setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
)
}
// ─── 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)
// Owned here (not inside StepFlash) so the same live WebSerial session —
// and the log lines it has accumulated — survives the Flash → Verify step
// transition instead of being torn down when StepFlash unmounts.
const serialConn = useSerialConnection()
useEffect(() => () => { serialConn.disconnect() }, []) // eslint-disable-line react-hooks/exhaustive-deps
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 (
navigate('/manufacturing')}>
Back to Inventory
{/* Step indicator */}
{/* Step content — floating on page background, no card wrapper */}
{step === 0 && }
{step === 1 && (
)}
{step === 2 && device && (
)}
{step === 3 && device && (
)}
{step === 4 && device && (
)}
)
}