feat: add MicroPython support for ESP32/ESP32-S3 boards via QEMU bridge

Extends MicroPython support (Phase 2) to ESP32 Xtensa boards running on
QEMU. Firmware is downloaded from micropython.org, cached in IndexedDB,
and loaded into QEMU. User code is injected via the raw-paste REPL
protocol after the MicroPython REPL boots.

- Create Esp32MicroPythonLoader.ts for firmware download/cache
- Add raw-paste REPL injection (Ctrl+A → Ctrl+E → code → Ctrl+D) to Esp32Bridge
- Extend loadMicroPythonProgram in store for ESP32 path
- Add ESP32 default MicroPython content (GPIO 2 blink)
- Simplify SerialMonitor Ctrl+C/D to work for all MicroPython boards
- Bundle fallback firmware for ESP32 and ESP32-S3
- Add all ESP32 board variants to SerialMonitor tab maps

Closes #3 (Phase 2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feature/micropython-rp2040
David Montero Crespo 2026-03-29 23:12:24 -03:00
parent 990ae4be8c
commit 7c9fd0bf2b
8 changed files with 296 additions and 33 deletions

Binary file not shown.

Binary file not shown.

View File

@ -4,43 +4,69 @@
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useSimulatorStore, getBoardSimulator } from '../../store/useSimulatorStore';
import { RP2040Simulator } from '../../simulation/RP2040Simulator';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
// Short labels for tabs
const BOARD_SHORT_LABEL: Record<BoardKind, string> = {
const BOARD_SHORT_LABEL: Partial<Record<BoardKind, string>> = {
'arduino-uno': 'Uno',
'arduino-nano': 'Nano',
'arduino-mega': 'Mega',
'raspberry-pi-pico': 'Pico',
'pi-pico-w': 'Pico W',
'raspberry-pi-3': 'Pi 3B',
'esp32': 'ESP32',
'esp32-s3': 'ESP32-S3',
'esp32-c3': 'ESP32-C3',
'esp32': 'ESP32',
'esp32-devkit-c-v4': 'ESP32',
'esp32-cam': 'ESP32-CAM',
'wemos-lolin32-lite':'Lolin32',
'esp32-s3': 'ESP32-S3',
'xiao-esp32-s3': 'XIAO-S3',
'arduino-nano-esp32':'Nano ESP32',
'esp32-c3': 'ESP32-C3',
'xiao-esp32-c3': 'XIAO-C3',
'aitewinrobot-esp32c3-supermini': 'C3 Mini',
'attiny85': 'ATtiny85',
};
const BOARD_ICON: Record<BoardKind, string> = {
const BOARD_ICON: Partial<Record<BoardKind, string>> = {
'arduino-uno': '⬤',
'arduino-nano': '▪',
'arduino-mega': '▬',
'raspberry-pi-pico': '◆',
'pi-pico-w': '◆',
'raspberry-pi-3': '⬛',
'esp32': '⬡',
'esp32-s3': '⬡',
'esp32-c3': '⬡',
'esp32': '⬡',
'esp32-devkit-c-v4': '⬡',
'esp32-cam': '⬡',
'wemos-lolin32-lite':'⬡',
'esp32-s3': '⬡',
'xiao-esp32-s3': '⬡',
'arduino-nano-esp32':'⬡',
'esp32-c3': '⬡',
'xiao-esp32-c3': '⬡',
'aitewinrobot-esp32c3-supermini': '⬡',
'attiny85': '▪',
};
const BOARD_COLOR: Record<BoardKind, string> = {
const BOARD_COLOR: Partial<Record<BoardKind, string>> = {
'arduino-uno': '#4fc3f7',
'arduino-nano': '#4fc3f7',
'arduino-mega': '#4fc3f7',
'raspberry-pi-pico': '#ce93d8',
'pi-pico-w': '#ce93d8',
'raspberry-pi-3': '#ef9a9a',
'esp32': '#a5d6a7',
'esp32-s3': '#a5d6a7',
'esp32-c3': '#a5d6a7',
'esp32': '#a5d6a7',
'esp32-devkit-c-v4': '#a5d6a7',
'esp32-cam': '#a5d6a7',
'wemos-lolin32-lite':'#a5d6a7',
'esp32-s3': '#a5d6a7',
'xiao-esp32-s3': '#a5d6a7',
'arduino-nano-esp32':'#a5d6a7',
'esp32-c3': '#a5d6a7',
'xiao-esp32-c3': '#a5d6a7',
'aitewinrobot-esp32c3-supermini': '#a5d6a7',
'attiny85': '#ffcc80',
};
export const SerialMonitor: React.FC = () => {
@ -103,17 +129,14 @@ export const SerialMonitor: React.FC = () => {
handleSend();
return;
}
// MicroPython REPL control characters
if (resolvedTabId && e.ctrlKey) {
const sim = getBoardSimulator(resolvedTabId);
if (sim instanceof RP2040Simulator && sim.isMicroPythonMode()) {
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
sim.serialWriteByte(0x03); // Ctrl+C — keyboard interrupt
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
sim.serialWriteByte(0x04); // Ctrl+D — soft reset
}
// MicroPython REPL control characters (works for RP2040 and ESP32)
if (resolvedTabId && isMicroPython && e.ctrlKey) {
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
serialWriteToBoard(resolvedTabId, '\x03'); // Ctrl+C — keyboard interrupt
} else if (e.key === 'd' || e.key === 'D') {
e.preventDefault();
serialWriteToBoard(resolvedTabId, '\x04'); // Ctrl+D — soft reset
}
}
}, [handleSend, resolvedTabId]);
@ -143,7 +166,7 @@ export const SerialMonitor: React.FC = () => {
<div style={styles.tabStrip}>
{boards.map((board) => {
const isActive = board.id === resolvedTabId;
const color = BOARD_COLOR[board.boardKind];
const color = BOARD_COLOR[board.boardKind] ?? '#999';
const hasUnread = (board.serialOutput.length) > (lastSeenLen[board.id] ?? 0);
return (
<button
@ -156,9 +179,9 @@ export const SerialMonitor: React.FC = () => {
title={BOARD_KIND_LABELS[board.boardKind]}
>
<span style={{ fontSize: 9, marginRight: 3, color: isActive ? color : '#888' }}>
{BOARD_ICON[board.boardKind]}
{BOARD_ICON[board.boardKind] ?? '●'}
</span>
{BOARD_SHORT_LABEL[board.boardKind]}
{BOARD_SHORT_LABEL[board.boardKind] ?? board.boardKind}
{hasUnread && !isActive && <span style={styles.unreadDot} />}
</button>
);

View File

@ -36,7 +36,7 @@ import type { BoardKind } from '../types/board';
* Map any ESP32-family board kind to the 3 base QEMU machine types understood
* by the backend esp_qemu_manager.
*/
function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
export function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
if (kind === 'esp32-s3' || kind === 'xiao-esp32-s3' || kind === 'arduino-nano-esp32') return 'esp32-s3';
if (kind === 'esp32-c3' || kind === 'xiao-esp32-c3' || kind === 'aitewinrobot-esp32c3-supermini') return 'esp32-c3';
return 'esp32'; // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite
@ -84,6 +84,11 @@ export class Esp32Bridge {
private _pendingFirmware: string | null = null;
private _pendingSensors: Array<Record<string, unknown>> = [];
// MicroPython raw-paste REPL injection
private _pendingMicroPythonCode: string | null = null;
private _serialBuffer = '';
micropythonMode = false;
constructor(boardId: string, boardKind: BoardKind) {
this.boardId = boardId;
this.boardKind = boardKind;
@ -134,6 +139,19 @@ export class Esp32Bridge {
if (this.onSerialData) {
for (const ch of text) this.onSerialData(ch, uart);
}
// Detect MicroPython REPL prompt and inject pending code
if (this._pendingMicroPythonCode) {
this._serialBuffer += text;
if (this._serialBuffer.includes('>>>')) {
this._injectCodeViaRawPaste(this._pendingMicroPythonCode);
this._pendingMicroPythonCode = null;
this._serialBuffer = '';
}
// Prevent buffer from growing indefinitely
if (this._serialBuffer.length > 4096) {
this._serialBuffer = this._serialBuffer.slice(-512);
}
}
break;
}
case 'gpio_change': {
@ -283,6 +301,68 @@ export class Esp32Bridge {
this._send({ type: 'esp32_sensor_detach', data: { pin } });
}
/**
* Queue user MicroPython code for injection after the REPL boots.
* The code will be sent via raw-paste protocol once `>>>` is detected.
*/
setPendingMicroPythonCode(code: string): void {
this._pendingMicroPythonCode = code;
this._serialBuffer = '';
this.micropythonMode = true;
}
/** Check if this bridge is in MicroPython mode */
isMicroPythonMode(): boolean {
return this.micropythonMode;
}
/**
* Inject code via MicroPython raw-paste REPL protocol:
* \x01 (Ctrl+A) enter raw REPL
* \x05 (Ctrl+E) enter raw-paste mode
* <code bytes>
* \x04 (Ctrl+D) execute
*/
private _injectCodeViaRawPaste(code: string): void {
console.log(`[Esp32Bridge:${this.boardId}] Injecting MicroPython code (${code.length} bytes) via raw-paste REPL`);
// Small delay to let the REPL fully initialize after printing >>>
setTimeout(() => {
// Step 1: Enter raw REPL (Ctrl+A)
this.sendSerialBytes([0x01]);
setTimeout(() => {
// Step 2: Enter raw-paste mode (Ctrl+E)
this.sendSerialBytes([0x05]);
setTimeout(() => {
// Step 3: Send code bytes in chunks (avoid overwhelming the serial)
const encoder = new TextEncoder();
const codeBytes = encoder.encode(code);
const CHUNK_SIZE = 256;
let offset = 0;
const sendChunk = () => {
if (offset >= codeBytes.length) {
// Step 4: Execute (Ctrl+D)
setTimeout(() => {
this.sendSerialBytes([0x04]);
console.log(`[Esp32Bridge:${this.boardId}] MicroPython code injection complete`);
}, 50);
return;
}
const end = Math.min(offset + CHUNK_SIZE, codeBytes.length);
const chunk = Array.from(codeBytes.slice(offset, end));
this.sendSerialBytes(chunk);
offset = end;
setTimeout(sendChunk, 10);
};
sendChunk();
}, 100);
}, 100);
}, 500);
}
private _send(payload: unknown): void {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));

View File

@ -0,0 +1,119 @@
/**
* Esp32MicroPythonLoader Downloads and caches MicroPython firmware for ESP32 boards
*
* Supports ESP32 (Xtensa LX6) and ESP32-S3 (Xtensa LX7).
* Firmware is cached in IndexedDB for fast subsequent loads.
* Falls back to bundled firmware in public/firmware/ if remote download fails.
*/
import { get as idbGet, set as idbSet } from 'idb-keyval';
import type { BoardKind } from '../types/board';
interface FirmwareConfig {
remote: string;
cacheKey: string;
fallback: string;
}
const FIRMWARE_MAP: Record<string, FirmwareConfig> = {
'esp32': {
remote: 'https://micropython.org/resources/firmware/ESP32_GENERIC-20230426-v1.20.0.bin',
cacheKey: 'micropython-esp32-v1.20.0',
fallback: '/firmware/micropython-esp32.bin',
},
'esp32-s3': {
remote: 'https://micropython.org/resources/firmware/ESP32_GENERIC_S3-20230426-v1.20.0.bin',
cacheKey: 'micropython-esp32s3-v1.20.0',
fallback: '/firmware/micropython-esp32s3.bin',
},
};
/** Map any ESP32-family board kind to firmware variant key */
function toFirmwareVariant(boardKind: BoardKind): 'esp32' | 'esp32-s3' {
if (boardKind === 'esp32-s3' || boardKind === 'xiao-esp32-s3' || boardKind === 'arduino-nano-esp32') {
return 'esp32-s3';
}
return 'esp32';
}
/**
* Get MicroPython firmware binary for an ESP32 board.
* Checks IndexedDB cache first, then remote, then bundled fallback.
*/
export async function getEsp32Firmware(
boardKind: BoardKind,
onProgress?: (loaded: number, total: number) => void,
): Promise<Uint8Array> {
const variant = toFirmwareVariant(boardKind);
const config = FIRMWARE_MAP[variant];
if (!config) throw new Error(`No MicroPython firmware for board: ${boardKind}`);
// 1. Check IndexedDB cache
try {
const cached = await idbGet(config.cacheKey);
if (cached instanceof Uint8Array && cached.length > 0) {
console.log(`[ESP32-MicroPython] Firmware loaded from cache (${variant})`);
return cached;
}
} catch {
// IndexedDB unavailable
}
// 2. Try remote download
try {
const response = await fetch(config.remote);
if (response.ok) {
const total = Number(response.headers.get('content-length') || 0);
const reader = response.body?.getReader();
if (reader) {
const chunks: Uint8Array[] = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
onProgress?.(loaded, total);
}
const firmware = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
firmware.set(chunk, offset);
offset += chunk.length;
}
try { await idbSet(config.cacheKey, firmware); } catch { /* non-fatal */ }
console.log(`[ESP32-MicroPython] Firmware downloaded (${variant}, ${firmware.length} bytes)`);
return firmware;
}
}
} catch {
console.warn(`[ESP32-MicroPython] Remote download failed for ${variant}, trying bundled fallback`);
}
// 3. Fallback to bundled firmware
const response = await fetch(config.fallback);
if (!response.ok) {
throw new Error(`MicroPython firmware not available for ${variant} (remote and bundled both failed)`);
}
const buffer = await response.arrayBuffer();
const firmware = new Uint8Array(buffer);
try { await idbSet(config.cacheKey, firmware); } catch { /* non-fatal */ }
console.log(`[ESP32-MicroPython] Firmware loaded from bundled fallback (${variant}, ${firmware.length} bytes)`);
return firmware;
}
/** Convert Uint8Array to base64 string */
export function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

View File

@ -32,6 +32,17 @@ while True:
time.sleep(1)
`;
const DEFAULT_ESP32_MICROPYTHON_CONTENT = `# MicroPython Blink for ESP32
from machine import Pin
import time
led = Pin(2, Pin.OUT) # Built-in LED on GPIO 2
while True:
led.toggle()
time.sleep(1)
`;
const DEFAULT_PY_CONTENT = `import RPi.GPIO as GPIO
import time
@ -297,7 +308,11 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const mainId = `${groupId}-main`;
let fileName: string;
let content: string;
if (isMicroPython) {
const isEsp32 = groupId.includes('esp32');
if (isMicroPython && isEsp32) {
fileName = 'main.py';
content = DEFAULT_ESP32_MICROPYTHON_CONTENT;
} else if (isMicroPython) {
fileName = 'main.py';
content = DEFAULT_MICROPYTHON_CONTENT;
} else if (isPi) {

View File

@ -587,10 +587,27 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (!board) return;
if (!BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return;
const sim = getBoardSimulator(boardId);
if (!(sim instanceof RP2040Simulator)) return;
if (isEsp32Kind(board.boardKind)) {
// ESP32 path: load MicroPython firmware via QEMU bridge, inject code via raw-paste REPL
const { getEsp32Firmware, uint8ArrayToBase64 } = await import('../simulation/Esp32MicroPythonLoader');
const esp32Bridge = getEsp32Bridge(boardId);
if (!esp32Bridge) return;
await sim.loadMicroPython(files);
const firmware = await getEsp32Firmware(board.boardKind);
const b64 = uint8ArrayToBase64(firmware);
esp32Bridge.loadFirmware(b64);
// Queue code injection for after REPL boots
const mainFile = files.find(f => f.name === 'main.py') ?? files[0];
if (mainFile) {
esp32Bridge.setPendingMicroPythonCode(mainFile.content);
}
} else {
// RP2040 path: load firmware + filesystem in browser
const sim = getBoardSimulator(boardId);
if (!(sim instanceof RP2040Simulator)) return;
await sim.loadMicroPython(files);
}
set((s) => {
const boards = s.boards.map((b) =>

View File

@ -22,6 +22,15 @@ export type LanguageMode = 'arduino' | 'micropython';
export const BOARD_SUPPORTS_MICROPYTHON = new Set<BoardKind>([
'raspberry-pi-pico',
'pi-pico-w',
// ESP32 Xtensa (QEMU bridge)
'esp32',
'esp32-devkit-c-v4',
'esp32-cam',
'wemos-lolin32-lite',
// ESP32-S3 Xtensa (QEMU bridge)
'esp32-s3',
'xiao-esp32-s3',
'arduino-nano-esp32',
]);
export interface BoardInstance {