Compare commits

...

4 Commits

Author SHA1 Message Date
David Montero Crespo 6e5e9778fc feat: add comprehensive MicroPython implementation documentation for RP2040, ESP32, ESP32-S3, and ESP32-C3 boards 2026-03-29 23:50:55 -03:00
David Montero Crespo 0bc4c031d1 feat: add MicroPython support for ESP32-C3 (RISC-V) boards
ESP32-C3 already uses the QEMU backend via Esp32Bridge, not browser-side
emulation. This adds MicroPython support by including C3 in the supported
set, adding the C3 firmware variant to the loader, and bundling the
fallback firmware binary.

Also fixes misleading type comments that said "browser emulation" for C3
boards — they actually use QEMU backend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 23:40:05 -03:00
David Montero Crespo 7c9fd0bf2b 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>
2026-03-29 23:12:24 -03:00
David Montero Crespo 990ae4be8c feat: add MicroPython support for RP2040 boards (Pico / Pico W)
Implements MicroPython emulation for Raspberry Pi Pico boards running
entirely in the browser using rp2040js. Users can toggle between
Arduino C++ and MicroPython modes via a language selector dropdown.

Key changes:
- Add LanguageMode type and BOARD_SUPPORTS_MICROPYTHON to board types
- Create MicroPythonLoader.ts: UF2 firmware parser, LittleFS filesystem
  builder (via littlefs-wasm), IndexedDB firmware caching
- Extend RP2040Simulator with loadMicroPython() method using USBCDC for
  serial REPL instead of UART
- Add setBoardLanguageMode and loadMicroPythonProgram store actions
- Update EditorToolbar with language toggle and MicroPython compile flow
- Enhance SerialMonitor with REPL label, Ctrl+C/D support
- Bundle MicroPython v1.20.0 UF2 firmware as fallback in public/firmware/
- Update useEditorStore to create main.py default for MicroPython mode

Closes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 21:27:41 -03:00
18 changed files with 1228 additions and 56 deletions

10
.gitignore vendored
View File

@ -93,8 +93,16 @@ backend/app/services/esp32c3-rom.bin
test/esp32-emulator/**/*.elf
test/esp32-emulator/**/*.map
test/esp32-emulator/out_*/
# Arduino-cli compilation by-products (not needed for tests)
**/*.ino.eep
**/*.ino.with_bootloader.bin
**/*.ino.with_bootloader.hex
**/*.ino.map
**/*.ino.uf2
.claude/settings.json
# Google Cloud service account credentials
velxio-ba3355a41944.json
marketing/*
marketing/*
docs/github-issues/*

View File

@ -0,0 +1,348 @@
# MicroPython Implementation — Technical Summary
> Issue #3 — Full MicroPython support for RP2040, ESP32, ESP32-S3, and ESP32-C3 boards.
> Branch: `feature/micropython-rp2040`
---
## Overview
Velxio supports MicroPython across **15 board variants** using two distinct execution strategies:
| Strategy | Boards | Execution | Serial |
|----------|--------|-----------|--------|
| **Browser-side** (rp2040js) | Raspberry Pi Pico, Pico W | In-browser at 125 MHz | USBCDC |
| **QEMU backend** (WebSocket) | ESP32, ESP32-S3, ESP32-C3 (13 variants) | Remote `qemu-system-xtensa` / `qemu-system-riscv32` | UART via WebSocket |
### Supported Boards
```
RP2040 (browser): raspberry-pi-pico, pi-pico-w
ESP32 Xtensa (QEMU): esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite
ESP32-S3 Xtensa (QEMU): esp32-s3, xiao-esp32-s3, arduino-nano-esp32
ESP32-C3 RISC-V (QEMU): esp32-c3, xiao-esp32-c3, aitewinrobot-esp32c3-supermini
```
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ User clicks "Run" with MicroPython mode │
│ │
│ EditorToolbar.handleRun() │
│ └── loadMicroPythonProgram(boardId, pyFiles) │
│ ├── RP2040? → sim.loadMicroPython(files) │
│ │ ├── getFirmware() [IndexedDB/remote/bundled]
│ │ ├── loadUF2() [parse UF2 → flash]
│ │ ├── loadUserFiles() [LittleFS → flash]
│ │ └── USBCDC serial [REPL via USB CDC]
│ │ │
│ └── ESP32? → getEsp32Firmware() │
│ ├── base64 encode │
│ ├── bridge.loadFirmware(b64) │
│ └── bridge.setPendingMicroPythonCode()
│ └── on ">>>" detected: │
│ _injectCodeViaRawPaste() │
│ \x01 → \x05 → code → \x04
└─────────────────────────────────────────────────────────────┘
```
---
## Phase 1: RP2040 MicroPython (Browser-Side)
### Files Created/Modified
| File | Action |
|------|--------|
| `frontend/src/simulation/MicroPythonLoader.ts` | **Created** — UF2 parser, LittleFS builder, firmware cache |
| `frontend/src/simulation/RP2040Simulator.ts` | Modified — `loadMicroPython()`, USBCDC serial, `micropythonMode` |
| `frontend/src/types/board.ts` | Modified — `LanguageMode` type, `BOARD_SUPPORTS_MICROPYTHON` set |
| `frontend/src/store/useSimulatorStore.ts` | Modified — `loadMicroPythonProgram`, `setBoardLanguageMode` |
| `frontend/src/store/useEditorStore.ts` | Modified — `DEFAULT_MICROPYTHON_CONTENT`, `createFileGroup` overload |
| `frontend/src/components/editor/EditorToolbar.tsx` | Modified — language selector, MicroPython compile/run flow |
| `frontend/src/components/simulator/SerialMonitor.tsx` | Modified — Ctrl+C/D, REPL label |
| `frontend/public/firmware/micropython-rp2040.uf2` | **Created** — bundled fallback (638 KB) |
### UF2 Firmware Loading
MicroPython for RP2040 uses UF2 format (USB Flashing Format). Each block is 512 bytes:
```
Offset Field
0 Magic 0 (0x0a324655)
4 Magic 1 (0x9e5d5157)
12 Flash address (little-endian)
32 Payload data (256 bytes)
```
Constants:
```typescript
FLASH_START_ADDRESS = 0x10000000 // RP2040 XIP flash base
MICROPYTHON_FS_FLASH_START = 0xa0000 // LittleFS starts at 640KB
MICROPYTHON_FS_BLOCK_SIZE = 4096
MICROPYTHON_FS_BLOCK_COUNT = 352 // 1.4 MB filesystem
```
### LittleFS Filesystem
User files (e.g., `main.py`) are written into a LittleFS image in memory using the `littlefs` WASM package:
1. Create backing buffer: `352 × 4096 = 1,441,792 bytes`
2. Register 4 WASM callbacks via `addFunction()`:
- `flashRead(cfg, block, off, buffer, size)` — reads from JS buffer
- `flashProg(cfg, block, off, buffer, size)` — writes to JS buffer
- `flashErase(cfg, block)` — no-op
- `flashSync()` — no-op
3. Format and mount: `_lfs_format()``_lfs_mount()`
4. Write files: `cwrap('lfs_write_file')` for each user file
5. Unmount and copy into flash at offset `0xa0000`
### USBCDC Serial (not UART)
MicroPython on RP2040 uses USB CDC for the REPL, not UART0:
```typescript
// In RP2040Simulator.loadMicroPython():
this.usbCDC = new USBCDC(rp2040.usbCtrl);
this.usbCDC.onDeviceConnected = () => this.usbCDC.sendSerialByte('\r'.charCodeAt(0));
this.usbCDC.onSerialData = (value) => { /* forward to SerialMonitor */ };
// Serial write routes through USBCDC when micropythonMode is true:
serialWrite(text: string): void {
if (this.micropythonMode && this.usbCDC) {
for (const ch of text) this.usbCDC.sendSerialByte(ch.charCodeAt(0));
} else {
this.rp2040?.uart[0].feedByte(...);
}
}
```
### Firmware Caching Strategy
All firmware uses a 3-tier loading strategy:
1. **IndexedDB cache** — instant load via `idb-keyval`
2. **Remote download** — from `micropython.org` with streaming progress
3. **Bundled fallback** — from `/firmware/` (Vite public directory)
Cache key: `micropython-rp2040-uf2-v1.20.0`
---
## Phase 2: ESP32/ESP32-S3 MicroPython (QEMU Backend)
### Files Created/Modified
| File | Action |
|------|--------|
| `frontend/src/simulation/Esp32MicroPythonLoader.ts` | **Created** — firmware download/cache for ESP32 variants |
| `frontend/src/simulation/Esp32Bridge.ts` | Modified — `setPendingMicroPythonCode()`, raw-paste injection |
| `frontend/src/store/useSimulatorStore.ts` | Modified — ESP32 path in `loadMicroPythonProgram` |
| `frontend/src/store/useEditorStore.ts` | Modified — `DEFAULT_ESP32_MICROPYTHON_CONTENT` |
| `frontend/src/components/simulator/SerialMonitor.tsx` | Modified — generic Ctrl+C/D for all boards |
| `frontend/public/firmware/micropython-esp32.bin` | **Created** — bundled fallback (1.5 MB) |
| `frontend/public/firmware/micropython-esp32s3.bin` | **Created** — bundled fallback (1.4 MB) |
### Firmware Variants
```typescript
FIRMWARE_MAP = {
'esp32': { remote: '.../ESP32_GENERIC-20230426-v1.20.0.bin', cacheKey: 'micropython-esp32-v1.20.0' },
'esp32-s3': { remote: '.../ESP32_GENERIC_S3-20230426-v1.20.0.bin', cacheKey: 'micropython-esp32s3-v1.20.0' },
'esp32-c3': { remote: '.../ESP32_GENERIC_C3-20230426-v1.20.0.bin', cacheKey: 'micropython-esp32c3-v1.20.0' },
}
```
Board kind → firmware variant mapping:
```
esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite → 'esp32'
esp32-s3, xiao-esp32-s3, arduino-nano-esp32 → 'esp32-s3'
esp32-c3, xiao-esp32-c3, aitewinrobot-esp32c3-supermini → 'esp32-c3'
```
### Raw-Paste REPL Protocol
Unlike RP2040 (which loads files into LittleFS), ESP32 boards inject user code into the MicroPython REPL after boot using the **raw-paste protocol**:
```
Step 1: Wait for ">>>" in serial output (REPL ready)
Step 2: Send \x01 (Ctrl+A) — enter raw REPL mode
Step 3: Send \x05 (Ctrl+E) — enter raw-paste mode
Step 4: Send code bytes in 256-byte chunks (10ms between chunks)
Step 5: Send \x04 (Ctrl+D) — execute the code
```
Implementation in `Esp32Bridge`:
```typescript
// State fields
private _pendingMicroPythonCode: string | null = null;
private _serialBuffer = '';
micropythonMode = false;
// In serial_output handler — detect REPL prompt
if (this._pendingMicroPythonCode && this._serialBuffer.includes('>>>')) {
this._injectCodeViaRawPaste(this._pendingMicroPythonCode);
this._pendingMicroPythonCode = null;
}
// Timing: 500ms initial delay → 100ms after Ctrl+A → 100ms after Ctrl+E
// → 10ms between 256-byte chunks → 50ms before Ctrl+D
```
### WebSocket Protocol Flow
```
Frontend Backend (QEMU)
│ │
│ start_esp32 { firmware_b64, board }
│──────────────────────────────────→│
│ │ QEMU boots MicroPython
│ │
│ serial_output { data: ">>>" } │
│←──────────────────────────────────│
│ │
│ esp32_serial_input { bytes: [0x01] } ← Ctrl+A (raw REPL)
│──────────────────────────────────→│
│ │
│ esp32_serial_input { bytes: [0x05] } ← Ctrl+E (raw-paste)
│──────────────────────────────────→│
│ │
│ esp32_serial_input { bytes: [...code...] } ← code chunks
│──────────────────────────────────→│
│ │
│ esp32_serial_input { bytes: [0x04] } ← Ctrl+D (execute)
│──────────────────────────────────→│
│ │
│ serial_output { data: "...output..." }
│←──────────────────────────────────│
```
---
## Phase 3: ESP32-C3 MicroPython
### Files Modified
| File | Action |
|------|--------|
| `frontend/src/types/board.ts` | Modified — added C3 boards to `BOARD_SUPPORTS_MICROPYTHON`, fixed comments |
| `frontend/src/simulation/Esp32MicroPythonLoader.ts` | Modified — added `esp32-c3` firmware variant |
| `frontend/public/firmware/micropython-esp32c3.bin` | **Created** — bundled fallback (1.4 MB) |
### Key Finding
ESP32-C3 boards were documented as "browser emulation (Esp32C3Simulator)" in the type definitions, but the actual runtime code routes them through `Esp32Bridge` (QEMU backend) — the `Esp32C3Simulator` is dead code. This was corrected.
The `isEsp32Kind()` function in `useSimulatorStore.ts` already included C3 boards, so the QEMU bridge infrastructure was already wired up. Only the MicroPython-specific additions were needed.
---
## Store Integration
### `loadMicroPythonProgram(boardId, files)`
```typescript
// In useSimulatorStore.ts
async loadMicroPythonProgram(boardId, files) {
if (isEsp32Kind(board.boardKind)) {
// ESP32 path: firmware → QEMU bridge → raw-paste injection
const firmware = await getEsp32Firmware(board.boardKind);
const b64 = uint8ArrayToBase64(firmware);
esp32Bridge.loadFirmware(b64);
esp32Bridge.setPendingMicroPythonCode(mainFile.content);
} else {
// RP2040 path: firmware + LittleFS in browser
await sim.loadMicroPython(files);
}
// Mark as loaded
board.compiledProgram = 'micropython-loaded';
}
```
### `setBoardLanguageMode(boardId, mode)`
When toggling between Arduino and MicroPython:
1. Stops any running simulation
2. Clears `compiledProgram`
3. Deletes old file group
4. Creates new file group with appropriate defaults:
- RP2040 MicroPython → `main.py` with `Pin(25)` blink
- ESP32 MicroPython → `main.py` with `Pin(2)` blink
- Arduino → `sketch.ino` with default Arduino code
---
## UI Integration
### Language Mode Selector
Appears in `EditorToolbar` next to the board name pill when `BOARD_SUPPORTS_MICROPYTHON.has(boardKind)`:
```
[Arduino C++ ▼] ↔ [MicroPython ▼]
```
Changing the selector:
- Switches `languageMode` on the `BoardInstance`
- Replaces editor files with language-appropriate defaults
- Updates compile button text: "Compile (Ctrl+B)" → "Load MicroPython"
### Serial Monitor
- **REPL label**: Shows "MicroPython REPL" in magenta when in MicroPython mode
- **Baud rate**: Hidden in MicroPython mode (REPL has no baud rate)
- **Ctrl+C**: Sends `\x03` (keyboard interrupt) — works for both RP2040 and ESP32
- **Ctrl+D**: Sends `\x04` (soft reset) — works for both RP2040 and ESP32
- **Placeholder**: "Type Python expression... (Ctrl+C to interrupt)"
### Run Button Behavior
For MicroPython boards, the Run button:
1. If firmware not yet loaded → auto-loads firmware first, then starts
2. If already loaded → starts simulation directly
3. Enabled even without `compiledProgram` (unlike Arduino mode)
---
## Firmware Files
| File | Size | Board | Version |
|------|------|-------|---------|
| `micropython-rp2040.uf2` | 638 KB | RP2040 | v1.20.0 (2023-04-26) |
| `micropython-esp32.bin` | 1.5 MB | ESP32 Xtensa | v1.20.0 (2023-04-26) |
| `micropython-esp32s3.bin` | 1.4 MB | ESP32-S3 Xtensa | v1.20.0 (2023-04-26) |
| `micropython-esp32c3.bin` | 1.4 MB | ESP32-C3 RISC-V | v1.20.0 (2023-04-26) |
All firmware uses MicroPython **v1.20.0** (stable release, 2023-04-26).
Firmware source: `https://micropython.org/resources/firmware/`
---
## Dependencies Added
| Package | Version | Purpose |
|---------|---------|---------|
| `littlefs` | 0.1.0 | LittleFS WASM for creating filesystem images (RP2040) |
| `idb-keyval` | 6.x | IndexedDB key-value wrapper for firmware caching |
---
## Git History
```
990ae4b feat: add MicroPython support for RP2040 boards (Pico / Pico W)
7c9fd0b feat: add MicroPython support for ESP32/ESP32-S3 boards via QEMU bridge
0bc4c03 feat: add MicroPython support for ESP32-C3 (RISC-V) boards
```
| Commit | Files Changed | Lines Added | Lines Removed |
|--------|:------------:|:-----------:|:------------:|
| `990ae4b` (RP2040) | 12 | +587 | -38 |
| `7c9fd0b` (ESP32/S3) | 8 | +296 | -33 |
| `0bc4c03` (ESP32-C3) | 3 | +16 | -4 |
| **Total** | **23** | **+899** | **-75** |

View File

@ -15,7 +15,9 @@
"@xterm/xterm": "^6.0.0",
"avr8js": "file:../wokwi-libs/avr8js",
"axios": "^1.13.6",
"idb-keyval": "^6.2.2",
"jszip": "^3.10.1",
"littlefs": "^0.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
@ -3669,6 +3671,12 @@
"node": ">= 14"
}
},
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3997,6 +4005,12 @@
"immediate": "~3.0.5"
}
},
"node_modules/littlefs": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/littlefs/-/littlefs-0.1.0.tgz",
"integrity": "sha512-mNwh4CHkuw83HCIAxzNE3KLV59A5IKkxiFbUUCPkA8gIZ9HeAOovFbrfdpAgVwLAX1tJrM5jP/I1cGPSFgBNZw==",
"license": "ISC"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@ -27,7 +27,9 @@
"@xterm/xterm": "^6.0.0",
"avr8js": "file:../wokwi-libs/avr8js",
"axios": "^1.13.6",
"idb-keyval": "^6.2.2",
"jszip": "^3.10.1",
"littlefs": "^0.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,8 +1,8 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useEditorStore } from '../../store/useEditorStore';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_KIND_LABELS } from '../../types/board';
import type { BoardKind, LanguageMode } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_KIND_LABELS, BOARD_SUPPORTS_MICROPYTHON } from '../../types/board';
import { compileCode } from '../../services/compilation';
import { CompileAllProgress } from './CompileAllProgress';
import type { BoardCompileStatus } from './CompileAllProgress';
@ -49,6 +49,8 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
boards,
activeBoardId,
compileBoardProgram,
loadMicroPythonProgram,
setBoardLanguageMode,
startBoard,
stopBoard,
resetBoard,
@ -112,6 +114,25 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
return;
}
// MicroPython mode — no backend compilation needed
if (activeBoard?.languageMode === 'micropython' && activeBoardId) {
addLog({ timestamp: new Date(), type: 'info', message: 'MicroPython: loading firmware and user files...' });
try {
const groupFiles = useEditorStore.getState().getGroupFiles(activeBoard.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
addLog({ timestamp: new Date(), type: 'success', message: 'MicroPython firmware loaded successfully' });
setMessage({ type: 'success', text: 'MicroPython ready' });
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
} finally {
setCompiling(false);
}
return;
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
const boardLabel = kind ? BOARD_KIND_LABELS[kind] : 'Unknown';
@ -155,9 +176,37 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
}
};
const handleRun = () => {
const handleRun = async () => {
if (activeBoardId) {
const board = boards.find((b) => b.id === activeBoardId);
// MicroPython mode: auto-load firmware + files, then start
if (board?.languageMode === 'micropython') {
trackRunSimulation(board.boardKind);
if (!board.compiledProgram) {
// Need to load MicroPython first
setCompiling(true);
setMessage(null);
addLog({ timestamp: new Date(), type: 'info', message: 'MicroPython: loading firmware and user files...' });
try {
const groupFiles = useEditorStore.getState().getGroupFiles(board.activeFileGroupId);
const pyFiles = groupFiles.map((f) => ({ name: f.name, content: f.content }));
await loadMicroPythonProgram(activeBoardId, pyFiles);
addLog({ timestamp: new Date(), type: 'success', message: 'MicroPython firmware loaded' });
} catch (err) {
const errMsg = err instanceof Error ? err.message : 'Failed to load MicroPython';
addLog({ timestamp: new Date(), type: 'error', message: errMsg });
setMessage({ type: 'error', text: errMsg });
setCompiling(false);
return;
}
setCompiling(false);
}
startBoard(activeBoardId);
setMessage(null);
return;
}
const isQemuBoard = board?.boardKind === 'raspberry-pi-3' || board?.boardKind === 'esp32' || board?.boardKind === 'esp32-s3';
if (isQemuBoard || board?.compiledProgram) {
trackRunSimulation(board?.boardKind);
@ -302,14 +351,39 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
<div className="editor-toolbar" ref={toolbarRef}>
{/* Active board context pill */}
{activeBoard && (
<div
className="tb-board-pill"
style={{ borderColor: BOARD_PILL_COLOR[activeBoard.boardKind], color: BOARD_PILL_COLOR[activeBoard.boardKind] }}
title={`Editing: ${BOARD_KIND_LABELS[activeBoard.boardKind]}`}
>
<span className="tb-board-pill-icon">{BOARD_PILL_ICON[activeBoard.boardKind]}</span>
<span className="tb-board-pill-label">{BOARD_KIND_LABELS[activeBoard.boardKind]}</span>
{activeBoard.running && <span className="tb-board-pill-running" title="Running" />}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div
className="tb-board-pill"
style={{ borderColor: BOARD_PILL_COLOR[activeBoard.boardKind], color: BOARD_PILL_COLOR[activeBoard.boardKind] }}
title={`Editing: ${BOARD_KIND_LABELS[activeBoard.boardKind]}`}
>
<span className="tb-board-pill-icon">{BOARD_PILL_ICON[activeBoard.boardKind]}</span>
<span className="tb-board-pill-label">{BOARD_KIND_LABELS[activeBoard.boardKind]}</span>
{activeBoard.running && <span className="tb-board-pill-running" title="Running" />}
</div>
{BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && (
<select
className="tb-lang-select"
value={activeBoard.languageMode ?? 'arduino'}
onChange={(e) => {
if (activeBoardId) setBoardLanguageMode(activeBoardId, e.target.value as LanguageMode);
}}
title="Language mode"
style={{
background: '#2d2d2d',
color: '#ccc',
border: '1px solid #444',
borderRadius: 4,
padding: '2px 4px',
fontSize: 11,
cursor: 'pointer',
outline: 'none',
}}
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
</select>
)}
</div>
)}
@ -319,7 +393,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
onClick={handleCompile}
disabled={compiling}
className="tb-btn tb-btn-compile"
title={compiling ? 'Compiling…' : 'Compile (Ctrl+B)'}
title={compiling ? 'Loading…' : activeBoard?.languageMode === 'micropython' ? 'Load MicroPython' : 'Compile (Ctrl+B)'}
>
{compiling ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="spin">
@ -337,9 +411,14 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
{/* Run */}
<button
onClick={handleRun}
disabled={running || (!['raspberry-pi-3','esp32','esp32-s3'].includes(activeBoard?.boardKind ?? '') && !compiledHex && !activeBoard?.compiledProgram)}
disabled={running || compiling || (
!['raspberry-pi-3','esp32','esp32-s3'].includes(activeBoard?.boardKind ?? '')
&& activeBoard?.languageMode !== 'micropython'
&& !compiledHex
&& !activeBoard?.compiledProgram
)}
className="tb-btn tb-btn-run"
title="Run"
title={activeBoard?.languageMode === 'micropython' ? 'Run MicroPython' : 'Run'}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5,3 19,12 5,21" />

View File

@ -9,37 +9,64 @@ 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 = () => {
@ -94,12 +121,25 @@ export const SerialMonitor: React.FC = () => {
setInputValue('');
}, [resolvedTabId, inputValue, lineEnding, serialWriteToBoard]);
const isMicroPython = activeBoard?.languageMode === 'micropython';
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSend();
return;
}
}, [handleSend]);
// 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]);
const handleTabClick = (boardId: string) => {
setActiveTabId(boardId);
@ -126,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
@ -139,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>
);
@ -149,7 +189,10 @@ export const SerialMonitor: React.FC = () => {
{/* Right-side controls */}
<div style={styles.tabControls}>
{activeBoard?.serialBaudRate != null && activeBoard.serialBaudRate > 0 && (
{isMicroPython && (
<span style={{ color: '#ce93d8', fontSize: 11, fontWeight: 600 }}>MicroPython REPL</span>
)}
{activeBoard?.serialBaudRate != null && activeBoard.serialBaudRate > 0 && !isMicroPython && (
<span style={styles.baudRate}>{activeBoard.serialBaudRate.toLocaleString()} baud</span>
)}
<label style={styles.autoscrollLabel}>
@ -184,7 +227,7 @@ export const SerialMonitor: React.FC = () => {
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type message to send..."
placeholder={isMicroPython ? 'Type Python expression... (Ctrl+C to interrupt)' : 'Type message to send...'}
style={styles.input}
disabled={!activeBoard?.running}
/>

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,127 @@
/**
* 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',
},
'esp32-c3': {
remote: 'https://micropython.org/resources/firmware/ESP32_GENERIC_C3-20230426-v1.20.0.bin',
cacheKey: 'micropython-esp32c3-v1.20.0',
fallback: '/firmware/micropython-esp32c3.bin',
},
};
/** Map any ESP32-family board kind to firmware variant key */
function toFirmwareVariant(boardKind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32-c3' {
if (boardKind === 'esp32-s3' || boardKind === 'xiao-esp32-s3' || boardKind === 'arduino-nano-esp32') {
return 'esp32-s3';
}
if (boardKind === 'esp32-c3' || boardKind === 'xiao-esp32-c3' || boardKind === 'aitewinrobot-esp32c3-supermini') {
return 'esp32-c3';
}
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

@ -0,0 +1,205 @@
/**
* MicroPythonLoader Loads MicroPython firmware + user files into RP2040 flash
*
* 1. Parses UF2 firmware and writes it to flash
* 2. Creates a LittleFS image with user .py files and writes it to flash at 0xa0000
* 3. Caches the MicroPython firmware UF2 in IndexedDB for fast subsequent loads
*/
import { get as idbGet, set as idbSet } from 'idb-keyval';
import createLittleFS from 'littlefs';
// Flash geometry (matches rp2040js and MicroPython defaults)
const FLASH_START_ADDRESS = 0x10000000;
const MICROPYTHON_FS_FLASH_START = 0xa0000;
const MICROPYTHON_FS_BLOCK_SIZE = 4096;
const MICROPYTHON_FS_BLOCK_COUNT = 352;
// UF2 block constants
const UF2_MAGIC_START0 = 0x0a324655;
const UF2_MAGIC_START1 = 0x9e5d5157;
const UF2_BLOCK_SIZE = 512;
const UF2_PAYLOAD_SIZE = 256;
const UF2_DATA_OFFSET = 32;
const UF2_ADDR_OFFSET = 12;
// Firmware cache key for IndexedDB
const FIRMWARE_CACHE_KEY = 'micropython-rp2040-uf2-v1.20.0';
// Bundled fallback path (placed in public/firmware/)
const FIRMWARE_FALLBACK_PATH = '/firmware/micropython-rp2040.uf2';
// Remote firmware URL
const FIRMWARE_REMOTE_URL =
'https://micropython.org/resources/firmware/RPI_PICO-20230426-v1.20.0.uf2';
/**
* Parse UF2 binary and write payload blocks into RP2040 flash.
* UF2 format: 512-byte blocks, each with a 256-byte payload targeted at a flash address.
*/
export function loadUF2(uf2Data: Uint8Array, flash: Uint8Array): void {
const view = new DataView(uf2Data.buffer, uf2Data.byteOffset, uf2Data.byteLength);
for (let offset = 0; offset + UF2_BLOCK_SIZE <= uf2Data.length; offset += UF2_BLOCK_SIZE) {
const magic0 = view.getUint32(offset, true);
const magic1 = view.getUint32(offset + 4, true);
if (magic0 !== UF2_MAGIC_START0 || magic1 !== UF2_MAGIC_START1) {
continue; // skip non-UF2 blocks
}
const flashAddress = view.getUint32(offset + UF2_ADDR_OFFSET, true);
const payload = uf2Data.subarray(offset + UF2_DATA_OFFSET, offset + UF2_DATA_OFFSET + UF2_PAYLOAD_SIZE);
const flashOffset = flashAddress - FLASH_START_ADDRESS;
if (flashOffset >= 0 && flashOffset + UF2_PAYLOAD_SIZE <= flash.length) {
flash.set(payload, flashOffset);
}
}
}
/**
* Create a LittleFS filesystem image containing the user's Python files
* and write it into RP2040 flash at the MicroPython filesystem offset.
*/
export async function loadUserFiles(
files: Array<{ name: string; content: string }>,
flash: Uint8Array,
): Promise<void> {
// Create a backing buffer for the LittleFS filesystem
const fsBuffer = new Uint8Array(MICROPYTHON_FS_BLOCK_COUNT * MICROPYTHON_FS_BLOCK_SIZE);
// Initialize the littlefs WASM module
const lfs = await createLittleFS({});
// Register flash read/write callbacks for the WASM module
const flashRead = lfs.addFunction(
(_cfg: number, block: number, off: number, buffer: number, size: number) => {
const start = block * MICROPYTHON_FS_BLOCK_SIZE + off;
lfs.HEAPU8.set(fsBuffer.subarray(start, start + size), buffer);
return 0;
},
'iiiiii',
);
const flashProg = lfs.addFunction(
(_cfg: number, block: number, off: number, buffer: number, size: number) => {
const start = block * MICROPYTHON_FS_BLOCK_SIZE + off;
fsBuffer.set(lfs.HEAPU8.subarray(buffer, buffer + size), start);
return 0;
},
'iiiiii',
);
const flashErase = lfs.addFunction(
(_cfg: number, _block: number) => 0,
'iii',
);
const flashSync = lfs.addFunction(() => 0, 'ii');
// Create LittleFS config and instance
const config = lfs._new_lfs_config(
flashRead, flashProg, flashErase, flashSync,
MICROPYTHON_FS_BLOCK_COUNT, MICROPYTHON_FS_BLOCK_SIZE,
);
const lfsInstance = lfs._new_lfs();
// Format and mount
lfs._lfs_format(lfsInstance, config);
lfs._lfs_mount(lfsInstance, config);
// Write user files using cwrap for automatic string marshalling
const writeFile = lfs.cwrap('lfs_write_file', 'number', ['number', 'string', 'string', 'number']);
for (const file of files) {
const fileName = file.name;
const content = file.content;
writeFile(lfsInstance, fileName, content, content.length);
}
// Unmount and free
lfs._lfs_unmount(lfsInstance);
lfs._free(lfsInstance);
lfs._free(config);
// Copy the LittleFS image into RP2040 flash at the filesystem offset
flash.set(fsBuffer, MICROPYTHON_FS_FLASH_START);
}
/**
* Get the MicroPython UF2 firmware binary.
* Checks IndexedDB cache first, then tries remote download, then bundled fallback.
*/
export async function getFirmware(
onProgress?: (loaded: number, total: number) => void,
): Promise<Uint8Array> {
// 1. Check IndexedDB cache
try {
const cached = await idbGet(FIRMWARE_CACHE_KEY);
if (cached instanceof Uint8Array && cached.length > 0) {
console.log('[MicroPython] Firmware loaded from cache');
return cached;
}
} catch {
// IndexedDB unavailable, continue
}
// 2. Try remote download
try {
const response = await fetch(FIRMWARE_REMOTE_URL);
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;
}
// Cache for next time
try {
await idbSet(FIRMWARE_CACHE_KEY, firmware);
} catch {
// Cache write failure is non-fatal
}
console.log(`[MicroPython] Firmware downloaded (${firmware.length} bytes)`);
return firmware;
}
}
} catch {
console.warn('[MicroPython] Remote firmware download failed, trying bundled fallback');
}
// 3. Fallback to bundled firmware
const response = await fetch(FIRMWARE_FALLBACK_PATH);
if (!response.ok) {
throw new Error('MicroPython firmware not available (remote and bundled both failed)');
}
const buffer = await response.arrayBuffer();
const firmware = new Uint8Array(buffer);
// Cache for next time
try {
await idbSet(FIRMWARE_CACHE_KEY, firmware);
} catch {
// non-fatal
}
console.log(`[MicroPython] Firmware loaded from bundled fallback (${firmware.length} bytes)`);
return firmware;
}

View File

@ -1,7 +1,8 @@
import { RP2040, GPIOPinState, ConsoleLogger, LogLevel } from 'rp2040js';
import { RP2040, GPIOPinState, ConsoleLogger, LogLevel, USBCDC } from 'rp2040js';
import type { RPI2C } from 'rp2040js';
import { PinManager } from './PinManager';
import { bootromB1 } from './rp2040-bootrom';
import { loadUF2, loadUserFiles, getFirmware } from './MicroPythonLoader';
/**
* RP2040Simulator Emulates Raspberry Pi Pico (RP2040) using rp2040js
@ -52,8 +53,10 @@ export class RP2040Simulator {
private totalCycles = 0;
private scheduledPinChanges: Array<{ cycle: number; pin: number; state: boolean }> = [];
private pioStepAccum = 0;
private usbCDC: USBCDC | null = null;
private micropythonMode = false;
/** Serial output callback — fires for each byte the Pico sends on UART0 */
/** Serial output callback — fires for each byte the Pico sends on UART0 (or USBCDC in MicroPython mode) */
public onSerialData: ((char: string) => void) | null = null;
/**
@ -97,6 +100,88 @@ export class RP2040Simulator {
console.warn('[RP2040] loadHex() called on RP2040Simulator — use loadBinary() instead');
}
/**
* Load MicroPython firmware + user .py files into RP2040 flash.
* Uses USBCDC for serial (REPL) instead of UART.
*/
async loadMicroPython(
files: Array<{ name: string; content: string }>,
onProgress?: (loaded: number, total: number) => void,
): Promise<void> {
console.log('[RP2040] Loading MicroPython firmware...');
// 1. Get MicroPython UF2 firmware (cached in IndexedDB)
const firmware = await getFirmware(onProgress);
// 2. Create fresh RP2040 instance
this.rp2040 = new RP2040();
this.rp2040.logger = new ConsoleLogger(LogLevel.Error);
this.rp2040.loadBootrom(bootromB1);
// 3. Load UF2 firmware into flash
loadUF2(firmware, this.rp2040.flash);
console.log(`[RP2040] MicroPython UF2 loaded (${firmware.length} bytes)`);
// 4. Create LittleFS with user files and load into flash
await loadUserFiles(files, this.rp2040.flash);
console.log(`[RP2040] LittleFS loaded with ${files.length} file(s)`);
// Keep a flash copy for reset
this.flashCopy = new Uint8Array(this.rp2040.flash);
// 5. Set up USBCDC for serial REPL (instead of UART)
this.usbCDC = new USBCDC(this.rp2040.usbCtrl);
this.usbCDC.onDeviceConnected = () => {
// Send newline to trigger the REPL prompt
this.usbCDC!.sendSerialByte('\r'.charCodeAt(0));
this.usbCDC!.sendSerialByte('\n'.charCodeAt(0));
};
this.usbCDC.onSerialData = (buffer: Uint8Array) => {
for (const byte of buffer) {
if (this.onSerialData) {
this.onSerialData(String.fromCharCode(byte));
}
}
};
// 6. Set PC to flash start
this.rp2040.core.PC = 0x10000000;
// 7. Wire peripherals (I2C, SPI, ADC, PIO, GPIO — same as Arduino mode)
// But skip UART serial wiring since MicroPython uses USBCDC
this.rp2040.uart[1].onByte = (value: number) => {
if (this.onSerialData) this.onSerialData(String.fromCharCode(value));
};
this.wireI2C(0);
this.wireI2C(1);
this.rp2040.spi[0].onTransmit = (v: number) => { this.rp2040!.spi[0].completeTransmit(v); };
this.rp2040.spi[1].onTransmit = (v: number) => { this.rp2040!.spi[1].completeTransmit(v); };
this.rp2040.adc.channelValues[0] = 2048;
this.rp2040.adc.channelValues[1] = 2048;
this.rp2040.adc.channelValues[2] = 2048;
this.rp2040.adc.channelValues[3] = 2048;
this.rp2040.adc.channelValues[4] = 876;
// Patch PIO (same as initMCU)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const pio of (this.rp2040 as any).pio) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pio.run = function (this: any) {
if (this.runTimer) { clearTimeout(this.runTimer); this.runTimer = null; }
};
}
this.pioStepAccum = 0;
this.setupGpioListeners();
this.micropythonMode = true;
console.log('[RP2040] MicroPython ready');
}
/** Returns true if currently in MicroPython mode */
isMicroPythonMode(): boolean {
return this.micropythonMode;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getADC(): any {
return this.rp2040?.adc ?? null;
@ -359,9 +444,46 @@ export class RP2040Simulator {
this.totalCycles = 0;
this.scheduledPinChanges = [];
if (this.rp2040 && this.flashCopy) {
this.initMCU(this.flashCopy);
// Re-register any previously added I2C devices
// (devices are kept in i2cDevices maps which persist across reset)
if (this.micropythonMode) {
// In MicroPython mode, restore the full flash snapshot (UF2 + LittleFS)
this.rp2040 = new RP2040();
this.rp2040.logger = new ConsoleLogger(LogLevel.Error);
this.rp2040.loadBootrom(bootromB1);
this.rp2040.flash.set(this.flashCopy);
this.rp2040.core.PC = 0x10000000;
// Re-wire USBCDC
this.usbCDC = new USBCDC(this.rp2040.usbCtrl);
this.usbCDC.onDeviceConnected = () => {
this.usbCDC!.sendSerialByte('\r'.charCodeAt(0));
this.usbCDC!.sendSerialByte('\n'.charCodeAt(0));
};
this.usbCDC.onSerialData = (buffer: Uint8Array) => {
for (const byte of buffer) {
if (this.onSerialData) this.onSerialData(String.fromCharCode(byte));
}
};
// Re-wire peripherals (skipping UART0 serial)
this.rp2040.uart[1].onByte = (value: number) => {
if (this.onSerialData) this.onSerialData(String.fromCharCode(value));
};
this.wireI2C(0);
this.wireI2C(1);
this.rp2040.spi[0].onTransmit = (v: number) => { this.rp2040!.spi[0].completeTransmit(v); };
this.rp2040.spi[1].onTransmit = (v: number) => { this.rp2040!.spi[1].completeTransmit(v); };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const pio of (this.rp2040 as any).pio) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pio.run = function (this: any) {
if (this.runTimer) { clearTimeout(this.runTimer); this.runTimer = null; }
};
}
this.pioStepAccum = 0;
this.setupGpioListeners();
} else {
this.initMCU(this.flashCopy);
}
console.log('[RP2040] CPU reset');
}
}
@ -443,12 +565,30 @@ export class RP2040Simulator {
}
/**
* Send text to UART0 RX (as if typed in Serial Monitor).
* Send text to UART0 RX (or USBCDC in MicroPython mode).
*/
serialWrite(text: string): void {
if (!this.rp2040) return;
for (let i = 0; i < text.length; i++) {
this.rp2040.uart[0].feedByte(text.charCodeAt(i));
if (this.micropythonMode && this.usbCDC) {
for (let i = 0; i < text.length; i++) {
this.usbCDC.sendSerialByte(text.charCodeAt(i));
}
} else {
for (let i = 0; i < text.length; i++) {
this.rp2040.uart[0].feedByte(text.charCodeAt(i));
}
}
}
/**
* Send a raw byte to the serial interface (for control characters like Ctrl+C).
*/
serialWriteByte(byte: number): void {
if (!this.rp2040) return;
if (this.micropythonMode && this.usbCDC) {
this.usbCDC.sendSerialByte(byte);
} else {
this.rp2040.uart[0].feedByte(byte);
}
}

View File

@ -21,6 +21,28 @@ void loop() {
delay(1000);
}`;
const DEFAULT_MICROPYTHON_CONTENT = `# MicroPython Blink for Raspberry Pi Pico
from machine import Pin
import time
led = Pin(25, Pin.OUT)
while True:
led.toggle()
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
@ -79,7 +101,7 @@ interface EditorState {
loadFiles: (files: { name: string; content: string }[]) => void;
// File group management
createFileGroup: (groupId: string, initialFiles?: { name: string; content: string }[]) => void;
createFileGroup: (groupId: string, languageModeOrFiles?: string | { name: string; content: string }[]) => void;
deleteFileGroup: (groupId: string) => void;
setActiveGroup: (groupId: string) => void;
getGroupFiles: (groupId: string) => WorkspaceFile[];
@ -263,10 +285,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
// ── File group management ─────────────────────────────────────────────────
createFileGroup: (groupId: string, initialFiles?: { name: string; content: string }[]) => {
createFileGroup: (groupId: string, languageModeOrFiles?: string | { name: string; content: string }[]) => {
set((s) => {
if (s.fileGroups[groupId]) return s; // already exists
// Resolve overloaded parameter
const initialFiles = Array.isArray(languageModeOrFiles) ? languageModeOrFiles : undefined;
const languageMode = typeof languageModeOrFiles === 'string' ? languageModeOrFiles : undefined;
let files: WorkspaceFile[];
if (initialFiles && initialFiles.length > 0) {
files = initialFiles.map((f, i) => ({
@ -276,15 +302,27 @@ export const useEditorStore = create<EditorState>((set, get) => ({
modified: false,
}));
} else {
// Determine default file by group name convention
// Determine default file by group name convention or language mode
const isPi = groupId.includes('raspberry-pi-3');
const isMicroPython = languageMode === 'micropython';
const mainId = `${groupId}-main`;
files = [{
id: mainId,
name: isPi ? 'script.py' : 'sketch.ino',
content: isPi ? DEFAULT_PY_CONTENT : DEFAULT_INO_CONTENT,
modified: false,
}];
let fileName: string;
let content: string;
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) {
fileName = 'script.py';
content = DEFAULT_PY_CONTENT;
} else {
fileName = 'sketch.ino';
content = DEFAULT_INO_CONTENT;
}
files = [{ id: mainId, name: fileName, content, modified: false }];
}
const firstId = files[0]?.id ?? `${groupId}-main`;

View File

@ -7,7 +7,8 @@ import { PinManager } from '../simulation/PinManager';
import { VirtualDS1307, VirtualTempSensor, I2CMemoryDevice } from '../simulation/I2CBusManager';
import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
import type { Wire, WireInProgress, WireEndpoint } from '../types/wire';
import type { BoardKind, BoardInstance } from '../types/board';
import type { BoardKind, BoardInstance, LanguageMode } from '../types/board';
import { BOARD_SUPPORTS_MICROPYTHON } from '../types/board';
import { calculatePinPosition } from '../utils/pinPositionCalculator';
import { useOscilloscopeStore } from './useOscilloscopeStore';
import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
@ -181,6 +182,8 @@ interface SimulatorState {
setBoardPosition: (pos: { x: number; y: number }, boardId?: string) => void;
setActiveBoardId: (boardId: string) => void;
compileBoardProgram: (boardId: string, program: string) => void;
loadMicroPythonProgram: (boardId: string, files: Array<{ name: string; content: string }>) => Promise<void>;
setBoardLanguageMode: (boardId: string, mode: LanguageMode) => void;
startBoard: (boardId: string) => void;
stopBoard: (boardId: string) => void;
resetBoard: (boardId: string) => void;
@ -298,6 +301,7 @@ const INITIAL_BOARD: BoardInstance = {
serialBaudRate: 0,
serialMonitorOpen: false,
activeFileGroupId: `group-${INITIAL_BOARD_ID}`,
languageMode: 'arduino' as LanguageMode,
};
// ── Store ─────────────────────────────────────────────────────────────────
@ -459,6 +463,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
serialOutput: '', serialBaudRate: 0,
serialMonitorOpen: false,
activeFileGroupId: `group-${id}`,
languageMode: 'arduino',
};
set((s) => ({ boards: [...s.boards, newBoard] }));
@ -577,6 +582,68 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
});
},
loadMicroPythonProgram: async (boardId: string, files: Array<{ name: string; content: string }>) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (!BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) 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;
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) =>
b.id === boardId ? { ...b, compiledProgram: 'micropython-loaded' } : b
);
const isActive = s.activeBoardId === boardId;
return {
boards,
...(isActive ? { compiledHex: 'micropython-loaded', hexEpoch: s.hexEpoch + 1 } : {}),
};
});
},
setBoardLanguageMode: (boardId: string, mode: LanguageMode) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
// Only allow MicroPython for supported boards
if (mode === 'micropython' && !BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return;
// Stop any running simulation
if (board.running) get().stopBoard(boardId);
// Clear compiled program since language changed
set((s) => ({
boards: s.boards.map((b) =>
b.id === boardId ? { ...b, languageMode: mode, compiledProgram: null } : b
),
}));
// Replace file group with appropriate default files
const editorStore = useEditorStore.getState();
editorStore.deleteFileGroup(board.activeFileGroupId);
editorStore.createFileGroup(board.activeFileGroupId, mode);
},
startBoard: (boardId: string) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;

View File

@ -12,11 +12,31 @@ export type BoardKind =
| 'esp32-s3' // Xtensa LX7, QEMU backend
| 'xiao-esp32-s3' // Seeed XIAO ESP32-S3, QEMU (esp32-s3)
| 'arduino-nano-esp32' // Arduino Nano ESP32 (S3), QEMU (esp32-s3)
| 'esp32-c3' // RISC-V RV32IMC, browser emulation (Esp32C3Simulator)
| 'xiao-esp32-c3' // Seeed XIAO ESP32-C3, browser emulation (Esp32C3Simulator)
| 'aitewinrobot-esp32c3-supermini' // ESP32-C3 SuperMini, browser emulation (Esp32C3Simulator)
| 'esp32-c3' // RISC-V RV32IMC, QEMU backend
| 'xiao-esp32-c3' // Seeed XIAO ESP32-C3, QEMU backend
| 'aitewinrobot-esp32c3-supermini' // ESP32-C3 SuperMini, QEMU backend
| 'attiny85'; // AVR ATtiny85, browser emulation (avr8js)
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',
// ESP32-C3 RISC-V (QEMU bridge)
'esp32-c3',
'xiao-esp32-c3',
'aitewinrobot-esp32c3-supermini',
]);
export interface BoardInstance {
id: string; // unique in canvas, e.g. 'arduino-uno', 'raspberry-pi-3'
boardKind: BoardKind;
@ -28,6 +48,7 @@ export interface BoardInstance {
serialBaudRate: number;
serialMonitorOpen: boolean;
activeFileGroupId: string;
languageMode: LanguageMode; // 'arduino' (default) or 'micropython'
}
export const BOARD_KIND_LABELS: Record<BoardKind, string> = {

View File

@ -21,7 +21,7 @@ export default defineConfig({
},
},
optimizeDeps: {
include: ['avr8js', 'rp2040js', '@wokwi/elements'],
include: ['avr8js', 'rp2040js', '@wokwi/elements', 'littlefs'],
},
test: {
globals: true,