1242 lines
49 KiB
TypeScript
1242 lines
49 KiB
TypeScript
import { RP2040, GPIOPinState, ConsoleLogger, LogLevel, USBCDC } from 'rp2040js';
|
||
import type { RPI2C } from 'rp2040js';
|
||
import { PinManager } from './PinManager';
|
||
import { I2CBusManager, wireRpI2cToBus, nullI2CMaster } from './I2CBusManager';
|
||
import type { I2CDevice } from './I2CBusManager';
|
||
import { bootromB1 } from './rp2040-bootrom';
|
||
import { loadUF2, loadUserFiles, getFirmware } from './MicroPythonLoader';
|
||
import {
|
||
Cyw43Emulator,
|
||
PioBusSniffer,
|
||
type Cyw43Bridge,
|
||
type LedEvent,
|
||
type PacketOutEvent,
|
||
} from './cyw43';
|
||
|
||
/**
|
||
* RP2040Simulator — Emulates Raspberry Pi Pico (RP2040) using rp2040js
|
||
*
|
||
* Features:
|
||
* - ARM Cortex-M0+ dual-core Cortex-M0+ CPU at 125 MHz (single-core emulated)
|
||
* - 30 GPIO pins (GPIO0-GPIO29) xc fv nn
|
||
* - 2× UART, 2× SPI, 2× I2C
|
||
* - ADC on GPIO26-GPIO29 (A0-A3) + internal temp sensor (ch4)
|
||
* - PWM on any GPIO
|
||
* - LED_BUILTIN on GPIO25
|
||
* - Full bootrom B1 for proper boot sequence
|
||
*
|
||
* Arduino-pico pin mapping (Earle Philhower's core):
|
||
* D0 = GPIO0 … D29 = GPIO29
|
||
* A0 = GPIO26 … A3 = GPIO29
|
||
* LED_BUILTIN = GPIO25
|
||
* Default Serial → UART0 (GPIO0=TX, GPIO1=RX)
|
||
* Default I2C → I2C0 (GPIO4=SDA, GPIO5=SCL)
|
||
* Default SPI → SPI0 (GPIO16=MISO, GPIO19=MOSI, GPIO18=SCK, GPIO17=CS)
|
||
*/
|
||
|
||
const F_CPU = 125_000_000; // 125 MHz
|
||
const CYCLE_NANOS = 1e9 / F_CPU; // nanoseconds per cycle (~8 ns)
|
||
const FPS = 60;
|
||
const CYCLES_PER_MS = F_CPU / 1000; // 125 000 cycles per simulated millisecond
|
||
|
||
/** Minimal structural view of the rp2040js clock we drive. */
|
||
interface SimClock {
|
||
readonly nanosToNextAlarm: number;
|
||
tick(nanos: number): void;
|
||
}
|
||
|
||
// Real-time scheduler. The RP2040 core is ~8x heavier to emulate than the
|
||
// AVR (125 MHz vs 16 MHz), so a host that cannot execute 125 M instructions
|
||
// per second of wall-clock would otherwise run the simulation in slow motion:
|
||
// a `delay(1000)` blink renders every 4-5 s. Two mechanisms keep sim-time
|
||
// locked to wall-time:
|
||
// 1. The frame budget is derived from the MEASURED wall-clock delta (like
|
||
// AVRSimulator), not a fixed 1/60 s, so the sim never silently falls
|
||
// behind the assumed 60 fps.
|
||
// 2. A `delay()` busy-wait spins reading the timer without putting the core
|
||
// to sleep (no WFI), so the WFI fast-path never triggers and the emulator
|
||
// grinds every idle cycle. IdleSpinDetector recognises such a
|
||
// side-effect-free spin and we advance the clock over it instead of
|
||
// executing it — exactly what the WFI path already does for sleep().
|
||
const MAX_DELTA_MS = 50; // clamp the wall-clock delta (paused/backgrounded tab)
|
||
// When an idle spin is elided with no timer alarm to anchor the jump, advance
|
||
// at most this many cycles before letting the firmware re-check its deadline.
|
||
// Bounds the delay overshoot to ~1 ms; with an alarm pending we stop exactly
|
||
// at the alarm (no overshoot).
|
||
const IDLE_SLICE_CYCLES = CYCLES_PER_MS; // 1 ms
|
||
|
||
/**
|
||
* Detects a side-effect-free busy-wait spin (e.g. arduino-pico `delay()`,
|
||
* which polls the timer in a tight loop instead of sleeping). Fed the PC
|
||
* about to execute on every instruction; reads the GPIO snapshot lazily, only
|
||
* when a backward branch closes a loop iteration, so the hot path stays cheap.
|
||
*
|
||
* Reports a spin only once the SAME loop has iterated `threshold` times with
|
||
* NO GPIO change (input or output) — so a bit-bang loop (toggles a pin every
|
||
* iteration) and an input-poll that just saw its pin move are never elided,
|
||
* and neither is a loop that calls out (long forward jump resets the count).
|
||
* A false positive is bounded-harmless: we only ever advance time up to the
|
||
* wall-clock budget, never past the next timer alarm or scheduled pin change.
|
||
*/
|
||
export class IdleSpinDetector {
|
||
private prevPc = -1;
|
||
private loopTarget = -1;
|
||
private iters = 0;
|
||
private gpioAtLastIter = -1;
|
||
|
||
constructor(
|
||
private readonly threshold = 32,
|
||
private readonly maxStride = 256,
|
||
) {}
|
||
|
||
/**
|
||
* @param pc program counter about to execute
|
||
* @param gpio thunk returning the current GPIO snapshot (called only on a
|
||
* backward branch, so the 30-pin scan stays off the hot path)
|
||
* @returns true when a stable, side-effect-free spin is detected
|
||
*/
|
||
observe(pc: number, gpio: () => number): boolean {
|
||
const prev = this.prevPc;
|
||
this.prevPc = pc;
|
||
if (prev === -1) return false;
|
||
|
||
if (pc < prev) {
|
||
// Backward branch — one loop iteration just closed.
|
||
const g = gpio();
|
||
if (this.loopTarget !== pc) {
|
||
// First time we land on this loop top (or the loop moved): start over.
|
||
this.loopTarget = pc;
|
||
this.gpioAtLastIter = g;
|
||
this.iters = 1;
|
||
return false;
|
||
}
|
||
if (g !== this.gpioAtLastIter) {
|
||
// A pin changed during the iteration — real work (bit-bang) or an
|
||
// input arrived. Not idle; restart the count from this iteration.
|
||
this.gpioAtLastIter = g;
|
||
this.iters = 1;
|
||
return false;
|
||
}
|
||
this.iters++;
|
||
return this.iters >= this.threshold;
|
||
}
|
||
|
||
if (pc > prev + this.maxStride) {
|
||
// Long forward jump (call / loop exit) — left the tight spin.
|
||
this.reset();
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Call right after eliding a slice so the firmware re-checks its deadline
|
||
* (executes the loop body again) before the next jump. */
|
||
noteElided(): void {
|
||
this.iters = 0;
|
||
}
|
||
|
||
reset(): void {
|
||
this.prevPc = -1;
|
||
this.loopTarget = -1;
|
||
this.iters = 0;
|
||
this.gpioAtLastIter = -1;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Backward-compatible alias for the unified `I2CDevice` shape used by
|
||
* both AVR and RP2040 buses now that I2CBusManager is the canonical
|
||
* abstraction. Existing call sites that import `RP2040I2CDevice` keep
|
||
* working without changes.
|
||
*/
|
||
export type RP2040I2CDevice = I2CDevice;
|
||
|
||
export class RP2040Simulator {
|
||
private rp2040: RP2040 | null = null;
|
||
private running = false;
|
||
private animationFrame: number | null = null;
|
||
public pinManager: PinManager;
|
||
private speed = 1.0;
|
||
private gpioUnsubscribers: Array<() => void> = [];
|
||
private flashCopy: Uint8Array | null = null;
|
||
private totalCycles = 0;
|
||
private scheduledPinChanges: Array<{ cycle: number; pin: number; state: boolean }> = [];
|
||
private pioStepAccum = 0;
|
||
private usbCDC: USBCDC | null = null;
|
||
private micropythonMode = false;
|
||
// Real-time scheduler state (see IdleSpinDetector + runFrameForTime).
|
||
private lastTimestamp = 0;
|
||
private readonly idleDetector = new IdleSpinDetector();
|
||
|
||
// ── Pico W WiFi (CYW43439) — only attached when boardKind === 'pi-pico-w'.
|
||
private cyw43: Cyw43Emulator | null = null;
|
||
private cyw43Sniffer: PioBusSniffer | null = null;
|
||
private cyw43Bridge: Cyw43Bridge | null = null;
|
||
private cyw43HookedFifos: Array<{ restore: () => void }> = [];
|
||
|
||
/** Serial output callback — fires for each byte the Pico sends on UART0 (or USBCDC in MicroPython mode) */
|
||
public onSerialData: ((char: string) => void) | null = null;
|
||
|
||
/**
|
||
* Generic SPI bus adapter — same shape as AVRSimulator.spi so SPI parts
|
||
* (ILI9341, SD cards, custom chips) can hook the bus uniformly across
|
||
* boards. Defaults to RP2040 SPI0; firmware that uses SPI1 will need to
|
||
* wrap rp2040.spi[1] manually until we add a .spi1 alias.
|
||
*
|
||
* Lazy-initialised so the rp2040.spi[0].onTransmit is only overridden
|
||
* once a part actually accesses .spi (avoiding clobbering the default
|
||
* loopback handler if no SPI part is on the canvas).
|
||
*/
|
||
private _spiAdapter: { onByte: ((mosi: number) => void) | null;
|
||
completeTransfer: (miso: number) => void } | null = null;
|
||
public get spi(): { onByte: ((mosi: number) => void) | null;
|
||
completeTransfer: (miso: number) => void } {
|
||
if (!this._spiAdapter) {
|
||
const adapter = {
|
||
onByte: null as ((mosi: number) => void) | null,
|
||
completeTransfer: (miso: number) => {
|
||
this.rp2040?.spi[0].completeTransmit(miso & 0xff);
|
||
},
|
||
};
|
||
// Re-route SPI0's onTransmit through our adapter when initMCU /
|
||
// initMicroPython runs. Until rp2040 is constructed (mcu=null) the
|
||
// setter just stages the handler — we wire it in start().
|
||
this._spiAdapter = adapter;
|
||
if (this.rp2040) {
|
||
this.rp2040.spi[0].onTransmit = (v: number) => adapter.onByte?.(v);
|
||
}
|
||
}
|
||
return this._spiAdapter;
|
||
}
|
||
|
||
/** Fires when the on-board LED on Pico W (driven through the CYW43, not GPIO 25) toggles. */
|
||
public onPicoWLed: ((on: boolean) => void) | null = null;
|
||
/** Fires whenever the chip emits a Wi-Fi link-up event for the synthetic AP. */
|
||
public onPicoWWifiUp: ((ssid: string) => void) | null = null;
|
||
|
||
/**
|
||
* Fires for every GPIO pin transition with a millisecond timestamp.
|
||
* Used by the oscilloscope / logic analyzer.
|
||
* timeMs is derived from the RP2040 cycle counter (cycles / F_CPU * 1000).
|
||
*/
|
||
public onPinChangeWithTime: ((pin: number, state: boolean, timeMs: number) => void) | null = null;
|
||
|
||
/**
|
||
* Track whether the first byte has been transmitted on each UART since
|
||
* the firmware booted. Used to seed the oscilloscope baseline at idle
|
||
* HIGH the first time a frame goes out, mirroring how real silicon
|
||
* idles the TX line HIGH once UARTEN is asserted.
|
||
*/
|
||
private uartTxSeeded: [boolean, boolean] = [false, false];
|
||
|
||
/**
|
||
* One `I2CBusManager` per hardware I2C controller (RP2040 has two:
|
||
* I2C0/Wire and I2C1/Wire1). Constructed up-front in the
|
||
* simulator's constructor with a placeholder master so that cross-
|
||
* board bridges + device registrations can land BEFORE firmware
|
||
* loads. The real RPI2C peripheral takes over in `wireI2C()` via
|
||
* `attachMaster` + `wireRpI2cToBus`.
|
||
*/
|
||
private i2cBuses: [I2CBusManager, I2CBusManager];
|
||
|
||
constructor(pinManager: PinManager) {
|
||
this.pinManager = pinManager;
|
||
this.i2cBuses = [
|
||
new I2CBusManager(nullI2CMaster()),
|
||
new I2CBusManager(nullI2CMaster()),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Load a compiled binary into the RP2040 flash memory.
|
||
* Accepts a base64-encoded string of the raw .bin file output by arduino-cli.
|
||
*/
|
||
loadBinary(base64: string): void {
|
||
console.log('[RP2040] Loading binary...');
|
||
|
||
const binaryStr = atob(base64);
|
||
const bytes = new Uint8Array(binaryStr.length);
|
||
for (let i = 0; i < binaryStr.length; i++) {
|
||
bytes[i] = binaryStr.charCodeAt(i);
|
||
}
|
||
|
||
console.log(`[RP2040] Binary size: ${bytes.length} bytes`);
|
||
this.flashCopy = bytes;
|
||
|
||
this.initMCU(bytes);
|
||
console.log('[RP2040] CPU initialized with bootrom, UART, I2C, SPI, GPIO');
|
||
}
|
||
|
||
/** Same interface as AVRSimulator for store compatibility */
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
loadHex(_hexContent: string): void {
|
||
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> {
|
||
// Pico W needs the RPI_PICO_W build (network + CYW43 driver + bigger,
|
||
// higher LittleFS). The cyw43 emulator is attached (via attachCyw43) only
|
||
// for pi-pico-w boards, so its presence selects the firmware variant.
|
||
const variant = this.cyw43 ? 'pico-w' : 'pico';
|
||
console.log(`[RP2040] Loading MicroPython firmware (${variant})...`);
|
||
|
||
// 1. Get MicroPython UF2 firmware (cached in IndexedDB)
|
||
const firmware = await getFirmware(variant, onProgress);
|
||
|
||
// 2. Create fresh RP2040 instance
|
||
this.rp2040 = new RP2040();
|
||
this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false);
|
||
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 (variant-specific
|
||
// flash offset — the Pico W FS lives higher than the plain Pico's).
|
||
await loadUserFiles(files, this.rp2040.flash, variant);
|
||
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);
|
||
// Default loopback for SPI0 — overridden by the generic .spi adapter
|
||
// if a SPI part later accesses simulator.spi. The adapter routes
|
||
// onTransmit into adapter.onByte and uses completeTransmit to drive
|
||
// MISO when the part calls completeTransfer.
|
||
this.rp2040.spi[0].onTransmit = (v: number) => {
|
||
if (this._spiAdapter && this._spiAdapter.onByte) {
|
||
this._spiAdapter.onByte(v);
|
||
} else {
|
||
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;
|
||
|
||
// Pico W: attachCyw43 installed the gSPI PIO-FIFO hooks on the RP2040
|
||
// instance that existed at board-creation time. loadMicroPython just swapped
|
||
// in a fresh RP2040, so those hooks now point at the discarded instance.
|
||
// Re-install them on the new PIO FIFOs or the cyw43 driver's bit-banged
|
||
// traffic never reaches the emulator and WiFi never connects.
|
||
if (this.cyw43) {
|
||
this.cyw43HookedFifos = [];
|
||
this.installCyw43PioHooks();
|
||
}
|
||
|
||
this.setupGpioListeners();
|
||
this.micropythonMode = true;
|
||
console.log('[RP2040] MicroPython ready');
|
||
}
|
||
|
||
/** Returns true if currently in MicroPython mode */
|
||
isMicroPythonMode(): boolean {
|
||
return this.micropythonMode;
|
||
}
|
||
|
||
// ── Pico W (CYW43439) attachment ────────────────────────────────────────
|
||
|
||
/**
|
||
* Wire a CYW43 chip emulator onto this RP2040 instance. Should only be
|
||
* called for ``pi-pico-w`` boards. Idempotent — calling twice is a no-op.
|
||
*
|
||
* The emulator observes outbound PIO TX FIFO writes (which the cyw43
|
||
* driver uses to bit-bang the gSPI bus) and feeds back synthesised
|
||
* responses. When a Cyw43Bridge is supplied, outbound Ethernet frames
|
||
* are forwarded to the backend network bridge and inbound packets
|
||
* coming back from the bridge are queued for the chip to deliver.
|
||
*/
|
||
attachCyw43(bridge: Cyw43Bridge | null = null): Cyw43Emulator {
|
||
if (this.cyw43) return this.cyw43;
|
||
const emu = new Cyw43Emulator();
|
||
const sniffer = new PioBusSniffer();
|
||
// The sniffer de-swaps commands/data per the chip's word-order regime,
|
||
// which the emulator owns (flips at the SPI_BUS_CONTROL write).
|
||
sniffer.setModeProvider(() => emu.isBigEndian());
|
||
this.cyw43 = emu;
|
||
this.cyw43Sniffer = sniffer;
|
||
this.cyw43Bridge = bridge;
|
||
|
||
emu.onLed((ev: LedEvent) => {
|
||
this.onPicoWLed?.(ev.on);
|
||
});
|
||
emu.onConnect((ev) => {
|
||
this.onPicoWWifiUp?.(ev.ssid);
|
||
});
|
||
emu.onPacketOut((ev: PacketOutEvent) => {
|
||
this.cyw43Bridge?.sendPacket(ev.ether);
|
||
});
|
||
// Drive WL_HOST_WAKE (GPIO24, active-high). The driver gates poll_device on
|
||
// this pin until it has received its first packet, so without it the first
|
||
// IOCTL response is never read and wifi_on stalls.
|
||
emu.onHostWake((active: boolean) => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
try { (this.rp2040 as any)?.gpio?.[24]?.setInputValue(active); } catch { /* noop */ }
|
||
});
|
||
|
||
if (bridge) {
|
||
bridge.onPacketIn = (p) => emu.injectPacket(p.ether);
|
||
// A real backend bridge owns the network, so disable the built-in
|
||
// DHCP/ARP responder to avoid answering on its behalf.
|
||
emu.setVirtualNet(null);
|
||
}
|
||
|
||
this.installCyw43PioHooks();
|
||
return emu;
|
||
}
|
||
|
||
/** Detach the CYW43 emulator (called from teardown). */
|
||
detachCyw43(): void {
|
||
for (const h of this.cyw43HookedFifos) h.restore();
|
||
this.cyw43HookedFifos = [];
|
||
this.cyw43 = null;
|
||
this.cyw43Sniffer = null;
|
||
this.cyw43Bridge = null;
|
||
}
|
||
|
||
/** Read access for tests / debug panels. */
|
||
getCyw43(): Cyw43Emulator | null { return this.cyw43; }
|
||
|
||
/**
|
||
* Hook every PIO state machine's ``txFIFO.push`` so the CYW43 emulator
|
||
* sees every word the cyw43 driver bit-bangs onto the bus, and
|
||
* mirror responses back into ``rxFIFO`` so the driver's reads land
|
||
* without needing a real chip on the wire.
|
||
*/
|
||
private installCyw43PioHooks(): void {
|
||
if (!this.rp2040 || !this.cyw43 || !this.cyw43Sniffer) return;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const pios: any[] = (this.rp2040 as any).pio;
|
||
for (const pio of pios) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
for (const sm of pio.machines as any[]) {
|
||
const tx = sm.txFIFO;
|
||
const rx = sm.rxFIFO;
|
||
if (!tx || !rx) continue;
|
||
const sniffer = this.cyw43Sniffer;
|
||
// Make the TX FIFO NON-DROPPING (head-pointer queue). rp2040js's 4-deep
|
||
// FIFO silently drops words once full, which truncates the 260-word F2
|
||
// IOCTL writes (clm_load, the connect ioctls) so the chip never sees a
|
||
// complete frame. Real hardware paces the DMA with DREQ and never drops.
|
||
// To keep the ~224 KB firmware download cheap we still discard the bulk
|
||
// of each firmware/backplane write (inDiscardableWriteData): the PIO
|
||
// drains the few kept words, raises TXSTALL, and the driver moves on.
|
||
const q: number[] = [];
|
||
let head = 0;
|
||
const origFull = Object.getOwnPropertyDescriptor(tx, 'full');
|
||
const origEmpty = Object.getOwnPropertyDescriptor(tx, 'empty');
|
||
const origItem = Object.getOwnPropertyDescriptor(tx, 'itemCount');
|
||
const origPush: (v: number) => void = tx.push.bind(tx);
|
||
const origPull: () => number = tx.pull.bind(tx);
|
||
const origPeek = tx.peek?.bind(tx);
|
||
const origReset = tx.reset?.bind(tx);
|
||
Object.defineProperty(tx, 'full', { get: () => false, configurable: true });
|
||
Object.defineProperty(tx, 'empty', { get: () => head >= q.length, configurable: true });
|
||
Object.defineProperty(tx, 'itemCount', { get: () => q.length - head, configurable: true });
|
||
tx.peek = () => (head < q.length ? q[head] : 0);
|
||
tx.reset = () => { q.length = 0; head = 0; };
|
||
tx.push = (value: number) => {
|
||
if (sniffer.inDiscardableWriteData()) {
|
||
if (q.length - head < 4) q.push(value >>> 0); // keep a few so the PIO TXSTALLs
|
||
return;
|
||
}
|
||
// Feed the gSPI sniffer; commands that produce a response queue it
|
||
// for on-demand delivery (see the rxFIFO.pull hook below).
|
||
this.feedCyw43Word(value);
|
||
q.push(value >>> 0);
|
||
};
|
||
tx.pull = () => {
|
||
if (head >= q.length) return 0;
|
||
const v = q[head++];
|
||
if (head > 8192 && head * 2 > q.length) { q.splice(0, head); head = 0; } // compact
|
||
return v;
|
||
};
|
||
this.cyw43HookedFifos.push({
|
||
restore: () => {
|
||
if (origFull) Object.defineProperty(tx, 'full', origFull); else delete tx.full;
|
||
if (origEmpty) Object.defineProperty(tx, 'empty', origEmpty); else delete tx.empty;
|
||
if (origItem) Object.defineProperty(tx, 'itemCount', origItem); else delete tx.itemCount;
|
||
tx.push = origPush;
|
||
tx.pull = origPull;
|
||
if (origPeek) tx.peek = origPeek;
|
||
if (origReset) tx.reset = origReset;
|
||
},
|
||
});
|
||
// Reset the gSPI framing at each transfer boundary. cyw43_spi_transfer
|
||
// does pio_sm_restart before pushing the count words, so this keeps the
|
||
// sniffer deterministic even across the firmware-stream fast-path.
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
if (typeof (sm as any).restart === 'function') {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const origRestart: () => void = (sm as any).restart.bind(sm);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(sm as any).restart = () => { this.cyw43Sniffer?.reset(); return origRestart(); };
|
||
this.cyw43HookedFifos.push({
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
restore: () => { (sm as any).restart = origRestart; },
|
||
});
|
||
}
|
||
// Serve the chip's response when the driver's DMA actually reads the
|
||
// RX FIFO. Pushing into the FIFO eagerly raced the async DMA/PIO and
|
||
// the data arrived late or was lost; serving on pull keeps it in lock
|
||
// step with the driver.
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const origRxPull: () => number = (rx as any).pull.bind(rx);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(rx as any).pull = () =>
|
||
this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origRxPull();
|
||
this.cyw43HookedFifos.push({
|
||
restore: () => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(rx as any).pull = origRxPull;
|
||
},
|
||
});
|
||
}
|
||
}
|
||
// Re-sync WL_HOST_WAKE: loadMicroPython swaps in a fresh RP2040 (GPIO reset
|
||
// to low) while the chip's frame queue — and thus its host-wake level —
|
||
// persists. onHostWake only fires on changes, so push the current level now.
|
||
try {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
(this.rp2040 as any)?.gpio?.[24]?.setInputValue(this.cyw43.hostWakeLevel());
|
||
} catch { /* noop */ }
|
||
}
|
||
|
||
private cyw43RxQueue: number[] = [];
|
||
private feedCyw43Word(word: number): void {
|
||
if (!this.cyw43Sniffer || !this.cyw43) return;
|
||
for (const ev of this.cyw43Sniffer.feedWord(word)) {
|
||
if (ev.kind === 'payload') {
|
||
const reply = this.cyw43.onCommand(ev.cmd, ev.payload, ev.readBytes);
|
||
if (reply && reply.length > 0) this.queueCyw43Reply(reply);
|
||
}
|
||
}
|
||
}
|
||
|
||
private queueCyw43Reply(reply: Uint8Array): void {
|
||
// 32-bit big-endian repacking with the same halfword swap the PIO
|
||
// program does on input. We push host-byte-order words; the SM's
|
||
// shift register puts them on the wire LSB-first per the gSPI spec.
|
||
for (let i = 0; i + 4 <= reply.length; i += 4) {
|
||
const w =
|
||
((reply[i + 3] << 24) | (reply[i + 2] << 16) | (reply[i + 1] << 8) | reply[i]) >>> 0;
|
||
this.cyw43RxQueue.push(w);
|
||
}
|
||
if (reply.length % 4 !== 0) {
|
||
// Pad to 4 bytes with zeros — the driver discards trailing bytes
|
||
// it didn't request.
|
||
const tail = reply.subarray(reply.length - (reply.length % 4));
|
||
let w = 0;
|
||
for (let i = 0; i < tail.length; i++) w |= tail[i] << (i * 8);
|
||
this.cyw43RxQueue.push(w >>> 0);
|
||
}
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
getADC(): any {
|
||
return this.rp2040?.adc ?? null;
|
||
}
|
||
|
||
/** Get underlying RP2040 instance (for advanced usage / tests) */
|
||
getMCU(): RP2040 | null {
|
||
return this.rp2040;
|
||
}
|
||
|
||
// ── Private initialization ───────────────────────────────────────────────
|
||
|
||
private initMCU(programBytes: Uint8Array): void {
|
||
this.rp2040 = new RP2040();
|
||
|
||
// Suppress noisy internal logs (only show errors)
|
||
this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false);
|
||
|
||
// Load RP2040 B1 bootrom — needed for proper boot sequence
|
||
this.rp2040.loadBootrom(bootromB1);
|
||
|
||
// Load binary into flash starting at offset 0 (maps to 0x10000000)
|
||
this.rp2040.flash.set(programBytes, 0);
|
||
|
||
// Set PC to flash start (boot vector)
|
||
this.rp2040.core.PC = 0x10000000;
|
||
|
||
// ── Wire UART0 (default Serial port for Arduino-Pico) ────────────
|
||
let serialBuffer = '';
|
||
this.rp2040.uart[0].onByte = (value: number) => {
|
||
const ch = String.fromCharCode(value);
|
||
serialBuffer += ch;
|
||
if (ch === '\n') {
|
||
console.log('[RP2040 UART0]', serialBuffer.trimEnd());
|
||
serialBuffer = '';
|
||
}
|
||
if (this.onSerialData) {
|
||
this.onSerialData(ch);
|
||
}
|
||
// Synthesize the bit-level waveform on the UART0 TX pin so an
|
||
// oscilloscope on it sees a real frame — rp2040js doesn't drive the
|
||
// GPIO when the UART transmits. See emitUartTxFrame().
|
||
this.emitUartTxFrame(0, value);
|
||
};
|
||
|
||
// ── Wire UART1 (Serial1) — also forward to onSerialData for now ──
|
||
this.rp2040.uart[1].onByte = (value: number) => {
|
||
if (this.onSerialData) {
|
||
this.onSerialData(String.fromCharCode(value));
|
||
}
|
||
this.emitUartTxFrame(1, value);
|
||
};
|
||
|
||
// ── Wire I2C0 and I2C1 ───────────────────────────────────────────
|
||
this.wireI2C(0);
|
||
this.wireI2C(1);
|
||
|
||
// ── Wire SPI0 and SPI1 ────────────────────────────────────────────
|
||
// SPI0 must check for a registered .spi adapter on every byte. If a
|
||
// part on the canvas (ILI9341, custom chip, …) accessed simulator.spi
|
||
// BEFORE this initMCU runs, the adapter is already staged but
|
||
// _adapter.onByte points at the part's handler — we have to route
|
||
// the byte through it. Without this, SPI parts see nothing and the
|
||
// canvas stays black (real regression — Pico Doom shipped with this
|
||
// bug for months because the same wiring in initMicroPython was
|
||
// adapter-aware but this Arduino path wasn't).
|
||
this.rp2040.spi[0].onTransmit = (v: number) => {
|
||
if (this._spiAdapter && this._spiAdapter.onByte) {
|
||
this._spiAdapter.onByte(v);
|
||
} else {
|
||
this.rp2040!.spi[0].completeTransmit(v);
|
||
}
|
||
};
|
||
this.rp2040.spi[1].onTransmit = (value: number) => {
|
||
this.rp2040!.spi[1].completeTransmit(value); // loopback
|
||
};
|
||
|
||
// ── Set default ADC values ───────────────────────────────────────
|
||
// Channel 0-3: GPIO26-29, channel 4: internal temp sensor
|
||
// Default to mid-range (~1.65V on 3.3V ref, 12-bit)
|
||
this.rp2040.adc.channelValues[0] = 2048;
|
||
this.rp2040.adc.channelValues[1] = 2048;
|
||
this.rp2040.adc.channelValues[2] = 2048;
|
||
this.rp2040.adc.channelValues[3] = 2048;
|
||
// Internal temp sensor: T = 27 - (V - 0.706) / 0.001721
|
||
// For 27°C: V = 0.706V → ADC = 0.706/3.3 * 4095 ≈ 876
|
||
this.rp2040.adc.channelValues[4] = 876;
|
||
|
||
// ── Patch PIO to use synchronous stepping instead of setTimeout ──
|
||
// rp2040js PIO uses setTimeout(() => this.run(), 0) which deadlocks
|
||
// when the CPU busy-waits for PIO FIFO space (e.g. pio_sm_put_blocking).
|
||
// We step PIO synchronously in the execute loop instead.
|
||
// 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;
|
||
}
|
||
// No-op: execute loop calls pio.step() synchronously
|
||
};
|
||
}
|
||
this.pioStepAccum = 0;
|
||
|
||
// ── Set up GPIO listeners ────────────────────────────────────────
|
||
this.setupGpioListeners();
|
||
}
|
||
|
||
/**
|
||
* Resolve the GPIO index currently routed to a given UART's TX line.
|
||
*
|
||
* The RP2040 GPIO function-select register decides which signal each pad
|
||
* carries; UART has FUNCSEL == 2. Per datasheet, UART0_TX can land on
|
||
* GP0 / GP12 / GP16 / GP28 and UART1_TX on GP4 / GP8 / GP20 / GP24. We
|
||
* walk the candidates and pick the first whose function select is UART.
|
||
* If none is mapped (rare — the firmware hasn't called `Serial.begin()`
|
||
* properly) fall back to the default for that UART (GP0 / GP4).
|
||
*/
|
||
private rp2040UartTxPin(uartIdx: 0 | 1): number {
|
||
const FUNCTION_UART = 2;
|
||
const candidates = uartIdx === 0 ? [0, 12, 16, 28] : [4, 8, 20, 24];
|
||
if (this.rp2040) {
|
||
for (const g of candidates) {
|
||
const pin = this.rp2040.gpio[g];
|
||
if (pin && (pin as unknown as { functionSelect: number }).functionSelect === FUNCTION_UART) {
|
||
return g;
|
||
}
|
||
}
|
||
}
|
||
return uartIdx === 0 ? 0 : 4;
|
||
}
|
||
|
||
/**
|
||
* Synthesize a bit-level UART frame on the TX pin so the oscilloscope
|
||
* sees a real waveform during `Serial.print` / `Serial1.print`.
|
||
*
|
||
* rp2040js's UART peripheral fires `onByte(value)` per transmitted byte
|
||
* but never toggles the corresponding GPIO — the same gap closed in
|
||
* AVRSimulator.emitUartTxFrame(). Here we do the same: build the frame
|
||
* (start LOW + data LSB-first + stop HIGH) using the UART's live
|
||
* `baudRate` and `bitsPerChar`, then push one transition per bit-change
|
||
* through `onPinChangeWithTime` so the scope draws the waveform at the
|
||
* actual silicon-equivalent baud rate.
|
||
*
|
||
* Time is taken from the RP2040 clock (nanos counter), matching the
|
||
* existing GPIO-listener path in `setupGpioListeners()` — UART
|
||
* waveforms therefore stack consistently with any other pin trace.
|
||
*/
|
||
private emitUartTxFrame(uartIdx: 0 | 1, byte: number): void {
|
||
if (!this.rp2040 || !this.onPinChangeWithTime) return;
|
||
const uart = this.rp2040.uart[uartIdx];
|
||
if (!uart) return;
|
||
const baud = uart.baudRate;
|
||
if (!baud || baud <= 0) return;
|
||
|
||
const txPin = this.rp2040UartTxPin(uartIdx);
|
||
const dataBits = uart.bitsPerChar;
|
||
const clk = (this.rp2040 as unknown as { clock?: { nanos: number } }).clock;
|
||
const startMs = clk ? clk.nanos / 1_000_000 : 0;
|
||
const bitMs = 1000 / baud;
|
||
|
||
// First frame after boot: seed an explicit idle HIGH one bit-period
|
||
// before the start bit so the scope has a HIGH baseline to draw the
|
||
// start-bit transition against. Subsequent frames inherit the HIGH
|
||
// baseline from the previous frame's stop bit.
|
||
if (!this.uartTxSeeded[uartIdx]) {
|
||
this.onPinChangeWithTime(txPin, true, Math.max(0, startMs - bitMs));
|
||
this.uartTxSeeded[uartIdx] = true;
|
||
}
|
||
|
||
const bits: boolean[] = [false]; // start bit
|
||
for (let i = 0; i < dataBits; i++) {
|
||
bits.push(((byte >> i) & 1) !== 0);
|
||
}
|
||
bits.push(true); // stop bit (rp2040js doesn't expose 2-stop-bit selection
|
||
// cleanly; default to 1 — same behaviour as 8N1 sketches)
|
||
|
||
let prevState = true;
|
||
for (let i = 0; i < bits.length; i++) {
|
||
if (bits[i] !== prevState) {
|
||
this.onPinChangeWithTime(txPin, bits[i], startMs + i * bitMs);
|
||
prevState = bits[i];
|
||
}
|
||
}
|
||
}
|
||
|
||
private wireI2C(bus: 0 | 1): void {
|
||
if (!this.rp2040) return;
|
||
const i2c: RPI2C = this.rp2040.i2c[bus];
|
||
// Swap in the real RPI2C peripheral and route its per-callback
|
||
// events into the existing bus manager. Any devices + bridges
|
||
// registered before the firmware loaded are preserved.
|
||
const busManager = this.i2cBuses[bus];
|
||
busManager.attachMaster(i2c);
|
||
wireRpI2cToBus(i2c, busManager);
|
||
}
|
||
|
||
private setupGpioListeners(): void {
|
||
this.gpioUnsubscribers.forEach((fn) => fn());
|
||
this.gpioUnsubscribers = [];
|
||
|
||
if (!this.rp2040) return;
|
||
|
||
for (let gpioIdx = 0; gpioIdx < 30; gpioIdx++) {
|
||
const pin = gpioIdx;
|
||
const gpio = this.rp2040.gpio[gpioIdx];
|
||
if (!gpio) continue;
|
||
|
||
const unsub = gpio.addListener((state: GPIOPinState) => {
|
||
const isHigh = state === GPIOPinState.High || state === GPIOPinState.InputPullUp;
|
||
this.pinManager.triggerPinChange(pin, isHigh, 'mcu');
|
||
if (this.onPinChangeWithTime && this.rp2040) {
|
||
// IClock interface exposes `nanos` (not `timeUs`)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const clk = (this.rp2040 as any).clock;
|
||
const timeMs = clk ? (clk.nanos as number) / 1_000_000 : 0;
|
||
this.onPinChangeWithTime(pin, isHigh, timeMs);
|
||
}
|
||
});
|
||
this.gpioUnsubscribers.push(unsub);
|
||
}
|
||
}
|
||
|
||
// ── Public API ───────────────────────────────────────────────────────────
|
||
|
||
start(): void {
|
||
if (this.running || !this.rp2040) {
|
||
console.warn('[RP2040] Already running or not initialized');
|
||
return;
|
||
}
|
||
|
||
this.running = true;
|
||
this.lastTimestamp = 0;
|
||
this.idleDetector.reset();
|
||
console.log('[RP2040] Starting simulation at 125 MHz...');
|
||
|
||
const execute = (timestamp: number) => {
|
||
if (!this.running || !this.rp2040) return;
|
||
|
||
// Derive this frame's cycle budget from the MEASURED wall-clock delta
|
||
// (mirrors AVRSimulator) so the sim cannot silently run in slow motion
|
||
// by assuming a perfect 60 fps. First frame falls back to one frame; the
|
||
// upper clamp (paused/backgrounded tab) is applied in runFrameForTime.
|
||
const deltaMs = this.lastTimestamp === 0 ? 1000 / FPS : timestamp - this.lastTimestamp;
|
||
this.lastTimestamp = timestamp;
|
||
|
||
try {
|
||
this.runFrameForTime(deltaMs);
|
||
} catch (error) {
|
||
console.error('[RP2040] Simulation error:', error);
|
||
this.stop();
|
||
return;
|
||
}
|
||
|
||
this.animationFrame = requestAnimationFrame(execute);
|
||
};
|
||
|
||
this.animationFrame = requestAnimationFrame(execute);
|
||
}
|
||
|
||
/**
|
||
* Run one frame's worth of simulation for `deltaMs` of wall-clock time.
|
||
* Returns counters for tests. Keeps simulated time locked to wall-clock:
|
||
* idle spins (busy-wait `delay()`) and WFI sleeps advance the clock instead
|
||
* of executing every idle cycle, so timing stays correct even when the host
|
||
* cannot emulate 125 MHz in real time. Exposed (not private) so the
|
||
* real-time scheduler can be driven deterministically in tests without rAF.
|
||
*/
|
||
runFrameForTime(deltaMs: number): { cyclesAdvanced: number; instructionsExecuted: number } {
|
||
if (!this.rp2040) return { cyclesAdvanced: 0, instructionsExecuted: 0 };
|
||
// Guard against NaN/negative deltas and clamp the upper bound so a single
|
||
// frame never simulates more than MAX_DELTA_MS of CPU time (a paused or
|
||
// backgrounded tab must not trigger a multi-second catch-up burst).
|
||
let dt = deltaMs > 0 ? deltaMs : 1000 / FPS;
|
||
if (dt > MAX_DELTA_MS) dt = MAX_DELTA_MS;
|
||
const cyclesTarget = Math.max(1, Math.floor(CYCLES_PER_MS * dt * this.speed));
|
||
const { core } = this.rp2040;
|
||
const clock = (this.rp2040 as unknown as { clock?: SimClock }).clock ?? null;
|
||
const pioDiv = this.getPIOClockDiv();
|
||
const gpioSnapshot = () => this.rp2040!.gpioValues;
|
||
|
||
let cyclesDone = 0;
|
||
let instructionsExecuted = 0;
|
||
while (cyclesDone < cyclesTarget) {
|
||
if (core.waiting) {
|
||
// CPU asleep (WFI/WFE): jump to the next timer alarm, but never past
|
||
// this frame's wall-clock budget, so a long sleep advances at real
|
||
// time across frames rather than leaping ahead.
|
||
if (!clock || clock.nanosToNextAlarm <= 0) {
|
||
this.stepPIO(); // nothing scheduled to wake it this frame
|
||
break;
|
||
}
|
||
const jumped = this.advanceClock(cyclesTarget - cyclesDone, pioDiv, clock);
|
||
if (jumped <= 0) break;
|
||
cyclesDone += jumped;
|
||
} else if (this.idleDetector.observe(core.PC, gpioSnapshot)) {
|
||
// Detected a side-effect-free busy-wait spin (e.g. delay()): advance
|
||
// the clock over it instead of grinding every cycle. Capped at the
|
||
// next alarm/scheduled pin change inside advanceClock, and to a small
|
||
// slice so the firmware re-checks its deadline (bounds overshoot).
|
||
const budget = Math.min(cyclesTarget - cyclesDone, IDLE_SLICE_CYCLES);
|
||
const jumped = this.advanceClock(budget, pioDiv, clock);
|
||
if (jumped <= 0) {
|
||
cyclesDone += this.execOne(core, clock, pioDiv);
|
||
instructionsExecuted++;
|
||
} else {
|
||
cyclesDone += jumped;
|
||
this.idleDetector.noteElided();
|
||
}
|
||
} else {
|
||
cyclesDone += this.execOne(core, clock, pioDiv);
|
||
instructionsExecuted++;
|
||
}
|
||
}
|
||
return { cyclesAdvanced: cyclesDone, instructionsExecuted };
|
||
}
|
||
|
||
/** Execute one ARM instruction in the production loop, advancing the clock
|
||
* and stepping PIO. Returns the cycles it took. */
|
||
private execOne(
|
||
core: { executeInstruction(): number },
|
||
clock: SimClock | null,
|
||
pioDiv: number,
|
||
): number {
|
||
const cycles: number = core.executeInstruction();
|
||
if (clock) clock.tick(cycles * CYCLE_NANOS);
|
||
this.totalCycles += cycles;
|
||
this.pioStepAccum += cycles;
|
||
while (this.pioStepAccum >= pioDiv) {
|
||
this.pioStepAccum -= pioDiv;
|
||
this.stepPIO();
|
||
}
|
||
this.flushScheduledPinChanges();
|
||
return cycles;
|
||
}
|
||
|
||
/**
|
||
* Advance the simulated clock by up to `budgetCycles` WITHOUT executing
|
||
* instructions, stepping PIO at the PIO clock rate so GPIO timestamps stay
|
||
* accurate. Never advances past the next timer alarm or the next scheduled
|
||
* pin change (so those still fire at their exact simulated time). Returns
|
||
* the number of cycles actually advanced.
|
||
*/
|
||
private advanceClock(budgetCycles: number, pioDiv: number, clock: SimClock | null): number {
|
||
if (budgetCycles <= 0 || !clock) return 0;
|
||
const alarmNanos: number = clock.nanosToNextAlarm ?? 0;
|
||
const alarmCycles = alarmNanos > 0 ? Math.ceil(alarmNanos / CYCLE_NANOS) : Infinity;
|
||
const nextPin =
|
||
this.scheduledPinChanges.length > 0
|
||
? this.scheduledPinChanges[0].cycle - this.totalCycles
|
||
: Infinity;
|
||
let jumped = Math.min(budgetCycles, alarmCycles, nextPin > 0 ? nextPin : Infinity);
|
||
if (!Number.isFinite(jumped) || jumped <= 0) return 0;
|
||
jumped = Math.ceil(jumped);
|
||
|
||
const totalNanos = jumped * CYCLE_NANOS;
|
||
const nanoPerPioStep = pioDiv * CYCLE_NANOS;
|
||
const pioSteps = Math.min(Math.floor(jumped / pioDiv), 50000);
|
||
let nanosStepped = 0;
|
||
for (let i = 0; i < pioSteps; i++) {
|
||
clock.tick(nanoPerPioStep);
|
||
nanosStepped += nanoPerPioStep;
|
||
this.stepPIO();
|
||
}
|
||
const remaining = totalNanos - nanosStepped;
|
||
if (remaining > 0) clock.tick(remaining);
|
||
this.totalCycles += jumped;
|
||
this.flushScheduledPinChanges();
|
||
return jumped;
|
||
}
|
||
|
||
stop(): void {
|
||
if (!this.running) return;
|
||
this.running = false;
|
||
if (this.animationFrame !== null) {
|
||
cancelAnimationFrame(this.animationFrame);
|
||
this.animationFrame = null;
|
||
}
|
||
// Force a new idle-HIGH seed on the next byte: the scope buffer is
|
||
// typically cleared on stop/start, so the previous run's "seeded"
|
||
// flag would suppress the baseline sample for the next session.
|
||
this.uartTxSeeded = [false, false];
|
||
this.lastTimestamp = 0;
|
||
this.idleDetector.reset();
|
||
console.log('[RP2040] Simulation stopped');
|
||
}
|
||
|
||
reset(): void {
|
||
this.stop();
|
||
this.totalCycles = 0;
|
||
this.scheduledPinChanges = [];
|
||
this.idleDetector.reset();
|
||
if (this.rp2040 && this.flashCopy) {
|
||
if (this.micropythonMode) {
|
||
// In MicroPython mode, restore the full flash snapshot (UF2 + LittleFS)
|
||
this.rp2040 = new RP2040();
|
||
this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false);
|
||
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');
|
||
}
|
||
}
|
||
|
||
isRunning(): boolean {
|
||
return this.running;
|
||
}
|
||
|
||
setSpeed(speed: number): void {
|
||
this.speed = Math.max(0.1, Math.min(10.0, speed));
|
||
}
|
||
|
||
getSpeed(): number {
|
||
return this.speed;
|
||
}
|
||
|
||
/** Returns the CPU clock frequency in Hz. */
|
||
getClockHz(): number {
|
||
return F_CPU;
|
||
}
|
||
|
||
/** Returns total CPU cycles executed since last reset/load. */
|
||
getCurrentCycles(): number {
|
||
return this.totalCycles;
|
||
}
|
||
|
||
/**
|
||
* Schedule a GPIO pin state change at a specific future cycle count.
|
||
* Enables cycle-accurate protocol simulation (e.g. HC-SR04 echo timing).
|
||
*/
|
||
schedulePinChange(pin: number, state: boolean, atCycle: number): void {
|
||
let i = this.scheduledPinChanges.length;
|
||
while (i > 0 && this.scheduledPinChanges[i - 1].cycle > atCycle) i--;
|
||
this.scheduledPinChanges.splice(i, 0, { cycle: atCycle, pin, state });
|
||
}
|
||
|
||
/** Get the PIO clock divider from the first enabled state machine. */
|
||
private getPIOClockDiv(): number {
|
||
if (!this.rp2040) return 64;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
for (const pio of (this.rp2040 as any).pio) {
|
||
if (pio.stopped) continue;
|
||
for (const m of pio.machines) {
|
||
if (m.enabled) {
|
||
return Math.max(1, m.clockDivInt || 1);
|
||
}
|
||
}
|
||
}
|
||
return 64; // default
|
||
}
|
||
|
||
/** Step PIO state machines synchronously (prevents setTimeout deadlock). */
|
||
private stepPIO(): void {
|
||
if (!this.rp2040) return;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const pio = (this.rp2040 as any).pio;
|
||
if (pio[0] && !pio[0].stopped) pio[0].step();
|
||
if (pio[1] && !pio[1].stopped) pio[1].step();
|
||
}
|
||
|
||
private flushScheduledPinChanges(): void {
|
||
if (this.scheduledPinChanges.length === 0) return;
|
||
while (
|
||
this.scheduledPinChanges.length > 0 &&
|
||
this.scheduledPinChanges[0].cycle <= this.totalCycles
|
||
) {
|
||
const { pin, state } = this.scheduledPinChanges.shift()!;
|
||
this.setPinState(pin, state);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Drive a GPIO pin externally (e.g. from a button or slider).
|
||
* GPIO n = Arduino D(n) for Raspberry Pi Pico.
|
||
*/
|
||
setPinState(arduinoPin: number, state: boolean): void {
|
||
if (!this.rp2040) return;
|
||
const gpio = this.rp2040.gpio[arduinoPin];
|
||
if (gpio) {
|
||
gpio.setInputValue(state);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send text to UART0 RX (or USBCDC in MicroPython mode).
|
||
*/
|
||
serialWrite(text: string): void {
|
||
if (!this.rp2040) return;
|
||
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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Register a virtual I2C device on the specified bus (0 or 1).
|
||
* Default bus 0 = Wire, bus 1 = Wire1. Devices are added directly
|
||
* to the bus manager (which exists from construction time, with a
|
||
* placeholder master until the real RPI2C is wired in start()).
|
||
*/
|
||
addI2CDevice(device: I2CDevice, bus: 0 | 1 = 0): void {
|
||
this.i2cBuses[bus].addDevice(device);
|
||
}
|
||
|
||
/** Remove an I2C device by address from the given bus. */
|
||
removeI2CDevice(address: number, bus: 0 | 1 = 0): void {
|
||
this.i2cBuses[bus].removeDevice(address);
|
||
}
|
||
|
||
/**
|
||
* Get the I2CBusManager for a given hardware bus (0 or 1).
|
||
* Available from construction time so Interconnect can install
|
||
* cross-board bridges before firmware loads.
|
||
*/
|
||
getI2CBus(bus: 0 | 1 = 0): I2CBusManager {
|
||
return this.i2cBuses[bus];
|
||
}
|
||
|
||
/**
|
||
* Execute one ARM instruction synchronously and return the number
|
||
* of CPU cycles it took. Mirrors `AVRSimulator.step()` for tests
|
||
* that need deterministic single-stepping outside the
|
||
* `requestAnimationFrame` loop used in production. No-op if the
|
||
* firmware has not been loaded.
|
||
*
|
||
* Does NOT advance PIO or fire scheduled pin changes — for those
|
||
* use the production `start()` loop or call `stepCycles(n)`.
|
||
*/
|
||
step(): number {
|
||
if (!this.rp2040) return 0;
|
||
const core = this.rp2040.core;
|
||
const clock = this.rp2040.clock;
|
||
if (core.waiting) {
|
||
// CPU is in WFE/WFI — advance clock to the next alarm so an
|
||
// interrupt can wake it. Without this, single-stepping a
|
||
// waiting CPU spins indefinitely.
|
||
const jump = clock?.nanosToNextAlarm ?? CYCLE_NANOS;
|
||
if (jump > 0 && clock) clock.tick(jump);
|
||
this.totalCycles += Math.ceil((jump || CYCLE_NANOS) / CYCLE_NANOS);
|
||
return Math.ceil((jump || CYCLE_NANOS) / CYCLE_NANOS);
|
||
}
|
||
const cycles: number = core.executeInstruction();
|
||
if (clock) clock.tick(cycles * CYCLE_NANOS);
|
||
this.totalCycles += cycles;
|
||
return cycles;
|
||
}
|
||
|
||
/**
|
||
* Drive the CPU forward by approximately `targetCycles` cycles,
|
||
* synchronously. Useful for test harnesses that want bounded,
|
||
* deterministic execution without depending on
|
||
* `requestAnimationFrame`. Returns the actual number of cycles
|
||
* consumed (may exceed targetCycles by at most the cost of one
|
||
* instruction).
|
||
*/
|
||
stepCycles(targetCycles: number): number {
|
||
let consumed = 0;
|
||
while (consumed < targetCycles) {
|
||
const c = this.step();
|
||
if (c === 0) break; // firmware not loaded
|
||
consumed += c;
|
||
}
|
||
return consumed;
|
||
}
|
||
|
||
/**
|
||
* Set ADC channel value (0-4095 for 12-bit).
|
||
* Channels 0-3 = GPIO26-29, channel 4 = internal temperature sensor.
|
||
*/
|
||
setADCValue(channel: number, value: number): void {
|
||
if (!this.rp2040) return;
|
||
if (channel >= 0 && channel < 5) {
|
||
this.rp2040.adc.channelValues[channel] = Math.max(0, Math.min(4095, value));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set SPI onTransmit handler for a bus (0 or 1).
|
||
* callback receives TX byte and must call completeTransmit on the SPI instance.
|
||
*/
|
||
setSPIHandler(bus: 0 | 1, handler: (value: number) => number): void {
|
||
if (!this.rp2040) return;
|
||
const spi = this.rp2040.spi[bus];
|
||
spi.onTransmit = (value: number) => {
|
||
const response = handler(value);
|
||
spi.completeTransmit(response);
|
||
};
|
||
}
|
||
|
||
// ── Generic sensor registration (board-agnostic API) ──────────────────────
|
||
// RP2040 handles all sensor protocols locally via schedulePinChange,
|
||
// so these return false / no-op — the sensor runs its own frontend logic.
|
||
|
||
registerSensor(_type: string, _pin: number, _props: Record<string, unknown>): boolean {
|
||
return false;
|
||
}
|
||
updateSensor(_pin: number, _props: Record<string, unknown>): void {}
|
||
unregisterSensor(_pin: number): void {}
|
||
}
|