From 1a0877f2af682dd1558215cebdbfd2ef5ab6e005 Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 22 May 2026 19:58:24 +0200 Subject: [PATCH] feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART transmits the byte over the WebSocket as a 'serial_output' event with no GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while real silicon would render the 8N1 frame at the configured baud rate. Two changes inside Esp32Bridge: * New `onPinChangeWithTime: (pin, state, timeMs) => void` callback that hooks the oscilloscope at parity with AVRSimulator / RP2040Simulator. The 'gpio_change' event now also flows through it (timestamped with `performance.now()` — QEMU virtual time isn't surfaced across the wire, but at 1× sim speed the wall-clock skew is invisible on any practical sweep). This also fixes the broader issue that ESP32 boards previously couldn't show ANY digital GPIO activity on the scope. * `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first + stop transitions at `this.uartBaudRate` (default 115200) on the UART0 TX pin, mapped per board variant: esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1 esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43 esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21 Backend doesn't expose the live baud rate so we default to 115200 (the Arduino default). Override path: bridge.uartBaudRate = N once we surface Serial.begin's argument via a backend event. Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)` inside the three Esp32Bridge construction sites in useSimulatorStore (setBoardType, addBoard, changeBoard). --- frontend/src/simulation/Esp32Bridge.ts | 101 ++++++++++++++++++++++++ frontend/src/store/useSimulatorStore.ts | 6 ++ 2 files changed, 107 insertions(+) diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 7f1f8457..c1ce7c30 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -101,7 +101,28 @@ export class Esp32Bridge { // Callbacks wired up by useSimulatorStore onSerialData: ((char: string, uart?: number) => void) | null = null; onPinChange: ((gpioPin: number, state: boolean) => void) | null = null; + /** + * Timestamped version of onPinChange — wired to the oscilloscope so the + * scope can render ESP32 GPIO activity at the same resolution as AVR / + * RP2040 boards. Also receives the synthesized UART TX frame bits from + * `emitUartTxFrame` so a scope on GPIO1 / GPIO43 / etc. shows real bit- + * level UART waveforms during `Serial.print`, matching real silicon. + * + * QEMU virtual time isn't exposed cleanly across the WebSocket, so the + * timestamps come from `performance.now()` (wall-clock). At 1× sim + * speed this matches the AVR / RP2040 simulator-time within ~1 ms which + * is invisible on any practical sweep. + */ + onPinChangeWithTime: ((gpioPin: number, state: boolean, timeMs: number) => void) | null = null; onPinDir: ((gpioPin: number, dir: 0 | 1) => void) | null = null; + /** + * Override baud rate used to space synthesized UART bits. QEMU + * transmits bytes "instantly" so the backend doesn't surface a real + * baud rate, but for the scope to show a realistic frame we need a + * bit period. Defaults to 115200 (Arduino default). The store + * updates this when the firmware's `Serial.begin(N)` is observable. + */ + uartBaudRate: number = 115200; /** Wired by the store to `makeLedcDutyHandler` which routes * channel→pin via the per-board SignalRouter mirror. */ onLedcDuty: ((duty: LedcDuty) => void) | null = null; @@ -175,6 +196,72 @@ export class Esp32Bridge { return this._connected; } + /** + * Default UART0 TX GPIO for each ESP32 family variant. The actual pin + * is selectable via the GPIO Matrix at runtime, but exposing the live + * matrix state across the WebSocket isn't worth it — these defaults + * match what the IO_MUX picks up for the standard `Serial` port and + * are what every Arduino-ESP32 sketch ends up using unless the user + * explicitly remaps via `Serial.setPins()`. + */ + private uart0TxPin(): number { + switch (this.boardKind) { + case 'esp32-s3': + case 'xiao-esp32-s3': + case 'arduino-nano-esp32': + return 43; + case 'esp32-c3': + case 'xiao-esp32-c3': + case 'aitewinrobot-esp32c3-supermini': + return 21; + default: + // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite, … + return 1; + } + } + + /** + * Bit-level UART frame synthesis on the TX GPIO. QEMU's UART + * peripheral transmits bytes "instantly" at the virtual-time layer + * and never toggles the SoC pad — same gap closed in AVRSimulator + * and RP2040Simulator. We rebuild the standard 8N1 frame (start + * LOW + 8 data LSB-first + stop HIGH) at `this.uartBaudRate`, stamp + * each transition with wall-clock-spaced timestamps starting now, + * and push them through `onPinChangeWithTime` so the oscilloscope + * draws the waveform a real ESP32 would put on the pin. + * + * Only UART0 is synthesized today — UART1 / UART2 would need their + * own per-board GPIO mapping which Velxio doesn't currently track. + */ + private emitUartTxFrame(byte: number, uart: number = 0): void { + if (uart !== 0) return; // UART0 only for now + if (!this.onPinChangeWithTime) return; + const baud = this.uartBaudRate || 115200; + if (baud <= 0) return; + + const txPin = this.uart0TxPin(); + const bitMs = 1000 / baud; + const startMs = performance.now(); + + // Seed idle HIGH right before the start bit so the scope renders the + // start-bit transition against a HIGH baseline, matching how the line + // sits between bytes on real hardware. + this.onPinChangeWithTime(txPin, true, Math.max(0, startMs - bitMs)); + + // 8N1: start LOW, then 8 data bits LSB-first, then stop HIGH. + const bits: boolean[] = [false]; + for (let i = 0; i < 8; i++) bits.push(((byte >> i) & 1) !== 0); + bits.push(true); + + let prev = true; + for (let i = 0; i < bits.length; i++) { + if (bits[i] !== prev) { + this.onPinChangeWithTime(txPin, bits[i], startMs + i * bitMs); + prev = bits[i]; + } + } + } + get clientId(): string { return getTabSessionId() + '::' + this.boardId; } @@ -224,6 +311,15 @@ export class Esp32Bridge { if (this.onSerialData) { for (const ch of text) this.onSerialData(ch, uart); } + // Synthesize the per-byte UART waveform on the TX GPIO so the + // oscilloscope shows a real frame, matching how a real ESP32 + // drives the pin. Falls back to UART0 when no uart index is + // provided (which is the case for all current backend events). + if (this.onPinChangeWithTime) { + for (let i = 0; i < text.length; i++) { + this.emitUartTxFrame(text.charCodeAt(i) & 0xff, uart ?? 0); + } + } // MicroPython REPL injection — 4-stage state machine. // Each stage waits for a confirmed string in the serial buffer before // advancing, so we never send code before raw REPL mode is verified. @@ -275,6 +371,11 @@ export class Esp32Bridge { `[Esp32Bridge:${this.boardId}] gpio_change pin=${pin} state=${state ? 'HIGH' : 'LOW'}`, ); this.onPinChange?.(pin, state); + // Also feed the scope path so ESP32 digital pin activity shows + // up on the oscilloscope at parity with AVR / RP2040 boards. + // Wall-clock timestamp is good enough at 1× sim speed; QEMU + // virtual time isn't surfaced across the WebSocket today. + this.onPinChangeWithTime?.(pin, state, performance.now()); break; } case 'gpio_dir': { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index bd026f32..e385fe94 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -933,6 +933,10 @@ export const useSimulatorStore = create((set, get) => { const boardPm = pinManagerMap.get(id); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; + // Wire scope sampling for ESP32 (GPIO transitions + synthesized + // UART TX bits). Mirrors what AVR/RP2040 simulators get for free + // by passing the oscilloscope callback into createSimulator(). + bridge.onPinChangeWithTime = getOscilloscopeCallback(id); bridge.onCrash = () => { set({ esp32CrashBoardId: id }); }; @@ -1595,6 +1599,7 @@ export const useSimulatorStore = create((set, get) => { const boardPm = pinManagerMap.get(boardId); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; + bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId); bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); }; @@ -1696,6 +1701,7 @@ export const useSimulatorStore = create((set, get) => { const boardPm = pinManagerMap.get(boardId); if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu'); }; + bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId); bridge.onCrash = () => { set({ esp32CrashBoardId: boardId }); };