import { useState, useRef, useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { ESPLoader, Transport } from "esptool-js"; import api from "../api/client"; // ─── constants ─────────────────────────────────────────────────────────────── // Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos const BOARD_TYPES = [ { value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" }, { value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" }, { value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" }, { value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" }, { value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" }, { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos"}, { value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos"}, ]; // 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 BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name])); // Display board version stored as semver string ("1.0", "2.1") as "Rev 1.0" function formatHwVersion(v) { if (!v) return "—"; // Already in semver form ("1.0") or legacy 2-digit ("01" → "Rev 1.0") if (/^\d+\.\d+/.test(v)) return `Rev ${v}`; // Legacy: "01" → "1.0", "02" → "2.0" const n = parseInt(v, 10); if (!isNaN(n)) return `Rev ${n}.0`; return `Rev ${v}`; } const STATUS_STYLES = { manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, provisioned: { bg: "#0a2e2a", color: "#4dd6c8" }, sold: { bg: "#1e1036", color: "#c084fc" }, claimed: { bg: "#2e1a00", color: "#fb923c" }, decommissioned: { bg: "var(--danger-bg)", color: "var(--danger-text)" }, }; // Statuses that can be re-flashed via the wizard 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; // ─── small helpers ──────────────────────────────────────────────────────────── function StatusBadge({ status }) { const style = STATUS_STYLES[status] || STATUS_STYLES.manufactured; return ( {status} ); } function ProgressBar({ label, percent }) { return (
{label} {Math.round(percent)}%
); } function ErrorBox({ msg }) { if (!msg) return null; return (
{msg}
); } function inputStyle() { return { backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }; } // ─── Step Indicator ─────────────────────────────────────────────────────────── // Steps: 0=Mode, 1=Select, 2=Flash, 3=Verify, 4=Done // current is 1-based to match step state (step 0 = mode picker, shown differently) const STEP_LABELS = ["Begin", "Device", "Flash", "Verify", "Done"]; function CheckeredFlagIcon() { return ( ); } function StepIndicator({ current }) { // current: 1..4 (step numbers matching STEP_LABELS index + 1) 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; // Color tokens 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(--border-primary)"; // Active dot is 38px, others are 30px const dotSize = active ? 38 : 30; return (
{/* Node */}
{done ? ( ) : isLast ? ( ) : idx}
{label}
{/* Connector line */} {i < STEP_LABELS.length - 1 && (
)}
); })}
); } // ─── Step 0 — Mode picker ───────────────────────────────────────────────────── function StepModePicker({ onPick }) { return (

What would you like to do?

Choose how to start the provisioning process.

{/* Flash Existing */} {/* Deploy New — same style as Flash Existing */}
); } // ─── Step 1a — Flash Existing: pick from inventory ──────────────────────────── function StepSelectExisting({ onSelected }) { const [search, setSearch] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState(null); const doSearch = async (q) => { setError(""); setLoading(true); try { const params = new URLSearchParams({ limit: "50" }); if (q) params.set("search", q); // Only show flashable statuses const all = await Promise.all( FLASHABLE_STATUSES.map((s) => api.get(`/manufacturing/devices?${params}&status=${s}`).then((d) => d.devices) ) ); const merged = all.flat().sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "") ); setResults(merged); } catch (err) { setError(err.message); } finally { setLoading(false); } }; useEffect(() => { doSearch(""); }, []); const handleSearchSubmit = (e) => { e.preventDefault(); doSearch(search); }; if (selected) { return (

Device Selected

Status

); } return (

Select a Device to Flash

setSearch(e.target.value)} className="flex-1 px-3 py-2 rounded-md text-sm border" style={inputStyle()} />
{results.length === 0 && !loading && (

No flashable devices found.

)} {results.length > 0 && (
{results.map((d) => ( setSelected(d)} className="cursor-pointer transition-colors" style={{ borderBottom: "1px solid var(--border-secondary)" }} onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")} onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "")} > ))}
Serial Type Ver Status
{d.serial_number} {BOARD_TYPE_LABELS[d.hw_type] || d.hw_type} {formatHwVersion(d.hw_version)}
)}
); } // ─── Board Type Tile (shared between StepDeployNew and AddDeviceModal) ──────── function BoardTypeTile({ bt, isSelected, pal, onClick }) { const [hovered, setHovered] = useState(false); 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 ( ); } // ─── Step 1b — Deploy New: pick board type + revision ───────────────────────── function StepDeployNew({ onSelected, onCreatedSn }) { const [boardType, setBoardType] = useState(null); const [boardVersion, setBoardVersion] = useState("1.0"); const [creating, setCreating] = useState(false); const [error, setError] = useState(""); const handleCreate = async () => { if (!boardType || !boardVersion.trim()) return; setError(""); setCreating(true); 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); // register for abort-cleanup const device = await api.get(`/manufacturing/devices/${sn}`); onSelected(device); } catch (err) { setError(err.message); } finally { setCreating(false); } }; // Group boards by family for row layout 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"); const renderTileRow = (boards, cols) => (
{boards.map((bt) => ( setBoardType(bt.value)} /> ))}
); return (
{/* Board type tiles — Vesper: 3 col, Agnus: 2 col centered, Chronos: 2 col centered */}

Board Type

{renderTileRow(vesperBoards, 3)}
{agnusBoards.map((bt) => ( setBoardType(bt.value)} /> ))}
{chronosBoards.map((bt) => ( setBoardType(bt.value)} /> ))}
{/* Board revision (left) + Generate button (right) */}
Rev setBoardVersion(e.target.value)} placeholder="1.0" className="px-3 py-2 rounded-md text-sm border w-32" style={inputStyle()} />

Semantic versioning: 1.0, 1.1, 2.0…

); } // ─── Shared info cell ───────────────────────────────────────────────────────── function InfoCell({ label, value, mono = false }) { return (

{label}

{value || "—"}

); } // ─── Step 2 — Flash ──────────────────────────────────────────────────────────── function StepFlash({ device, 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 [nvsProgress, setNvsProgress] = useState(0); const [fwProgress, setFwProgress] = useState(0); const [log, setLog] = useState([]); const [serial, setSerial] = useState([]); const [serialAutoScroll, setSerialAutoScroll] = useState(true); const [error, setError] = useState(""); const loaderRef = useRef(null); const portRef = useRef(null); const serialReaderRef = useRef(null); const serialActiveRef = useRef(false); const serialAutoScrollRef = useRef(true); // mirrors state — readable inside async loops const logEndRef = useRef(null); const serialEndRef = useRef(null); // Keep the ref in sync with state so async loops always see the current value const handleSetSerialAutoScroll = (val) => { serialAutoScrollRef.current = val; setSerialAutoScroll(val); }; 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 = () => { if (serialAutoScrollRef.current) serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; // When auto-scroll is re-enabled, jump to bottom immediately useEffect(() => { if (serialAutoScroll) serialEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [serialAutoScroll]); 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 textDecoder = new TextDecoder(); let lineBuffer = ""; try { while (serialActiveRef.current) { const { value, done: streamDone } = await reader.read(); if (streamDone) break; lineBuffer += textDecoder.decode(value, { stream: true }); const lines = lineBuffer.split(/\r?\n/); lineBuffer = lines.pop(); for (const line of lines) { if (line.trim()) { appendSerial(line); scrollSerial(); } } } } catch (_) { // Reader cancelled on cleanup — expected } 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; // Try to get a display name from port info 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([]); setNvsProgress(0); setFwProgress(0); setDone(false); const port = portRef.current; try { // 1. Fetch binaries appendLog("Fetching NVS binary…"); const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/nvs.bin`); appendLog(`NVS binary: ${nvsBuffer.byteLength} bytes`); appendLog("Fetching firmware binary…"); const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${device.serial_number}/firmware.bin`); appendLog(`Firmware binary: ${fwBuffer.byteLength} bytes`); // 2. Connect ESPLoader 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."); // 3. Flash NVS + firmware const nvsData = arrayBufferToString(nvsBuffer); const fwData = arrayBufferToString(fwBuffer); await loaderRef.current.writeFlash({ fileArray: [ { data: nvsData, address: NVS_ADDRESS }, { data: fwData, address: FW_ADDRESS }, ], flashSize: "keep", flashMode: "keep", flashFreq: "keep", eraseAll: false, compress: true, reportProgress(fileIndex, written, total) { if (fileIndex === 0) setNvsProgress((written / total) * 100); else { setNvsProgress(100); setFwProgress((written / total) * 100); } }, calculateMD5Hash: () => "", }); setNvsProgress(100); setFwProgress(100); appendLog("Flash complete. Resetting device…"); // 4. Hard-reset via RTS pulse 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…"); // 5. Disconnect esptool transport try { await loaderRef.current.transport.disconnect(); } catch (_) {} appendLog("esptool disconnected. Opening serial monitor…"); // 6. Update status → flashed await api.request(`/manufacturing/devices/${device.serial_number}/status`, { method: "PATCH", body: JSON.stringify({ status: "flashed", note: "Flashed via browser provisioning wizard" }), }); setFlashing(false); setDone(true); // 7. Re-open for serial monitor 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 = "serial" in navigator; const busy = connecting || flashing; // ── Row 1: Info+buttons (left) | Flash Output (right) ────────────────────── const InfoPanel = (
{/* Header: title + COM status */}

Device to Flash

{!webSerialAvailable && (
Web Serial API not available. Use Chrome or Edge on a desktop system.
)} {error &&
} {(flashing || nvsProgress > 0) && (
)} {/* Spacer — pushes bottom bar to the actual bottom of the card */}
{/* Bottom bar: info (left) | buttons (right) */}
{/* Left: status hint + tech info */}
{portConnected && !flashing && !done && log.length === 0 && (
Ready to flash.
)} {flashing && (

Flashing — do not disconnect…

)}

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

{/* Right: action buttons */} {!busy && (
{!portConnected && ( )} {portConnected && done && ( )} {done && ( )} {portConnected && !done && ( )}
)}
); const FlashOutputPanel = (
Flash Output
{log.length === 0 ? ( {flashing ? "Connecting…" : "Output will appear here once flashing starts."} ) : ( log.map((line, i) =>
{line}
) )}
); return (
{/* Row 1: Info | Flash Output */}
{InfoPanel} {FlashOutputPanel}
{/* Row 2: Serial Output — full width, resizable by drag. ↓ EDIT THIS VALUE to adjust the serial monitor height ↓ */}
Serial Output {serialActiveRef.current && ( Live )}
{serial.length === 0 ? ( {done ? "Waiting for device boot…" : "Available after flash completes."} ) : ( serial.map((line, i) =>
{line}
) )}
); } // ─── 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); // Auto-start polling on mount 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" }); return; } } } 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]); // Auto-start on mount 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 (
{/* Fixed-height card so layout doesn't jump when data arrives */}

Waiting for Device

{/* Loading state */} {polling && !verified && (

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

)} {/* Verified state */} {verified && heartbeatData && (

Device is live!

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

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

); } // ─── Step 4 — Done ──────────────────────────────────────────────────────────── function StepDone({ device, onProvisionNext }) { const navigate = useNavigate(); return (

Device Provisioned

{device.serial_number} is live.

{/* Row 1: Serial Number (left) | Status (right) */}

Serial Number

{device.serial_number}

Status

{/* Row 2: Board Type (left) | HW Version (right) */}

Board Type

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

HW Version

{formatHwVersion(device.hw_version)}

); } // ─── Main Wizard ────────────────────────────────────────────────────────────── // Steps: // 0 = Mode picker ("Flash Existing" vs "Deploy New") // 1 = Select device (varies by mode) // 2 = Flash // 3 = Verify // 4 = Done export default function ProvisioningWizard() { const [step, setStep] = useState(0); const [mode, setMode] = useState(null); // "existing" | "new" const [device, setDevice] = useState(null); const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup const handleModePicked = (m) => { setMode(m); setStep(1); }; const handleDeviceSelected = (dev) => { setDevice(dev); setStep(2); }; const handleFlashed = () => setStep(3); const handleVerified = (updatedDevice) => { setDevice(updatedDevice); setStep(4); }; const handleBack = async () => { if (step === 1) { // Going back to mode picker — if Deploy New created a serial, clean it up if (mode === "new" && createdSn) { await cleanupCreatedSn(createdSn); setCreatedSn(null); } setStep(0); setMode(null); setDevice(null); } else if (step === 2) { setStep(1); setDevice(null); } else if (step === 3) { setStep(2); } }; const handleAbort = async () => { if (mode === "new" && createdSn) { await cleanupCreatedSn(createdSn); setCreatedSn(null); } setStep(0); setMode(null); setDevice(null); }; const handleProvisionNext = () => { setDevice(null); setCreatedSn(null); setStep(0); setMode(null); }; // Determine Back/Abort visibility const showBack = step >= 1 && step <= 3; const showAbort = step >= 1 && step <= 3; // Flash step takes full available width; all others are centered at 720px const isFlashStep = step === 2; return (
{/* ── Sticky top bar ── */}
{/* Title + controls row */}
{/* Left: Title */}

Provisioning Wizard

{/* Center: StepIndicator — always visible, current=step+1 to account for Begin as step 1 */}
{/* Right: Back + Abort grouped together */}
{showBack && ( )} {showAbort && ( )}
{/* ── Step content ── */}
{step === 0 && } {step === 1 && mode === "existing" && ( )} {step === 1 && mode === "new" && ( setCreatedSn(sn)} /> )} {step === 2 && device && ( )} {step === 3 && device && ( )} {step === 4 && device && ( )}
); } // ─── Cleanup helper ─────────────────────────────────────────────────────────── // Deletes a newly-created (un-flashed) serial number when user aborts Deploy New async function cleanupCreatedSn(sn) { try { await api.request(`/manufacturing/devices/${sn}`, { method: "DELETE" }); } catch (_) { // Best-effort — if the endpoint doesn't exist yet, silently ignore } }