/** * FlashModal — hardware flash UI for a single board on the canvas. * * Opened from the board context menu's "Flash to real board" item. * Walks the user through: * 1. Picking a USB serial port (auto-enumerated) * 2. Triggering the flash (streams arduino-cli output live) * 3. Showing success / error with the option to retry * * Two backends: * - Desktop (Tauri): enumerates ports via the sidecar and streams * arduino-cli output over SSE. * - Web: if the pro overlay installed a Web Serial flasher for this * board kind (see `lib/proWebFlash.ts`), the browser's own port * picker replaces the dropdown — the modal opens on a single * "Connect & Flash" button. Without an overlay (pure OSS web * build), it shows the "requires Velxio Desktop" fallback. */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { BoardInstance } from '../../store/useSimulatorStore'; import { isTauri, listSerialPorts, type SerialPortInfo } from '../../desktop/tauriBridge'; import { streamFlash, type FlashEvent } from '../../services/flashService'; import { getWebFlashImpl, webFlashAvailable, webFlashMpyAvailable, } from '../../lib/proWebFlash'; import { useEditorStore } from '../../store/useEditorStore'; interface Props { board: BoardInstance; fqbn: string; onClose: () => void; } type ModalState = | { kind: 'loading-ports' } | { kind: 'picking'; ports: SerialPortInfo[]; selectedPath: string | null } | { kind: 'web-ready' } | { kind: 'flashing'; port: string; log: string[]; progress: number } | { kind: 'success'; port: string; elapsedMs: number; log: string[] } | { kind: 'error'; port: string | null; message: string; log: string[] }; export const FlashModal = ({ board, fqbn, onClose }: Props) => { const [state, setState] = useState({ kind: 'loading-ports' }); // Keep the latest log in a ref so the flash generator's setState // calls aren't accumulating stale array copies. const logRef = useRef([]); // Web Serial mode: the pro overlay's flasher handles this board in // this browser (the impl is installed before any modal can open). const isWebMode = !isTauri() && webFlashAvailable(board.boardKind); // MicroPython projects use the firmware-install + raw-REPL upload path. const isMpy = board.languageMode === 'micropython'; const mpyWebOk = isMpy && !isTauri() && webFlashMpyAvailable(board.boardKind); // Abort handle for an in-flight web flash (closing the modal cancels). const abortRef = useRef(null); // ── Initial state: enumerate ports (desktop) or arm the web flow ── useEffect(() => { if (!isTauri()) { // Web Serial's requestPort() must run from a user gesture, so the // web flow can't start here — it waits on "Connect & Flash". setState( isWebMode ? { kind: 'web-ready' } : { kind: 'error', port: null, message: 'Hardware flashing requires Velxio Desktop. The web app cannot ' + 'access local USB serial ports.', log: [], }, ); return; } let cancelled = false; void (async () => { const ports = await listSerialPorts(); if (cancelled) return; setState({ kind: 'picking', ports, selectedPath: ports[0]?.path ?? null, }); })(); return () => { cancelled = true; }; }, [isWebMode]); const refreshPorts = useCallback(async () => { setState({ kind: 'loading-ports' }); const ports = await listSerialPorts(); setState({ kind: 'picking', ports, selectedPath: ports[0]?.path ?? null, }); }, []); // ── Trigger the flash ──────────────────────────────────────────── const doFlash = useCallback( async (port: string) => { if (!board.compiledProgram) { setState({ kind: 'error', port, message: 'No compiled program for this board. Compile the sketch first ' + '(Compile button in the toolbar).', log: [], }); return; } logRef.current = []; setState({ kind: 'flashing', port, log: [], progress: 0 }); const fmt = formatForFqbn(fqbn); try { for await (const ev of streamFlash({ boardId: board.id, port, fqbn, programFormat: fmt, programData: board.compiledProgram, })) { if (ev.phase === 'done') { if (ev.success) { setState({ kind: 'success', port, elapsedMs: ev.elapsed_ms, log: [...logRef.current], }); } else { setState({ kind: 'error', port, message: ev.error, log: [...logRef.current], }); } return; } if ('line' in ev) { logRef.current = [...logRef.current, ev.line]; } setState((prev) => { if (prev.kind !== 'flashing') return prev; return { ...prev, log: logRef.current, progress: ev.phase === 'writing' && ev.progress !== undefined ? ev.progress : prev.progress, }; }); } } catch (err) { setState({ kind: 'error', port, message: err instanceof Error ? err.message : String(err), log: [...logRef.current], }); } }, [board.compiledProgram, board.id, fqbn], ); // ── Trigger a Web Serial flash (pro overlay backend) ───────────── const doWebFlash = useCallback(async () => { const impl = getWebFlashImpl(); if (!impl) return; if (!isMpy && !board.compiledProgram) { setState({ kind: 'error', port: null, message: 'No compiled program for this board. Compile the sketch first ' + '(Compile button in the toolbar).', log: [], }); return; } logRef.current = []; setState({ kind: 'flashing', port: 'Web Serial', log: [], progress: 0 }); const ctrl = new AbortController(); abortRef.current = ctrl; const onProgress = (p: { phase: string; pct: number; line?: string }) => { if (p.line) { logRef.current = [...logRef.current, p.line]; } setState((prev) => { if (prev.kind !== 'flashing') return prev; return { ...prev, log: logRef.current, // Seam reports 0-100; the bar renders 0-1 like the SSE path. progress: p.phase === 'writing' || p.phase === 'uploading' ? p.pct / 100 : prev.progress, }; }); }; try { const result = isMpy && impl.flashMicroPython ? await impl.flashMicroPython({ boardId: board.id, boardKind: board.boardKind, // Same file collection as the MicroPython Run path // (EditorToolbar): the board's workspace group. files: useEditorStore .getState() .getGroupFiles(board.activeFileGroupId) .map((f) => ({ name: f.name, content: f.content })), signal: ctrl.signal, onProgress, }) : await impl.flash({ boardId: board.id, boardKind: board.boardKind, binaryBase64: board.compiledProgram ?? '', signal: ctrl.signal, onProgress, }); setState({ kind: 'success', port: result.chipName, elapsedMs: result.elapsedMs, log: [...logRef.current], }); } catch (err) { if (ctrl.signal.aborted) return; // user cancelled — modal is closing setState({ kind: 'error', port: null, message: err instanceof Error ? err.message : String(err), log: [...logRef.current], }); } finally { abortRef.current = null; } }, [board.compiledProgram, board.id, board.boardKind, board.activeFileGroupId, isMpy]); const handleClose = useCallback(() => { abortRef.current?.abort(); onClose(); }, [onClose]); // ── Render ────────────────────────────────────────────────────── const boardLabel = board.boardKind; return (

Flash {boardLabel}

{state.kind === 'loading-ports' && (
Detecting USB serial ports...
)} {state.kind === 'picking' && ( setState({ ...state, selectedPath: p })} onRefresh={() => void refreshPorts()} onFlash={(p) => void doFlash(p)} /> )} {state.kind === 'web-ready' && ( void doWebFlash()} /> )} {(state.kind === 'flashing' || state.kind === 'success' || state.kind === 'error') && ( isWebMode ? void doWebFlash() : state.port && void doFlash(state.port) } onClose={handleClose} onBackToPicker={() => isWebMode ? setState({ kind: 'web-ready' }) : void refreshPorts() } /> )}
); }; // ── Picker subview ────────────────────────────────────────────────── interface PickerProps { board: BoardInstance; ports: SerialPortInfo[]; selected: string | null; onSelect: (path: string) => void; onRefresh: () => void; onFlash: (path: string) => void; } const PickerView = ({ board, ports, selected, onSelect, onRefresh, onFlash }: PickerProps) => { const hasCompiled = !!board.compiledProgram; if (ports.length === 0) { return (
No USB serial ports detected.
Plug your board into a USB port and click Refresh. On Linux, you may need to add yourself to the dialout group:
              sudo usermod -a -G dialout $USER
            
(log out + back in after running.)
); } return (
{!hasCompiled && (
No compiled program for this board yet. Click Compile in the toolbar first.
)}
); }; // ── Web Serial subview ────────────────────────────────────────────── interface WebReadyProps { board: BoardInstance; /** MicroPython path available (overlay implements firmware+upload). */ mpyWebOk: boolean; onFlash: () => void; } const WebReadyView = ({ board, mpyWebOk, onFlash }: WebReadyProps) => { // MicroPython boards carry the 'micropython-loaded' sentinel instead of // a flash image — they flash only via the overlay's MicroPython path // (firmware install + raw-REPL file upload), which needs no compile. const isMpy = board.languageMode === 'micropython'; const canFlash = isMpy ? mpyWebOk : !!board.compiledProgram; return (
Flash over USB, right from the browser.
Plug the board in via USB and click Connect & Flash — your browser will ask which serial port to use. Close any other program using the port (serial monitors, IDEs) first. {isMpy && mpyWebOk && ( <> {' '}This MicroPython project flashes in two steps: the MicroPython firmware is installed first if the board doesn't have it (about a minute), then your .py files are uploaded and the board reboots into main.py. )}
{isMpy && !mpyWebOk && (
MicroPython projects cannot be flashed to a real board from here.
)} {!board.compiledProgram && !isMpy && (
No compiled program for this board yet. Click Compile in the toolbar first.
)}
); }; // ── Progress / success / error subview ────────────────────────────── interface ProgressProps { state: | { kind: 'flashing'; port: string; log: string[]; progress: number } | { kind: 'success'; port: string; elapsedMs: number; log: string[] } | { kind: 'error'; port: string | null; message: string; log: string[] }; /** Web Serial backend: closing cancels the flash and there is no port picker. */ webMode: boolean; onRetry: () => void; onClose: () => void; onBackToPicker: () => void; } const ProgressView = ({ state, webMode, onRetry, onClose, onBackToPicker }: ProgressProps) => { const logRef = useRef(null); // Auto-scroll the log to the bottom as new lines come in. useEffect(() => { if (logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [state.log]); return (
{state.kind === 'flashing' && ( <>
Flashing on {state.port}...
{Math.round(state.progress * 100)}%
)} {state.kind === 'success' && (
✓ Flashed successfully in {(state.elapsedMs / 1000).toFixed(1)}s
)} {state.kind === 'error' && (
{state.message}
)}
        {state.log.join('\n') || '(no output yet)'}
      
{state.kind === 'flashing' ? ( Don't unplug the board while flashing. ) : ( )}
{state.kind === 'error' && ( )}
); }; // ── Helpers + shared styles ───────────────────────────────────────── function portLabel(p: SerialPortInfo): string { const parts: string[] = [p.path]; if (p.product || p.manufacturer) { parts.push('-', p.product ?? p.manufacturer ?? ''); } if (p.vid !== undefined && p.vid !== null && p.pid !== undefined && p.pid !== null) { parts.push(`(${hex4(p.vid)}:${hex4(p.pid)})`); } return parts.join(' '); } function hex4(n: number): string { return n.toString(16).padStart(4, '0'); } /** * Decide the program file extension based on the FQBN. Mirrors the * formats arduino-cli expects per uploader (avrdude wants .hex, * esptool wants .bin, picotool accepts either .uf2 or .bin). */ function formatForFqbn(fqbn: string): 'hex' | 'bin' | 'uf2' | 'elf' { if (fqbn.startsWith('arduino:avr') || fqbn.startsWith('ATTinyCore:avr')) { return 'hex'; } if (fqbn.startsWith('esp32:esp32')) return 'bin'; if (fqbn.startsWith('rp2040:rp2040')) return 'uf2'; if (fqbn.startsWith('arduino:samd')) return 'bin'; // Defensive fallback - arduino-cli's auto-detection should still // do the right thing in most cases. return 'bin'; } const closeBtnStyle: React.CSSProperties = { width: 28, height: 28, padding: 0, background: 'transparent', border: '1px solid #2c2c33', borderRadius: 4, color: '#999', fontSize: 18, cursor: 'pointer', lineHeight: 1, }; const primaryBtnStyle: React.CSSProperties = { padding: '7px 16px', fontSize: 13, fontWeight: 600, color: 'white', background: 'linear-gradient(135deg, #007acc 0%, #005ea1 100%)', border: '1px solid #005ea1', borderRadius: 4, cursor: 'pointer', fontFamily: 'inherit', }; const secondaryBtnStyle: React.CSSProperties = { padding: '7px 14px', fontSize: 13, color: '#bbb', background: 'transparent', border: '1px solid #2c2c33', borderRadius: 4, cursor: 'pointer', fontFamily: 'inherit', }; const selectStyle: React.CSSProperties = { width: '100%', padding: '8px 10px', background: '#0c0c11', color: '#e6e6e9', border: '1px solid #2c2c33', borderRadius: 4, fontSize: 13, fontFamily: 'inherit', };