diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index cf8d5bff..34bd848d 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -114,6 +114,14 @@ export class RP2040Simulator { */ 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 @@ -424,6 +432,10 @@ export class RP2040Simulator { 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 ── @@ -431,6 +443,7 @@ export class RP2040Simulator { if (this.onSerialData) { this.onSerialData(String.fromCharCode(value)); } + this.emitUartTxFrame(1, value); }; // ── Wire I2C0 and I2C1 ─────────────────────────────────────────── @@ -489,6 +502,84 @@ export class RP2040Simulator { 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]; @@ -615,6 +706,10 @@ export class RP2040Simulator { 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]; console.log('[RP2040] Simulation stopped'); }