velxio/frontend/src/components/simulator/FlashModal.tsx

673 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<ModalState>({ 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<string[]>([]);
// 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<AbortController | null>(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 (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.6)',
zIndex: 9600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 560,
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'calc(100vh - 64px)',
background: '#1a1d24',
color: '#e6e6e9',
border: '1px solid #2c2c33',
borderRadius: 8,
padding: 20,
boxShadow: '0 12px 36px rgba(0,0,0,0.7)',
display: 'flex',
flexDirection: 'column',
gap: 14,
fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
Flash {boardLabel}
</h2>
<button
type="button"
onClick={handleClose}
style={closeBtnStyle}
aria-label="Close"
>
×
</button>
</div>
{state.kind === 'loading-ports' && (
<div style={{ padding: '24px 0', textAlign: 'center', color: '#888' }}>
Detecting USB serial ports...
</div>
)}
{state.kind === 'picking' && (
<PickerView
board={board}
ports={state.ports}
selected={state.selectedPath}
onSelect={(p) => setState({ ...state, selectedPath: p })}
onRefresh={() => void refreshPorts()}
onFlash={(p) => void doFlash(p)}
/>
)}
{state.kind === 'web-ready' && (
<WebReadyView
board={board}
mpyWebOk={mpyWebOk}
onFlash={() => void doWebFlash()}
/>
)}
{(state.kind === 'flashing' ||
state.kind === 'success' ||
state.kind === 'error') && (
<ProgressView
state={state}
webMode={isWebMode}
onRetry={() =>
isWebMode
? void doWebFlash()
: state.port && void doFlash(state.port)
}
onClose={handleClose}
onBackToPicker={() =>
isWebMode ? setState({ kind: 'web-ready' }) : void refreshPorts()
}
/>
)}
</div>
</div>
);
};
// ── 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 (
<div>
<div style={{ padding: 16, background: '#0c0c11', borderRadius: 4, marginBottom: 12 }}>
<div style={{ color: '#aaa', fontSize: 13, marginBottom: 8 }}>
No USB serial ports detected.
</div>
<div style={{ color: '#777', fontSize: 12, lineHeight: 1.5 }}>
Plug your board into a USB port and click Refresh. On Linux,
you may need to add yourself to the dialout group:
<pre style={{ marginTop: 6, fontSize: 11 }}>
sudo usermod -a -G dialout $USER
</pre>
(log out + back in after running.)
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onRefresh} style={primaryBtnStyle}>
Refresh
</button>
</div>
</div>
);
}
return (
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 12, color: '#aaa' }}>
Serial port
</label>
<select
value={selected ?? ''}
onChange={(e) => onSelect(e.target.value)}
style={selectStyle}
>
{ports.map((p) => (
<option key={p.path} value={p.path}>
{portLabel(p)}
</option>
))}
</select>
{!hasCompiled && (
<div style={{ marginTop: 10, padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12 }}>
No compiled program for this board yet. Click Compile in the toolbar first.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14 }}>
<button type="button" onClick={onRefresh} style={secondaryBtnStyle}>
Refresh ports
</button>
<button
type="button"
disabled={!selected || !hasCompiled}
onClick={() => selected && onFlash(selected)}
style={{ ...primaryBtnStyle, opacity: !selected || !hasCompiled ? 0.5 : 1 }}
>
Flash
</button>
</div>
</div>
);
};
// ── 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 (
<div>
<div style={{ padding: 16, background: '#0c0c11', borderRadius: 4, marginBottom: 12 }}>
<div style={{ color: '#aaa', fontSize: 13, marginBottom: 8 }}>
Flash over USB, right from the browser.
</div>
<div style={{ color: '#777', fontSize: 12, lineHeight: 1.5 }}>
Plug the board in via USB and click Connect &amp; 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.
</>
)}
</div>
</div>
{isMpy && !mpyWebOk && (
<div style={{ padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12, marginBottom: 12 }}>
MicroPython projects cannot be flashed to a real board from here.
</div>
)}
{!board.compiledProgram && !isMpy && (
<div style={{ padding: 10, background: '#3a2e1a', color: '#ffb84d', borderRadius: 4, fontSize: 12, marginBottom: 12 }}>
No compiled program for this board yet. Click Compile in the toolbar first.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={!canFlash}
onClick={onFlash}
style={{ ...primaryBtnStyle, opacity: canFlash ? 1 : 0.5 }}
>
Connect &amp; Flash
</button>
</div>
</div>
);
};
// ── 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<HTMLPreElement | null>(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 (
<div>
{state.kind === 'flashing' && (
<>
<div style={{ fontSize: 13, color: '#ccc', marginBottom: 8 }}>
Flashing on {state.port}...
</div>
<div style={{ height: 6, background: '#0c0c11', borderRadius: 3, overflow: 'hidden', marginBottom: 6 }}>
<div
style={{
height: '100%',
width: `${Math.round(state.progress * 100)}%`,
background: 'linear-gradient(90deg, #007acc 0%, #00a4ff 100%)',
transition: 'width 0.2s ease',
}}
/>
</div>
<div style={{ fontSize: 11, color: '#888', marginBottom: 12 }}>
{Math.round(state.progress * 100)}%
</div>
</>
)}
{state.kind === 'success' && (
<div style={{ padding: 12, background: '#143824', color: '#7ee87e', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
✓ Flashed successfully in {(state.elapsedMs / 1000).toFixed(1)}s
</div>
)}
{state.kind === 'error' && (
<div style={{ padding: 12, background: '#3a1a1a', color: '#ff8585', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
{state.message}
</div>
)}
<pre
ref={logRef}
style={{
height: 240,
margin: 0,
padding: 10,
background: '#0c0c11',
color: '#9aa5b1',
borderRadius: 4,
fontSize: 11,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{state.log.join('\n') || '(no output yet)'}
</pre>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 12 }}>
{state.kind === 'flashing' ? (
<span style={{ fontSize: 11, color: '#666' }}>
Don't unplug the board while flashing.
</span>
) : (
<button type="button" onClick={onBackToPicker} style={secondaryBtnStyle}>
{webMode ? 'Start over' : 'Pick another port'}
</button>
)}
<div style={{ display: 'flex', gap: 8 }}>
{state.kind === 'error' && (
<button type="button" onClick={onRetry} style={primaryBtnStyle}>
Retry
</button>
)}
<button type="button" onClick={onClose} style={secondaryBtnStyle}>
{state.kind === 'flashing' ? (webMode ? 'Cancel' : 'Hide') : 'Close'}
</button>
</div>
</div>
</div>
);
};
// ── 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',
};