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>
feature/micropython-rp2040
David Montero Crespo 2026-03-29 21:27:41 -03:00
parent e99ded70b5
commit 990ae4be8c
12 changed files with 587 additions and 38 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

@ -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.

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

@ -4,7 +4,8 @@
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useSimulatorStore, getBoardSimulator } from '../../store/useSimulatorStore';
import { RP2040Simulator } from '../../simulation/RP2040Simulator';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
@ -94,12 +95,28 @@ 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
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
}
}
}
}, [handleSend, resolvedTabId]);
const handleTabClick = (boardId: string) => {
setActiveTabId(boardId);
@ -149,7 +166,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 +204,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

@ -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,17 @@ 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_PY_CONTENT = `import RPi.GPIO as GPIO
import time
@ -79,7 +90,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 +274,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 +291,23 @@ 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;
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,51 @@ 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;
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

@ -17,6 +17,13 @@ export type BoardKind =
| 'aitewinrobot-esp32c3-supermini' // ESP32-C3 SuperMini, browser emulation (Esp32C3Simulator)
| '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',
]);
export interface BoardInstance {
id: string; // unique in canvas, e.g. 'arduino-uno', 'raspberry-pi-3'
boardKind: BoardKind;
@ -28,6 +35,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,