# ESP32 family — QEMU Waveform Injection for the SAR ADC > How Velxio achieves per-read ADC fidelity on ESP32 / ESP32-S3 / ESP32-C3, > the QEMU-side data contract, the WebSocket protocol that delivers > waveforms to the subprocess, and how to rebuild `qemu-lcgamboa` when you > change the C code. ## Why a separate doc? The AVR and RP2040 paths live entirely in JS — we monkey-patch `AVRADC.onADCRead` / `RPADC.onADCRead`. ESP32, ESP32-S3, and ESP32-C3 all run inside a forked QEMU (`third-party/qemu-lcgamboa`), so JS can't reach the ADC peripheral. Instead, the **full periodic waveform** is pushed down to the QEMU SAR ADC, which interpolates it against `QEMU_CLOCK_VIRTUAL` on every MMIO read. The `circuit-emulation-adc-aliasing.md` page covers *why* per-read sampling is needed. This page covers *how* it's implemented for the ESP32 family. --- ## 1. End-to-end pipeline ``` ┌─────────────────────────────┐ │ frontend SPICE solve (.tran)│ └──────────────┬──────────────┘ │ useElectricalStore.timeWaveforms ▼ ┌─────────────────────────────┐ │ subscribeToStore.ts │ │ pushEsp32Waveforms() │ │ - iterate boards │ │ - map ADC pin → SPICE net │ │ - quantise to 12-bit u16 │ └──────────────┬──────────────┘ │ bridge.setAdcWaveform(gpio, u12, periodNs) ▼ ┌─────────────────────────────┐ │ Esp32Bridge.ts │ │ base64(u12 LE) + period │ └──────────────┬──────────────┘ │ WebSocket "esp32_adc_waveform" ▼ ┌─────────────────────────────┐ │ FastAPI /ws/simulation │ │ esp_lib_manager. │ │ set_adc_waveform(...) │ └──────────────┬──────────────┘ │ JSON line on subprocess stdin ▼ ┌─────────────────────────────┐ │ esp32_worker.py │ │ cmd == 'set_adc_waveform' │ │ ctypes → libqemu symbol │ └──────────────┬──────────────┘ │ qemu_picsimlab_set_apin_waveform(ch, u16*, n, period_ns) ▼ ┌─────────────────────────────┐ │ esp32_sens.c / esp32c3_saradc.c │ │ - g_malloc LUT │ │ - latch epoch = virt_ns │ │ - interpolate on read │ └─────────────────────────────┘ ``` **DC fallback.** When no `.tran` result is available the subsystem falls back to the existing `qemu_picsimlab_set_apin(channel, mv)` code path so DC examples behave exactly as before. Passing `samples=NULL` or `n=0` to `qemu_picsimlab_set_apin_waveform` clears the LUT and re-arms the DC path. --- ## 2. QEMU API contract ### New symbol ```c void qemu_picsimlab_set_apin_waveform(int chn, const uint16_t *samples, int n, uint64_t period_ns); ``` - `chn` — SAR ADC channel (0-based; 8 channels on ESP32, 10 on ESP32-C3, varies by board). - `samples` — array of 12-bit raw ADC counts (`0..4095`). When the frontend quantises a SPICE voltage it uses `round(v / 3.3 · 4095)`, clamped. - `n` — number of samples. Pass `0` (or `samples=NULL`) to clear. - `period_ns` — full period of the waveform in nanoseconds. Usually the last entry of `timeWaveforms.time` × 1e9. ### Device-state additions Per SAR-ADC peripheral (`Esp32SensState`, `Esp32c3SarAdcState`): ```c uint16_t *waveform_samples[NUM_CHANNELS]; int waveform_len[NUM_CHANNELS]; uint64_t waveform_period_ns[NUM_CHANNELS]; uint64_t waveform_epoch_ns[NUM_CHANNELS]; ``` ### Read-path interpolation Pseudo-code (the real helpers live in `esp32c3_saradc_sample_waveform` and the ESP32 equivalent): ```c uint64_t now = qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL); uint64_t dt = (now - epoch) % period_ns; double t_frac = (double) dt / period_ns * (n - 1); int lo = (int) t_frac; int hi = lo + 1; double a = t_frac - lo; uint16_t raw = round(samples[lo] * (1 - a) + samples[hi] * a); ``` Clamp `raw` to `[0, 0xFFFF]`. The SAR ADC handler writes it into the 17-bit data register as usual — so as far as the guest knows, the ADC just happened to return a slightly different value on every conversion. ### Reset / cleanup The peripheral's `reset` handler frees every `waveform_samples[i]` and zeros the metadata arrays. That keeps `esp_restart()` clean and avoids leaking LUTs across reboots. --- ## 3. WebSocket protocol ### Frontend → backend ```json { "type": "esp32_adc_waveform", "data": { "channel": 0, "samples_u12_le": "", "period_ns": 20000000 } } ``` - `channel` — GPIO number to map (the backend resolves GPIO → SAR channel). - `samples_u12_le` — base64 of a `Uint16Array` in little-endian order. Each entry is a 12-bit sample occupying the low 12 bits; upper 4 bits are always zero. - `period_ns` — same as the QEMU API. Pass an empty `samples_u12_le` (base64 `""`) or `period_ns: 0` to clear. ### Backend → QEMU `esp32_worker.py` decodes the base64 into a ctypes `(c_uint16 * n)` buffer and calls `lib.qemu_picsimlab_set_apin_waveform(ch, arr, n, period_ns)`. If the symbol is missing (you haven't rebuilt QEMU yet), the worker logs a warning and silently falls through to the existing DC path. --- ## 4. Rebuilding `qemu-lcgamboa` ### Windows Requires MSYS2 + mingw64 (the user's env already has `C:\msys64\mingw64\bin`): ```bash cd third-party/qemu-lcgamboa ./configure --target-list=xtensa-softmmu,riscv32-softmmu --enable-picsimlab make -j$(nproc) ``` Copy the resulting `qemu-system-xtensa.exe` and `qemu-system-riscv32.exe` plus the generated `libqemu*.dll` into `backend/qemu-bin/` (or wherever `esp32_worker.py` picks them up — see `ESP32_QEMU_DIR` env var). First-time build is ~15-20 minutes. Incremental `make` after touching one `.c` file is seconds. ### Linux / macOS ```bash cd third-party/qemu-lcgamboa ./configure --target-list=xtensa-softmmu,riscv32-softmmu --enable-picsimlab make -j$(nproc) sudo make install # or copy binaries manually into backend/qemu-bin ``` ### Verifying the new symbol ```bash nm -D $(which qemu-system-xtensa) | grep qemu_picsimlab_set_apin_waveform ``` Should emit one match per target binary. If it doesn't, the build picked up stale object files — run `make clean` and rebuild. --- ## 5. Mapping ADC pins to GPIO ESP32 has 18 SAR-capable GPIOs split across ADC1 (8 channels) and ADC2 (10 channels); ESP32-C3 has 5 ADC channels on ADC1 only; ESP32-S3 has 10 on ADC1 + 10 on ADC2. The frontend stores the mapping in `ADC_PIN_TO_GPIO` (see `subscribeToStore.ts`). To add a new board or pin alias: 1. Add it to `ADC_PIN_MAP[boardKind]` (list of `{ pinName, channel }`). 2. Add a resolver function to `ADC_PIN_TO_GPIO[boardKind]` that converts the `pinName` + channel into the GPIO number expected by QEMU. 3. `pushEsp32Waveforms()` will pick it up automatically. --- ## 6. Data-rate sanity check At peak we push 400 samples × 2 bytes × 8 channels = 6.4 KB per solve per board. `CircuitScheduler` rate-limits to ~5 solves / sec under heavy activity, so worst-case traffic is ~32 KB/s — comfortable on the local WebSocket and well below the `ws_max_message_size` default. If you add a board with 20+ ADC channels, increase `MAX_PUSH_BYTES` in `Esp32Bridge.ts` before running large `.tran` windows. --- ## 7. Testing Unit tests for the waveform-stats helpers live at `frontend/src/__tests__/waveform-stats.test.ts`. The per-read hook itself is exercised in `frontend/src/__tests__/spice-rectifier-live-repro.test.ts` (AVR path) — the ESP32 E2E counterpart is `frontend/src/__tests__/esp32-rectifier-integration.test.ts` and is **gated behind `VELXIO_ESP32_E2E=1`** because it requires a running QEMU toolchain plus a live backend: ```bash VELXIO_ESP32_E2E=1 npm test -- esp32-rectifier-integration ``` --- ## 8. Troubleshooting | Symptom | Cause | Fix | | --- | --- | --- | | `analogRead` returns stale DC on ESP32 despite `.tran` | QEMU binary predates this patch | `make clean && make` in `qemu-lcgamboa`, redeploy to `backend/qemu-bin/` | | `esp32_worker` logs `symbol 'qemu_picsimlab_set_apin_waveform' not found` | Same — rebuild not yet deployed | As above; the worker falls back to DC so the app still runs | | Guest sees 0 V after a few frames | Waveform cleared on reset but not re-pushed | Confirm `pushEsp32Waveforms()` is called on every solve (see `installAdcReadHooks`) | | WebSocket frame ~2 MB pause | You pushed a huge `.tran` (tens of thousands of samples) | Tighten `pickDynamicAnalysis` step / stop so the solve returns fewer samples | --- ## 9. Key files | File | Role | | --- | --- | | `third-party/qemu-lcgamboa/hw/misc/esp32_sens.c` | ESP32 SAR ADC1/2 — waveform LUT + sample_waveform helper | | `third-party/qemu-lcgamboa/hw/misc/esp32c3_saradc.c` | ESP32-C3 SAR ADC — same pattern | | `third-party/qemu-lcgamboa/include/hw/misc/esp32_sens.h` | State struct + `esp32_sens_set_waveform` prototype | | `third-party/qemu-lcgamboa/include/hw/misc/esp32c3_saradc.h` | Same for C3 | | `third-party/qemu-lcgamboa/hw/xtensa/esp32_picsimlab.c` | Defines `qemu_picsimlab_set_apin_waveform` for ESP32 | | `third-party/qemu-lcgamboa/hw/riscv/esp32c3_picsimlab.c` | Defines it for ESP32-C3 | | `backend/app/services/esp32_worker.py` | ctypes bridge from stdin JSON → libqemu | | `backend/app/services/esp32_lib_manager.py::set_adc_waveform` | Queues the command on the subprocess | | `backend/app/api/routes/simulation.py` | Handles `esp32_adc_waveform` WebSocket message | | `frontend/src/simulation/Esp32Bridge.ts::setAdcWaveform` | Base64-encodes + sends the WebSocket frame | | `frontend/src/simulation/spice/subscribeToStore.ts::pushEsp32Waveforms` | Iterates boards, quantises samples, calls the bridge |