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. +
- {error &&
} + {/* Error box — same reserved-space treatment. */} +
+ +
{/* 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. */}
- {portConnected && !flashing && !done && log.length === 0 && ( -
- - Ready to flash. -
- )} - {flashing && ( -

Flashing — do not disconnect…

- )} +
+ + + {flashing ? 'Flashing — do not disconnect…' : 'Ready to flash.'} + +

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

- {/* Right: action buttons */} - {!busy && ( -
- {!portConnected && ( - - )} - {portConnected && done && ( - - )} - {done && ( - - )} - {portConnected && !done && ( - - )} -
- )} - {busy && ( -
+ {/* 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) */} -
+ {/* Info panel (left) | Flash output (right) — align-items: start so the + row's height is never driven by the (explicitly-sized) right panel, + only ever by the left panel's natural content height. */} +
{InfoPanel} {FlashOutputPanel}
@@ -1152,9 +1135,148 @@ function StepFlash({ device, bespokeOverride, onFlashed }) { ) } +// ─── Serial Log Panel (Verify step's right panel) ───────────────────────────── +// Same stepped-severity filter pattern as LogsTab (frontend/src/pages/bellcloud/ +// devices/tabs/LogsTab.jsx), applied to raw serial lines instead of MQTT rows. + +const SERIAL_LEVEL_STEPS = [ + { value: '', label: 'ALL', color: 'var(--color-text-muted)' }, + { value: 'INFO', label: 'INFO', color: 'var(--color-info)' }, + { value: 'WARN', label: 'WARN', color: 'var(--color-warning)' }, + { value: 'ERROR', label: 'ERROR', color: 'var(--color-danger)' }, +] +const SERIAL_LEVEL_RANK = { VERBOSE: -1, DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 } +const SERIAL_LEVEL_COLORS = { + ERROR: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' }, + WARN: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' }, + INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' }, + DEBUG: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' }, + VERBOSE: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' }, +} + +function SerialLogPanel({ lines, monitoring, height }) { + const [levelStep, setLevelStep] = useState(0) + const [searchText, setSearchText] = useState('') + const [autoScroll, setAutoScroll] = useState(true) + const containerRef = useRef(null) + + const levelFilter = SERIAL_LEVEL_STEPS[levelStep].value + + const parsed = useMemo(() => lines.map((raw) => ({ raw, ...parseSerialLine(raw) })), [lines]) + + const filtered = useMemo(() => parsed.filter((l) => { + if (levelFilter) { + const rank = l.level ? SERIAL_LEVEL_RANK[l.level] : -1 + if (rank < SERIAL_LEVEL_RANK[levelFilter]) return false + } + if (searchText && !l.raw.toLowerCase().includes(searchText.toLowerCase())) return false + return true + }), [parsed, levelFilter, searchText]) + + const endRef = useRef(null) + useEffect(() => { + if (autoScroll) endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) + }, [filtered, autoScroll]) + + return ( +
+
+ + Serial Output + + {monitoring && ( + + + LIVE + + )} +
+
+ {SERIAL_LEVEL_STEPS.map((step, idx) => ( + + ))} +
+ setSearchText(e.target.value)} + placeholder="Filter…" + style={{ + height: 22, padding: '0 var(--space-2)', borderRadius: 'var(--radius-sm)', + border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-base)', + color: 'var(--color-text-primary)', fontSize: 'var(--font-size-xs)', width: 120, outline: 'none', + }} + /> + +
+
+ {filtered.length === 0 ? ( + + {lines.length === 0 ? 'No serial output yet.' : 'No lines match this filter.'} + + ) : filtered.map((l, i) => { + const s = l.level ? SERIAL_LEVEL_COLORS[l.level] : null + return ( +
+ {l.level && ( + {l.level} + )} + {l.tag && [{l.tag}]} + {l.message} +
+ ) + })} +
+
+
+ ) +} + // ─── Step 3: Verify ─────────────────────────────────────────────────────────── -function StepVerify({ device, onVerified }) { +function StepVerify({ device, onVerified, serialConn }) { const [polling, setPolling] = useState(false) const [timedOut, setTimedOut] = useState(false) const [verified, setVerified] = useState(false) @@ -1162,6 +1284,20 @@ function StepVerify({ device, onVerified }) { const [error, setError] = useState('') const intervalRef = useRef(null) const timeoutRef = useRef(null) + const statusPanelRef = useRef(null) + const [statusPanelHeight, setStatusPanelHeight] = useState(null) + + // getBoundingClientRect().height (border-box/visual), not contentRect — + // see the matching note on StepFlash's infoPanelHeight above. + useEffect(() => { + const el = statusPanelRef.current + if (!el) return + const observer = new ResizeObserver(() => { + setStatusPanelHeight(el.getBoundingClientRect().height) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) const startPolling = useCallback(() => { if (polling) return @@ -1174,8 +1310,13 @@ function StepVerify({ device, onVerified }) { 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 + // received_at already comes back as a full ISO-8601 string with an + // explicit UTC offset (e.g. "2026-07-14T16:03:21+00:00") — see + // backend/database/pg_mqtt.py::_row_to_dict. Parsing it directly is + // correct; appending a literal "Z" (the old code) turns it into an + // invalid string like "...+00:00Z", so Date.parse always returned + // NaN and a live device could never be detected as verified. + const receivedMs = latest.received_at ? Date.parse(latest.received_at) : NaN if (!isNaN(receivedMs) && receivedMs > startTime) { clearInterval(intervalRef.current) clearTimeout(timeoutRef.current) @@ -1211,66 +1352,73 @@ function StepVerify({ device, onVerified }) { clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false) } - return ( -
-
-

- Waiting for Device -

+ const StatusPanel = ( +
+

+ Waiting for Device +

- {polling && !verified && ( -
- -

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

- -
- )} + {polling && !verified && ( +
+ +

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

+ +
+ )} - {verified && heartbeatData && ( -
-
-
- -
-

Device is live!

-
-
- - - - + {verified && heartbeatData && ( +
+
+
+
+

Device is live!

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

+ {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

) + + return ( +
+ {StatusPanel} + +
+ ) } // ─── Step 4: Done ───────────────────────────────────────────────────────────── -function StepDone({ device, onProvisionNext }) { +function StepDone({ device, onProvisionNext, serialConn }) { const navigate = useNavigate() const [showSerialLogs, setShowSerialLogs] = useState(false) @@ -1314,11 +1462,21 @@ function StepDone({ device, onProvisionNext }) {
-
- + -
- setShowSerialLogs(false)} logs={[]} /> + setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
) } @@ -1345,6 +1503,12 @@ export default function ProvisioningWizard() { const [bespokeOverride, setBespokeOverride] = useState(null) const createdSnRef = useRef(null) + // Owned here (not inside StepFlash) so the same live WebSerial session — + // and the log lines it has accumulated — survives the Flash → Verify step + // transition instead of being torn down when StepFlash unmounts. + const serialConn = useSerialConnection() + useEffect(() => () => { serialConn.disconnect() }, []) // eslint-disable-line react-hooks/exhaustive-deps + const handleModePick = (m) => { setMode(m); setStep(1) } const handleDeviceSelected = (d, bespoke = null) => { @@ -1364,7 +1528,7 @@ export default function ProvisioningWizard() { } return ( -
+