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
parent
e99ded70b5
commit
990ae4be8c
|
|
@ -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/*
|
||||
docs/github-issues/*
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['avr8js', 'rp2040js', '@wokwi/elements'],
|
||||
include: ['avr8js', 'rp2040js', '@wokwi/elements', 'littlefs'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue