diff --git a/frontend/public/firmware/micropython-esp32.bin b/frontend/public/firmware/micropython-esp32.bin new file mode 100644 index 0000000..560daf6 Binary files /dev/null and b/frontend/public/firmware/micropython-esp32.bin differ diff --git a/frontend/public/firmware/micropython-esp32s3.bin b/frontend/public/firmware/micropython-esp32s3.bin new file mode 100644 index 0000000..cd396f6 Binary files /dev/null and b/frontend/public/firmware/micropython-esp32s3.bin differ diff --git a/frontend/src/components/simulator/SerialMonitor.tsx b/frontend/src/components/simulator/SerialMonitor.tsx index dc55ed2..9eab2ee 100644 --- a/frontend/src/components/simulator/SerialMonitor.tsx +++ b/frontend/src/components/simulator/SerialMonitor.tsx @@ -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 = { +const BOARD_SHORT_LABEL: Partial> = { '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 = { +const BOARD_ICON: Partial> = { '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 = { +const BOARD_COLOR: Partial> = { '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 = () => {
{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 ( ); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 0e1ffac..cb412df 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -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> = []; + // 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 + * + * \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)); diff --git a/frontend/src/simulation/Esp32MicroPythonLoader.ts b/frontend/src/simulation/Esp32MicroPythonLoader.ts new file mode 100644 index 0000000..d36adf0 --- /dev/null +++ b/frontend/src/simulation/Esp32MicroPythonLoader.ts @@ -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 = { + '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 { + 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); +} diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index 6507157..ea706e8 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -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((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) { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 924a9e7..8190b07 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -587,10 +587,27 @@ export const useSimulatorStore = create((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) => diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 1f2c672..c4b29cc 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -22,6 +22,15 @@ export type LanguageMode = 'arduino' | 'micropython'; export const BOARD_SUPPORTS_MICROPYTHON = new Set([ '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 {