1412 lines
69 KiB
JavaScript
1412 lines
69 KiB
JavaScript
// 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 (
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M4 2v20h2V13h14V3H4zm2 2h3v3H6V4zm0 5h3v3H6V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9zm5-5h3v3h-3V4zm0 5h3v3h-3V9z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function StepIndicator({ current }) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
|
|
{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 (
|
|
<div key={label} style={{ display: 'flex', alignItems: 'center' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
|
|
<div style={{
|
|
width: dotSize, height: dotSize, borderRadius: '60%',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
backgroundColor: dotBg, color: dotColor,
|
|
fontSize: active ? '1.0rem' : '0.85rem', fontWeight: 500,
|
|
border: active ? '2px solid #22c55e' : done ? '2px solid #6d9b78c0' : '2px solid #333',
|
|
boxShadow: active ? '0 0 18px 8px rgba(34,197,94,0.4)' : 'none',
|
|
transition: 'all 0.2s',
|
|
}}>
|
|
{done ? (
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : isLast ? <CheckeredFlagIcon /> : idx}
|
|
</div>
|
|
<span style={{
|
|
fontSize: 'var(--font-size-xs)', marginTop: 4,
|
|
fontWeight: active ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
|
|
color: labelColor, textShadow: labelGlow,
|
|
opacity: pending ? 0.5 : 1, maxWidth: 72, textAlign: 'center', lineHeight: 1.2,
|
|
whiteSpace: 'nowrap',
|
|
}}>{label}</span>
|
|
</div>
|
|
{i < STEP_LABELS.length - 1 && (
|
|
<div style={{
|
|
height: 2, flex: '1 1 16px', minWidth: 28, maxWidth: 40,
|
|
backgroundColor: lineColor,
|
|
marginBottom: 18, marginLeft: 4, marginRight: 4,
|
|
transition: 'background-color 0.2s', flexShrink: 1,
|
|
}} />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
onMouseEnter={() => 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%',
|
|
}}
|
|
>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)', fontFamily: 'var(--font-family-display)', letterSpacing: 'var(--tracking-wide)', color: isSelected ? pal.selectedText : hovered ? pal.idleText : 'var(--color-text-primary)', marginBottom: 2 }}>{bt.name}</p>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', opacity: 0.7, marginBottom: 3 }}>{bt.codename}</p>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: isSelected ? 0.9 : 0.6, lineHeight: 1.4 }}>{bt.desc}</p>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ─── Progress Bar ─────────────────────────────────────────────────────────────
|
|
|
|
function ProgressBar({ label, percent, flex = false }) {
|
|
return (
|
|
<div style={{ flex: flex ? 1 : undefined }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 'var(--space-1)' }}>
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)' }}>{label}</span>
|
|
<span style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>{Math.round(percent)}%</span>
|
|
</div>
|
|
<div style={{ height: 6, borderRadius: 'var(--radius-full)', backgroundColor: 'var(--color-bg-elevated)', overflow: 'hidden' }}>
|
|
<div style={{
|
|
height: '100%', width: `${percent}%`, borderRadius: 'var(--radius-full)',
|
|
background: 'var(--gradient-primary)', transition: 'width 0.2s ease',
|
|
boxShadow: percent > 0 ? 'var(--shadow-primary-glow)' : 'none',
|
|
}} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Info Cell ────────────────────────────────────────────────────────────────
|
|
|
|
function InfoCell({ label, value, mono = false }) {
|
|
return (
|
|
<div>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', fontWeight: 'var(--font-weight-semibold)', marginBottom: 'var(--space-1)' }}>{label}</p>
|
|
{typeof value === 'string'
|
|
? <p style={{ fontSize: 'var(--font-size-sm)', fontFamily: mono ? 'var(--font-family-mono)' : undefined, color: 'var(--color-text-primary)' }}>{value || '—'}</p>
|
|
: value}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Error Box ────────────────────────────────────────────────────────────────
|
|
|
|
function ErrorBox({ msg }) {
|
|
if (!msg) return null
|
|
return (
|
|
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
|
{msg}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title="Select Bespoke Firmware"
|
|
size="md"
|
|
footer={
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
|
hw_revision will be set to <span style={{ fontFamily: 'var(--font-family-mono)' }}>1.0</span> for bespoke devices.
|
|
</p>
|
|
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
<Button variant="primary" onClick={handleConfirm} disabled={!selected || !hwFamily.trim()}>
|
|
Continue
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
|
Choose a bespoke firmware and the hardware family to register in NVS.
|
|
</p>
|
|
|
|
{loading && <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}><Spinner /></div>}
|
|
{error && <ErrorBox msg={error} />}
|
|
{!loading && !error && firmwares.length === 0 && (
|
|
<p style={{ textAlign: 'center', padding: 'var(--space-6)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
|
No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.
|
|
</p>
|
|
)}
|
|
|
|
{firmwares.length > 0 && (
|
|
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ backgroundColor: 'var(--color-bg-elevated)', borderBottom: '1px solid var(--color-border)' }}>
|
|
{['UID', 'Version', 'Channel', 'Size'].map((h) => (
|
|
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{firmwares.map((fw) => {
|
|
const isSel = selected?.id === fw.id
|
|
return (
|
|
<tr
|
|
key={fw.id}
|
|
onClick={() => 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 = '' }}
|
|
>
|
|
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-primary)' }}>{fw.bespoke_uid || '—'}</td>
|
|
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)' }}>{fw.version}</td>
|
|
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{fw.channel}</td>
|
|
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : '—'}</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-1)' }}>
|
|
Hardware Family for NVS
|
|
</label>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-2)' }}>
|
|
Written to NVS as <span style={{ fontFamily: 'var(--font-family-mono)' }}>hw_type</span>.
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={hwFamily}
|
|
onChange={(e) => 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)' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// ─── Step 0: Mode Picker ──────────────────────────────────────────────────────
|
|
|
|
function StepMode({ onPick }) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
|
<div>
|
|
<h2 style={{ fontSize: 'var(--font-size-lg)', fontFamily: 'var(--font-family-display)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>
|
|
What would you like to do?
|
|
</h2>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
|
Choose how to start the provisioning process.
|
|
</p>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
|
{/* Flash Existing */}
|
|
<button
|
|
type="button"
|
|
onClick={() => 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'
|
|
}}
|
|
>
|
|
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-lg)', backgroundColor: 'var(--color-primary-subtle)', border: '1px solid var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 'var(--space-3)' }}>
|
|
<svg width="18" height="18" fill="none" stroke="var(--color-primary)" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<p style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>Flash Existing</p>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
|
Re-flash a device already in inventory — manufactured, flashed, or provisioned.
|
|
</p>
|
|
</button>
|
|
|
|
{/* Deploy New Device */}
|
|
<button
|
|
type="button"
|
|
onClick={() => 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'
|
|
}}
|
|
>
|
|
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-lg)', backgroundColor: 'var(--color-success-bg)', border: '1px solid var(--color-success)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 'var(--space-3)' }}>
|
|
<svg width="18" height="18" fill="none" stroke="var(--color-success)" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</div>
|
|
<p style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>Deploy New Device</p>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
|
Generate a new serial number, select board type and revision, then flash and provision.
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
<div style={{ padding: 'var(--space-4)', backgroundColor: 'var(--color-bg-elevated)', border: `1px solid ${pal.selectedBorder}`, borderRadius: 'var(--radius-lg)' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: pal.selectedText, textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-3)' }}>Device Selected</p>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
|
|
<InfoCell label="Serial Number" value={picked.serial_number} mono />
|
|
<InfoCell label="Board Type" value={BOARD_TYPE_LABELS[picked.hw_type] || picked.hw_type} />
|
|
<InfoCell label="HW Revision" value={formatHwVersion(picked.hw_version)} />
|
|
<InfoCell label="Status" value={<StatusBadge variant={STATUS_VARIANT[picked.mfg_status] || 'neutral'}>{picked.mfg_status}</StatusBadge>} />
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
|
<Button variant="primary" onClick={() => onSelected(picked)}>
|
|
Continue to Flash <Icon name="arrow-right" size={14} />
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => setPicked(null)}>Change Device</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── 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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
|
|
{/* Board type */}
|
|
<div>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-3)' }}>Board Type</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
|
{/* Vesper row — 3 equal columns */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-2)' }}>
|
|
{vesperBoards.map((bt) => (
|
|
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
|
))}
|
|
</div>
|
|
{/* Agnus row — 2 boards centered inside the same 3-col grid */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-2)' }}>
|
|
<div style={{ gridColumn: '1 / 3', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
|
|
{agnusBoards.map((bt) => (
|
|
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Chronos row — 2 boards centered inside the same 3-col grid */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-2)' }}>
|
|
<div style={{ gridColumn: '1 / 3', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-2)' }}>
|
|
{chronosBoards.map((bt) => (
|
|
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Board revision — narrow */}
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-4)' }}>
|
|
<div>
|
|
<label style={{ display: 'block', fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-1)' }}>
|
|
Board Revision
|
|
</label>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
|
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Rev</span>
|
|
<input
|
|
type="text"
|
|
value={boardVersion}
|
|
onChange={(e) => 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',
|
|
}}
|
|
/>
|
|
</div>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 'var(--space-1)' }}>e.g. 1.0, 1.20</p>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 'var(--space-2)', marginLeft: 'auto' }}>
|
|
<ErrorBox msg={error} />
|
|
<Button variant="primary" onClick={handleCreate} loading={creating} disabled={!boardType}>
|
|
Generate Serial & Continue <Icon name="arrow-right" size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bespoke divider */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
|
<div style={{ flex: 1, height: 1, backgroundColor: 'var(--color-border)' }} />
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>or</span>
|
|
<div style={{ flex: 1, height: 1, backgroundColor: 'var(--color-border)' }} />
|
|
</div>
|
|
|
|
{/* Bespoke option */}
|
|
<div style={{ padding: 'var(--space-4)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)' }}>
|
|
<div>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>Select Bespoke Firmware</p>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
|
Flash a one-off bespoke firmware with a custom hardware family written to NVS.
|
|
</p>
|
|
</div>
|
|
<Button variant="secondary" onClick={() => setShowBespoke(true)} style={{ flexShrink: 0 }}>
|
|
Select Bespoke →
|
|
</Button>
|
|
</div>
|
|
|
|
<BespokePickerModal open={showBespoke} onConfirm={handleBespokeConfirm} onClose={() => setShowBespoke(false)} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Existing device search ──
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
<SearchBar
|
|
value={search}
|
|
onChange={(v) => { setSearch(v); doSearch(v) }}
|
|
placeholder="Search serial, batch, type…"
|
|
/>
|
|
|
|
{error && <ErrorBox msg={error} />}
|
|
|
|
{loading ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}><Spinner /></div>
|
|
) : results.length === 0 ? (
|
|
<p style={{ textAlign: 'center', padding: 'var(--space-8)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>No flashable devices found.</p>
|
|
) : (
|
|
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ backgroundColor: 'var(--color-bg-elevated)', borderBottom: '1px solid var(--color-border)' }}>
|
|
{['Serial', 'Type', 'Revision', 'Status'].map((h) => (
|
|
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{results.map((d, i) => (
|
|
<tr
|
|
key={d.id}
|
|
onClick={() => 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 = '' }}
|
|
>
|
|
<td style={{ padding: 'var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{d.serial_number}</td>
|
|
<td style={{ padding: 'var(--space-3)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}</td>
|
|
<td style={{ padding: 'var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{formatHwVersion(d.hw_version)}</td>
|
|
<td style={{ padding: 'var(--space-3)' }}>
|
|
<StatusBadge variant={STATUS_VARIANT[d.mfg_status] || 'neutral'}>{d.mfg_status}</StatusBadge>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title="Serial Output Logs"
|
|
size="lg"
|
|
footer={
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', cursor: 'pointer', userSelect: 'none' }}>
|
|
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Auto-scroll</span>
|
|
<span
|
|
onClick={() => setAutoScroll((v) => !v)}
|
|
style={{
|
|
position: 'relative', display: 'inline-flex', alignItems: 'center',
|
|
width: 32, height: 18,
|
|
backgroundColor: autoScroll ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
|
|
borderRadius: 9, border: '1px solid var(--color-border)',
|
|
cursor: 'pointer', transition: 'background-color 0.2s', flexShrink: 0,
|
|
}}
|
|
>
|
|
<span style={{
|
|
position: 'absolute', left: autoScroll ? 14 : 2,
|
|
width: 14, height: 14, borderRadius: '50%',
|
|
backgroundColor: '#fff', transition: 'left 0.15s',
|
|
}} />
|
|
</span>
|
|
</label>
|
|
<Button variant="secondary" onClick={onClose}>Close</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div style={{
|
|
backgroundColor: 'var(--color-bg-abyss)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--radius-md)',
|
|
padding: 'var(--space-3)',
|
|
height: 400,
|
|
overflowY: 'auto',
|
|
fontFamily: 'var(--font-family-mono)',
|
|
fontSize: 'var(--font-size-xs)',
|
|
lineHeight: 1.6,
|
|
color: '#a3e635',
|
|
}}>
|
|
{logs.length === 0 ? (
|
|
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>No serial output yet.</span>
|
|
) : (
|
|
logs.map((line, i) => <div key={i}>{line}</div>)
|
|
)}
|
|
<div ref={endRef} />
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
// ─── 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 = (
|
|
<div style={{
|
|
borderRadius: 'var(--radius-lg)',
|
|
border: `1px solid ${familyPal.idleBorder}`,
|
|
padding: 'var(--space-5)',
|
|
backgroundColor: 'var(--color-bg-elevated)',
|
|
display: 'flex', flexDirection: 'column',
|
|
}}>
|
|
{/* Header: title + COM status button */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: familyPal.idleText }}>
|
|
Device to Flash
|
|
</p>
|
|
<button
|
|
onClick={portConnected ? disconnectPort : undefined}
|
|
title={portConnected ? `${portName} — Click to disconnect` : 'No port connected'}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '4px 10px', borderRadius: 'var(--radius-md)',
|
|
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)',
|
|
cursor: portConnected ? 'pointer' : 'default',
|
|
backgroundColor: portConnected ? '#0a2e1a' : 'var(--color-bg-base)',
|
|
color: portConnected ? '#4dd6c8' : 'var(--color-text-muted)',
|
|
border: `1px solid ${portConnected ? '#4dd6c8' : 'var(--color-border)'}`,
|
|
transition: 'opacity 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => { if (portConnected) e.currentTarget.style.opacity = '0.75' }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1' }}
|
|
>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: portConnected ? '#22c55e' : '#444', display: 'inline-block', flexShrink: 0 }} />
|
|
{portConnected ? portName || 'Connected' : 'No Port'}
|
|
{portConnected && <span style={{ marginLeft: 2, opacity: 0.6 }}>✕</span>}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Device info */}
|
|
<div style={{ marginBottom: 'var(--space-4)' }}>
|
|
{bespokeOverride ? (
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
|
|
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
|
<InfoCell label="NVS hw_type" value={bespokeOverride.hwFamily} />
|
|
<InfoCell label="NVS hw_revision" value="1.0" />
|
|
<div>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', fontWeight: 'var(--font-weight-semibold)', marginBottom: 'var(--space-1)' }}>Firmware</p>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: '#fb923c' }}>BESPOKE · {bespokeOverride.firmware.bespoke_uid}</p>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', marginTop: 2 }}>v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Row 1: serial number + status badge */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
|
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
|
{device.serial_number}
|
|
</p>
|
|
<StatusBadge variant={STATUS_VARIANT[device.mfg_status] || 'neutral'}>{device.mfg_status}</StatusBadge>
|
|
</div>
|
|
{/* Row 2: board type + codename */}
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-1)' }}>
|
|
Board Type:
|
|
<span style={{ display: 'inline-block', width: 'var(--space-3)' }} />
|
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)' }}>
|
|
{boardInfo?.uiName || boardInfo?.name || device.hw_type}
|
|
</span>
|
|
{boardInfo?.codename && (
|
|
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)', marginLeft: 'var(--space-2)' }}>| {boardInfo.codename}</span>
|
|
)}
|
|
</p>
|
|
{/* Row 3: revision */}
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
|
Revision:
|
|
<span style={{ display: 'inline-block', width: 'var(--space-3)' }} />
|
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)' }}>
|
|
{formatHwVersion(device.hw_version)}
|
|
</span>
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* NVS profile toggle */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-3)' }}>
|
|
<div style={{ display: 'flex', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
|
|
{[['current', 'Current Gen NVS'], ['legacy', 'Legacy NVS']].map(([val, lbl]) => (
|
|
<button
|
|
key={val}
|
|
type="button"
|
|
onClick={() => 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}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{!webSerialAvailable && (
|
|
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-3)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)' }}>
|
|
Web Serial API not available. Use Chrome or Edge on a desktop system.
|
|
</div>
|
|
)}
|
|
|
|
{error && <div style={{ marginBottom: 'var(--space-3)' }}><ErrorBox msg={error} /></div>}
|
|
|
|
{/* Progress bars — always visible, idle at 0% */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
|
|
<div style={{ display: 'flex', gap: 'var(--space-4)' }}>
|
|
<ProgressBar flex label="Bootloader (0x1000)" percent={blProgress} />
|
|
<ProgressBar flex label="Partition Table (0x8000)" percent={partProgress} />
|
|
</div>
|
|
<ProgressBar label="NVS (0x9000)" percent={nvsProgress} />
|
|
<ProgressBar label="Firmware (0x10000)" percent={fwProgress} />
|
|
</div>
|
|
|
|
{/* Spacer */}
|
|
<div style={{ flex: 1 }} />
|
|
|
|
{/* Bottom bar */}
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', paddingTop: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
|
{/* Left: status hint */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{portConnected && !flashing && !done && log.length === 0 && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: '#4dd6c8' }}>
|
|
<span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: '#22c55e', display: 'inline-block' }} />
|
|
Ready to flash.
|
|
</div>
|
|
)}
|
|
{flashing && (
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Flashing — do not disconnect…</p>
|
|
)}
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: 0.5 }}>
|
|
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
|
|
</p>
|
|
</div>
|
|
|
|
{/* Right: action buttons */}
|
|
{!busy && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
|
{!portConnected && (
|
|
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
|
|
Select COM Port
|
|
</Button>
|
|
)}
|
|
{portConnected && done && (
|
|
<Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button>
|
|
)}
|
|
{done && (
|
|
<Button variant="primary" onClick={onFlashed}>
|
|
Proceed to Verify <Icon name="arrow-right" size={14} />
|
|
</Button>
|
|
)}
|
|
{portConnected && !done && (
|
|
<Button variant="primary" onClick={handleStartFlash}>
|
|
Start Flashing
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{busy && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
|
<Spinner size="sm" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
// ── Right panel: flash output log ─────────────────────────────────────────
|
|
const FlashOutputPanel = (
|
|
<div style={{
|
|
borderRadius: 'var(--radius-lg)',
|
|
border: '1px solid var(--color-border)',
|
|
overflow: 'hidden',
|
|
display: 'flex', flexDirection: 'column',
|
|
height: 320,
|
|
alignSelf: 'start',
|
|
position: 'sticky', top: 0,
|
|
}}>
|
|
<div style={{
|
|
padding: '8px 12px',
|
|
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
|
|
textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)',
|
|
color: 'var(--color-text-muted)',
|
|
backgroundColor: 'var(--color-bg-elevated)',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
flexShrink: 0,
|
|
}}>
|
|
Flash Output
|
|
</div>
|
|
<div style={{
|
|
flex: 1, minHeight: 0,
|
|
padding: 'var(--space-3)',
|
|
overflowY: 'auto',
|
|
fontFamily: 'var(--font-family-mono)',
|
|
fontSize: 'var(--font-size-xs)',
|
|
backgroundColor: 'var(--color-bg-base)',
|
|
color: 'var(--color-text-secondary)',
|
|
lineHeight: 1.6,
|
|
}}>
|
|
{log.length === 0
|
|
? <span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>{flashing ? 'Connecting…' : 'Output will appear here once flashing starts.'}</span>
|
|
: log.map((line, i) => <div key={i}>{line}</div>)
|
|
}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
|
{/* Info panel (left) | Flash output (right) */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'stretch' }}>
|
|
{InfoPanel}
|
|
{FlashOutputPanel}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
<div style={{ padding: 'var(--space-5)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', minHeight: 280 }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-4)' }}>
|
|
Waiting for Device
|
|
</p>
|
|
|
|
{polling && !verified && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) 0', gap: 'var(--space-4)' }}>
|
|
<Spinner size="lg" />
|
|
<p style={{ fontSize: 'var(--font-size-sm)', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
|
Waiting for device to connect…<br />
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
|
Power cycle the device and ensure it can reach the MQTT broker.
|
|
</span>
|
|
</p>
|
|
<Button variant="ghost" onClick={stopPolling}>Stop</Button>
|
|
</div>
|
|
)}
|
|
|
|
{verified && heartbeatData && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
|
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#0a2e2a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<Icon name="check" size={16} color="#4dd6c8" />
|
|
</div>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: '#4dd6c8' }}>Device is live!</p>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', backgroundColor: 'var(--color-bg-base)', borderRadius: 'var(--radius-md)' }}>
|
|
<InfoCell label="Firmware" value={heartbeatData.firmware_version || '—'} />
|
|
<InfoCell label="IP Address" value={heartbeatData.ip_address || '—'} />
|
|
<InfoCell label="Uptime" value={heartbeatData.uptime_display || '—'} />
|
|
<InfoCell label="Gateway" value={heartbeatData.gateway || '—'} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{timedOut && !verified && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', paddingTop: 'var(--space-4)' }}>
|
|
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>
|
|
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
|
|
</div>
|
|
<Button variant="primary" onClick={startPolling}>Retry Verification</Button>
|
|
</div>
|
|
)}
|
|
|
|
{error && !timedOut && !verified && (
|
|
<div style={{ marginTop: 'var(--space-3)' }}><ErrorBox msg={`Poll error (will retry): ${error}`} /></div>
|
|
)}
|
|
</div>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
|
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Step 4: Done ─────────────────────────────────────────────────────────────
|
|
|
|
function StepDone({ device, onProvisionNext }) {
|
|
const navigate = useNavigate()
|
|
const [showSerialLogs, setShowSerialLogs] = useState(false)
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) var(--space-4)', gap: 'var(--space-4)' }}>
|
|
<div style={{ width: 64, height: 64, borderRadius: '50%', backgroundColor: '#0a2e2a', border: '2px solid #4dd6c8', display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 24px rgba(77,214,200,0.3)' }}>
|
|
<Icon name="check" size={28} color="#4dd6c8" />
|
|
</div>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<h3 style={{ fontSize: 'var(--font-size-lg)', fontFamily: 'var(--font-family-display)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-text-primary)', marginBottom: 'var(--space-1)' }}>
|
|
Device Provisioned
|
|
</h3>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
|
{device?.serial_number} is live.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Device summary grid */}
|
|
<div style={{ borderRadius: 'var(--radius-lg)', overflow: 'hidden', border: '1px solid var(--color-border)', backgroundColor: 'var(--color-bg-elevated)' }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', borderBottom: '1px solid var(--color-border)' }}>
|
|
<div style={{ padding: 'var(--space-4)', borderRight: '1px solid var(--color-border)' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Serial Number</p>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>{device?.serial_number}</p>
|
|
</div>
|
|
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Status</p>
|
|
<StatusBadge variant={STATUS_VARIANT[device?.mfg_status] || 'neutral'}>{device?.mfg_status}</StatusBadge>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
|
|
<div style={{ padding: 'var(--space-4)', borderRight: '1px solid var(--color-border)' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Board Type</p>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{BOARD_TYPE_LABELS[device?.hw_type] || device?.hw_type}</p>
|
|
</div>
|
|
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>HW Version</p>
|
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{formatHwVersion(device?.hw_version)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)' }}>
|
|
<Button variant="primary" onClick={onProvisionNext}>
|
|
Provision Next Device
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
|
|
View in Inventory
|
|
</Button>
|
|
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
|
|
View Serial Logs
|
|
</Button>
|
|
</div>
|
|
|
|
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={[]} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': step === 2 ? '1200px' : 'var(--content-max-width-md)' }}>
|
|
<PageHeader title="Provisioning Wizard" subtitle="Flash firmware to a Bell Systems board via WebSerial">
|
|
<Button variant="ghost" onClick={() => navigate('/manufacturing')}>
|
|
<Icon name="arrow-left" size={14} />
|
|
Back to Inventory
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{/* Step indicator */}
|
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
|
<StepIndicator current={step + 1} />
|
|
</div>
|
|
|
|
{/* Step content — floating on page background, no card wrapper */}
|
|
<div style={{ paddingTop: 'var(--space-2)' }}>
|
|
{step === 0 && <StepMode onPick={handleModePick} />}
|
|
|
|
{step === 1 && (
|
|
<StepSelectDevice
|
|
mode={mode}
|
|
preloadSn={preloadSn}
|
|
onSelected={handleDeviceSelected}
|
|
onCreatedSn={handleCreatedSn}
|
|
/>
|
|
)}
|
|
|
|
{step === 2 && device && (
|
|
<StepFlash
|
|
device={device}
|
|
bespokeOverride={bespokeOverride}
|
|
onFlashed={handleFlashed}
|
|
/>
|
|
)}
|
|
|
|
{step === 3 && device && (
|
|
<StepVerify device={device} onVerified={handleVerified} />
|
|
)}
|
|
|
|
{step === 4 && device && (
|
|
<StepDone device={device} onProvisionNext={handleProvisionNext} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|