feat(flash): MicroPython web-flash seam (firmware install + raw-REPL upload)

The proWebFlash seam gains an optional flashMicroPython(files) method
plus an 'uploading' progress phase. MicroPython boards enable the
Flash menu when the overlay implements it — no compile step needed,
the modal collects the workspace group's files (same path as the Run
flow) and explains the two-step install+upload. toFirmwareVariant is
exported so the flasher shares the emulator's per-chip offset table.
This commit is contained in:
David Montero Crespo 2026-08-11 22:55:47 +02:00
parent 820a0fa024
commit e3a71e0958
5 changed files with 131 additions and 40 deletions

View File

@ -7,6 +7,7 @@ import {
getWebFlashImpl,
installWebFlashImpl,
webFlashAvailable,
webFlashMpyAvailable,
type WebFlashImpl,
} from '../lib/proWebFlash';
@ -39,6 +40,18 @@ describe('proWebFlash seam', () => {
expect(webFlashAvailable('esp32')).toBe(false);
});
it('MicroPython availability requires the optional method AND board support', () => {
expect(webFlashMpyAvailable('esp32')).toBe(false); // no impl
installWebFlashImpl(fakeImpl((kind) => kind === 'esp32'));
expect(webFlashMpyAvailable('esp32')).toBe(false); // impl without flashMicroPython
installWebFlashImpl({
...fakeImpl((kind) => kind === 'esp32'),
flashMicroPython: vi.fn(),
});
expect(webFlashMpyAvailable('esp32')).toBe(true);
expect(webFlashMpyAvailable('arduino-uno')).toBe(false); // unsupported board
});
it('swallows a throwing available() instead of breaking the menu', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
installWebFlashImpl(

View File

@ -21,7 +21,12 @@ 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 } from '../../lib/proWebFlash';
import {
getWebFlashImpl,
webFlashAvailable,
webFlashMpyAvailable,
} from '../../lib/proWebFlash';
import { useEditorStore } from '../../store/useEditorStore';
interface Props {
board: BoardInstance;
@ -45,6 +50,9 @@ export const FlashModal = ({ board, fqbn, onClose }: Props) => {
// 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);
@ -166,7 +174,7 @@ export const FlashModal = ({ board, fqbn, onClose }: Props) => {
const doWebFlash = useCallback(async () => {
const impl = getWebFlashImpl();
if (!impl) return;
if (!board.compiledProgram) {
if (!isMpy && !board.compiledProgram) {
setState({
kind: 'error',
port: null,
@ -181,27 +189,45 @@ export const FlashModal = ({ board, fqbn, onClose }: Props) => {
setState({ kind: 'flashing', port: 'Web Serial', log: [], progress: 0 });
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
const result = await impl.flash({
boardId: board.id,
boardKind: board.boardKind,
binaryBase64: board.compiledProgram,
signal: ctrl.signal,
onProgress: (p) => {
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.pct / 100 : prev.progress,
};
});
},
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,
@ -219,7 +245,7 @@ export const FlashModal = ({ board, fqbn, onClose }: Props) => {
} finally {
abortRef.current = null;
}
}, [board.compiledProgram, board.id, board.boardKind]);
}, [board.compiledProgram, board.id, board.boardKind, board.activeFileGroupId, isMpy]);
const handleClose = useCallback(() => {
abortRef.current?.abort();
@ -292,7 +318,11 @@ export const FlashModal = ({ board, fqbn, onClose }: Props) => {
)}
{state.kind === 'web-ready' && (
<WebReadyView board={board} onFlash={() => void doWebFlash()} />
<WebReadyView
board={board}
mpyWebOk={mpyWebOk}
onFlash={() => void doWebFlash()}
/>
)}
{(state.kind === 'flashing' ||
@ -400,14 +430,17 @@ const PickerView = ({ board, ports, selected, onSelect, onRefresh, onFlash }: Pi
interface WebReadyProps {
board: BoardInstance;
/** MicroPython path available (overlay implements firmware+upload). */
mpyWebOk: boolean;
onFlash: () => void;
}
const WebReadyView = ({ board, onFlash }: WebReadyProps) => {
const WebReadyView = ({ board, mpyWebOk, onFlash }: WebReadyProps) => {
// MicroPython boards carry the 'micropython-loaded' sentinel instead of
// a flash image — nothing to write. Arduino/ESP-IDF sketches only.
// 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 hasCompiled = !!board.compiledProgram && !isMpy;
const canFlash = isMpy ? mpyWebOk : !!board.compiledProgram;
return (
<div>
<div style={{ padding: 16, background: '#0c0c11', borderRadius: 4, marginBottom: 12 }}>
@ -418,13 +451,20 @@ const WebReadyView = ({ board, onFlash }: WebReadyProps) => {
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 && (
{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 yet — only
compiled Arduino/ESP-IDF sketches.
MicroPython projects cannot be flashed to a real board from here.
</div>
)}
@ -437,9 +477,9 @@ const WebReadyView = ({ board, onFlash }: WebReadyProps) => {
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
disabled={!hasCompiled}
disabled={!canFlash}
onClick={onFlash}
style={{ ...primaryBtnStyle, opacity: hasCompiled ? 1 : 0.5 }}
style={{ ...primaryBtnStyle, opacity: canFlash ? 1 : 0.5 }}
>
Connect &amp; Flash
</button>

View File

@ -79,7 +79,7 @@ import {
} from '../../lib/proBoardGate';
import { FlashModal } from './FlashModal';
import { isTauri as isTauriRuntimeFn } from '../../desktop/tauriBridge';
import { webFlashAvailable } from '../../lib/proWebFlash';
import { webFlashAvailable, webFlashMpyAvailable } from '../../lib/proWebFlash';
import { isEsp32Family } from '../../types/boardOptions';
import { BoardOptionsModal } from './BoardOptionsModal';
import { useOscilloscopeStore } from '../../store/useOscilloscopeStore';
@ -3448,15 +3448,21 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
// for this board kind (no-op in pure OSS builds).
if (isTauriRuntime || webFlashAvailable(board.boardKind)) {
// MicroPython boards don't compile to a flash image — the store
// keeps the 'micropython-loaded' sentinel, which no flasher can
// write. Arduino/ESP-IDF sketches only.
// keeps the 'micropython-loaded' sentinel. They flash only when
// the overlay implements the MicroPython path (firmware install
// + raw-REPL file upload); no compile step is required there,
// the workspace files are always available.
const isMpy = board.languageMode === 'micropython';
const mpyWebOk =
isMpy && !isTauriRuntime && webFlashMpyAvailable(board.boardKind);
actions.push({
id: 'flash',
label: t('editor.canvas.flashToBoard'),
disabled: !board.compiledProgram || isMpy,
disabled: isMpy ? !mpyWebOk : !board.compiledProgram,
title: isMpy
? 'MicroPython projects cannot be flashed yet — Arduino sketches only'
? mpyWebOk
? 'Install MicroPython (if needed) and upload this project to a real USB-attached board'
: 'MicroPython projects cannot be flashed from here — Arduino sketches only'
: board.compiledProgram
? 'Flash the compiled sketch to a real USB-attached board'
: 'Compile the sketch first',

View File

@ -18,8 +18,8 @@
*/
export interface WebFlashProgress {
phase: 'connecting' | 'erasing' | 'writing' | 'resetting';
/** 0-100, meaningful during 'writing'. */
phase: 'connecting' | 'erasing' | 'writing' | 'uploading' | 'resetting';
/** 0-100, meaningful during 'writing' and 'uploading'. */
pct: number;
/** Optional log line to append to the modal's console. */
line?: string;
@ -35,6 +35,21 @@ export interface WebFlashRequest {
signal: AbortSignal;
}
/** A workspace source file, as the editor stores it. */
export interface WebFlashFile {
name: string;
content: string;
}
export interface MicroPythonFlashRequest {
boardId: string;
boardKind: string;
/** The project's files; .py files are written to the board's filesystem. */
files: WebFlashFile[];
onProgress: (p: WebFlashProgress) => void;
signal: AbortSignal;
}
export interface WebFlashResult {
/** Chip name as detected by the loader, e.g. "ESP32-S3". */
chipName: string;
@ -52,6 +67,13 @@ export interface WebFlashImpl {
* with an Error whose message is user-presentable.
*/
flash(req: WebFlashRequest): Promise<WebFlashResult>;
/**
* MicroPython path: install the MicroPython firmware if the board
* doesn't answer as a REPL, then upload the project's .py files over
* raw REPL and soft-reset so main.py runs. Optional — absent means
* MicroPython projects can't be flashed and the UI says so.
*/
flashMicroPython?(req: MicroPythonFlashRequest): Promise<WebFlashResult>;
}
let _impl: WebFlashImpl | null = null;
@ -81,3 +103,13 @@ export function webFlashAvailable(boardKind: string): boolean {
return false;
}
}
/**
* Whether MicroPython projects on `boardKind` can be flashed here:
* the installed impl must support the board AND implement the
* MicroPython path.
*/
export function webFlashMpyAvailable(boardKind: string): boolean {
if (!_impl?.flashMicroPython) return false;
return webFlashAvailable(boardKind);
}

View File

@ -34,7 +34,7 @@ const FIRMWARE_MAP: Record<string, FirmwareConfig> = {
};
/** Map any ESP32-family board kind to firmware variant key */
function toFirmwareVariant(boardKind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
export function toFirmwareVariant(boardKind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
if (
boardKind === 'esp32-s3' ||
boardKind === 'xiao-esp32-s3' ||