feat(esp32/uart): synthesize bit-level TX waveform on UART0 TX GPIO
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).
This commit is contained in:
parent
6584a49a8f
commit
1a0877f2af
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -933,6 +933,10 @@ export const useSimulatorStore = create<SimulatorState>((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<SimulatorState>((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<SimulatorState>((set, get) => {
|
|||
const boardPm = pinManagerMap.get(boardId);
|
||||
if (boardPm) boardPm.triggerPinChange(gpioPin, state, 'mcu');
|
||||
};
|
||||
bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId);
|
||||
bridge.onCrash = () => {
|
||||
set({ esp32CrashBoardId: boardId });
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue