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:
parent
820a0fa024
commit
e3a71e0958
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 & 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 & Flash
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
Loading…
Reference in New Issue