diff --git a/frontend/src/__tests__/pro-web-flash-seam.test.ts b/frontend/src/__tests__/pro-web-flash-seam.test.ts index 9961a7b3..8f17898e 100644 --- a/frontend/src/__tests__/pro-web-flash-seam.test.ts +++ b/frontend/src/__tests__/pro-web-flash-seam.test.ts @@ -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( diff --git a/frontend/src/components/simulator/FlashModal.tsx b/frontend/src/components/simulator/FlashModal.tsx index 29fc882c..3edce114 100644 --- a/frontend/src/components/simulator/FlashModal.tsx +++ b/frontend/src/components/simulator/FlashModal.tsx @@ -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(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' && ( - void doWebFlash()} /> + 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 (
@@ -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. + + )}
- {isMpy && ( + {isMpy && !mpyWebOk && (
- 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.
)} @@ -437,9 +477,9 @@ const WebReadyView = ({ board, onFlash }: WebReadyProps) => {
diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 98444c93..5e33a069 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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', diff --git a/frontend/src/lib/proWebFlash.ts b/frontend/src/lib/proWebFlash.ts index 9f82eb3d..f9f50de3 100644 --- a/frontend/src/lib/proWebFlash.ts +++ b/frontend/src/lib/proWebFlash.ts @@ -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; + /** + * 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; } 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); +} diff --git a/frontend/src/simulation/Esp32MicroPythonLoader.ts b/frontend/src/simulation/Esp32MicroPythonLoader.ts index 4db04f0b..93f3d4f2 100644 --- a/frontend/src/simulation/Esp32MicroPythonLoader.ts +++ b/frontend/src/simulation/Esp32MicroPythonLoader.ts @@ -34,7 +34,7 @@ const FIRMWARE_MAP: Record = { }; /** 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' ||