feat(rp2040/uart): synthesize bit-level TX waveform on GP0 / GP4

Same gap as the AVR USART: rp2040js's UART fires `onByte(value)` per
transmitted byte but never toggles the corresponding GPIO, so an
oscilloscope on GP0 (UART0 TX, default for Arduino-Pico's Serial1) sees
nothing during `Serial.print`.  Real silicon drives the pin with the
full UART frame at the configured baud rate, and Velxio should match.

`emitUartTxFrame(uartIdx, byte)` derives:
  * `txPin` via FUNCSEL inspection: walk GP0 / GP12 / GP16 / GP28 (the
    four candidates for UART0 TX per RP2040 datasheet) and pick the
    first whose `functionSelect == 2` (FUNCTION_UART).  Same for UART1.
    Fall back to GP0 / GP4 when nothing is mapped (firmware hasn't
    called `Serial1.begin()` properly).
  * `baudRate` and `bitsPerChar` directly from the UART peripheral
    (rp2040js already exposes these as live getters).
  * Time from the RP2040 IClock's `nanos` counter, matching the
    existing `setupGpioListeners` path — UART waveforms therefore stack
    consistently with PIO / SIO traces on the same scope.

Both `uart[0].onByte` and `uart[1].onByte` get hooked.  The seed-idle-
HIGH baseline is pushed once per UART per simulation run; `stop()`
clears the flag so a re-run gets a fresh seed (matching how the scope
buffer is cleared on restart).
This commit is contained in:
David Montero 2026-05-22 19:54:55 +02:00
parent b587faf1b0
commit 6584a49a8f
1 changed files with 95 additions and 0 deletions

View File

@ -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');
}