diff --git a/frontend/src/hooks/useSerialConnection.js b/frontend/src/hooks/useSerialConnection.js
new file mode 100644
index 0000000..69dba06
--- /dev/null
+++ b/frontend/src/hooks/useSerialConnection.js
@@ -0,0 +1,113 @@
+// frontend/src/hooks/useSerialConnection.js
+// Owns one WebSerial port connection across the Provisioning Wizard's Flash
+// and Verify steps. Lifted out of StepFlash so the same live serial session
+// (and its accumulated log) survives the step transition — a fresh device
+// reboot after flashing is exactly the window the Verify step needs to watch.
+
+import { useState, useRef, useCallback } from 'react'
+
+export function useSerialConnection() {
+ const [portConnected, setPortConnected] = useState(false)
+ const [portName, setPortName] = useState('')
+ const [monitoring, setMonitoring] = useState(false)
+ const [lines, setLines] = useState([])
+
+ const portRef = useRef(null)
+ const readerRef = useRef(null)
+ const monitorActiveRef = useRef(false)
+
+ const appendLine = useCallback((msg) => {
+ setLines((prev) => [...prev, String(msg)])
+ }, [])
+
+ const clearLines = useCallback(() => setLines([]), [])
+
+ const requestPort = useCallback(async () => {
+ 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)
+ return port
+ }, [])
+
+ // Starts (or restarts) the 115200-baud monitor on the currently held port.
+ // Safe to call after esptool has just disconnected its own transport —
+ // the small delay lets the OS release the port before we reopen it.
+ const startMonitor = useCallback(async (port, { delayMs = 1000 } = {}) => {
+ const target = port || portRef.current
+ if (!target) return
+ monitorActiveRef.current = true
+ setMonitoring(true)
+ if (delayMs) await new Promise((r) => setTimeout(r, delayMs))
+ try {
+ await target.open({ baudRate: 115200 })
+ } catch (openErr) {
+ appendLine(`[Error opening port: ${openErr.message}]`)
+ setMonitoring(false)
+ return
+ }
+ let reader
+ try {
+ reader = target.readable.getReader()
+ } catch (readerErr) {
+ appendLine(`[Error getting reader: ${readerErr.message}]`)
+ try { await target.close() } catch (_) {}
+ setMonitoring(false)
+ return
+ }
+ readerRef.current = reader
+ const dec = new TextDecoder()
+ let buf = ''
+ try {
+ while (monitorActiveRef.current) {
+ const { value, done } = await reader.read()
+ if (done) break
+ buf += dec.decode(value, { stream: true })
+ const parts = buf.split(/\r?\n/)
+ buf = parts.pop()
+ for (const line of parts) { if (line.trim()) appendLine(line) }
+ }
+ } catch (_) {
+ // reader cancelled/port lost — fall through to cleanup
+ } finally {
+ try { reader.releaseLock() } catch (_) {}
+ setMonitoring(false)
+ }
+ }, [appendLine])
+
+ const stopMonitor = useCallback(async () => {
+ monitorActiveRef.current = false
+ try { await readerRef.current?.cancel() } catch (_) {}
+ try { readerRef.current?.releaseLock() } catch (_) {}
+ setMonitoring(false)
+ }, [])
+
+ const disconnect = useCallback(async () => {
+ await stopMonitor()
+ try { await portRef.current?.close() } catch (_) {}
+ portRef.current = null
+ setPortConnected(false)
+ setPortName('')
+ appendLine('[Port disconnected]')
+ }, [stopMonitor, appendLine])
+
+ return {
+ portRef,
+ portConnected,
+ portName,
+ monitoring,
+ lines,
+ appendLine,
+ clearLines,
+ requestPort,
+ startMonitor,
+ stopMonitor,
+ disconnect,
+ }
+}
+
+export default useSerialConnection
diff --git a/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx b/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx
index f289e41..f3c8ef2 100644
--- a/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx
+++ b/frontend/src/pages/engineering/manufacturing/ProvisioningWizard.jsx
@@ -2,7 +2,7 @@
// 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 { 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'
@@ -13,6 +13,7 @@ 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 ────────────────────────────────────────────────────────────────
@@ -82,6 +83,21 @@ function formatHwVersion(v) {
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']
@@ -660,74 +676,48 @@ function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
}
// ─── 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 }) {
- const [autoScroll, setAutoScroll] = useState(true)
- const endRef = useRef(null)
-
- useEffect(() => {
- if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
- }, [logs, autoScroll, open])
-
+function SerialLogModal({ open, onClose, logs, monitoring }) {
+ if (!open) return null
return (
-
-
-
-
- }
- >
-
- {logs.length === 0 ? (
- No serial output yet.
- ) : (
- logs.map((line, i) =>
{line}
)
- )}
-
-
-
+ <>
+ {/* .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. */}
+
+
+
+
+ }
+ >
+
+
+ >
)
}
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
-function StepFlash({ device, bespokeOverride, onFlashed }) {
- const [portConnected, setPortConnected] = useState(false)
- const [portName, setPortName] = useState('')
+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)
@@ -736,22 +726,43 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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 infoPanelRef = useRef(null)
- const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
- const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
+ // 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 scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ 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')
@@ -770,56 +781,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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)
+ await requestPort()
} catch (err) {
setError(err.message || 'Port selection cancelled.')
} finally {
@@ -829,7 +794,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const handleStartFlash = async () => {
if (!portRef.current) return
- setError(''); setLog([]); setSerial([])
+ setError(''); setLog([]); clearSerialLines()
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
setDone(false)
@@ -866,8 +831,8 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
transport, baudrate: FLASH_BAUD,
terminal: {
clean() {},
- writeLine: (line) => { appendLog(line); scrollLog() },
- write: (msg) => { appendLog(msg); scrollLog() },
+ writeLine: (line) => appendLog(line),
+ write: (msg) => appendLog(msg),
},
})
await loaderRef.current.main()
@@ -913,7 +878,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
setFlashing(false)
setDone(true)
appendSerial('── Serial monitor started (115200 baud) ──')
- startSerialMonitor(port)
+ startMonitor(port)
} catch (err) {
setError(err.message || String(err))
setFlashing(false)
@@ -926,10 +891,11 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
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 = (
-
- {!webSerialAvailable && (
-
- Web Serial API not available. Use Chrome or Edge on a desktop system.
-
- )}
+ {/* 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.
+
{/* Progress bars — always visible, idle at 0% */}
@@ -1052,64 +1026,71 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
{/* Bottom bar */}
- {/* Left: status hint */}
+ {/* 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. */}
+ {/* 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. */}
+
+
-
- )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
// ── 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 = (
+ {/* 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. */}
+