velxio/docs/wiki/circuit-emulation-esp32-qem...

10 KiB
Raw Blame History

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

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):

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):

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

{
  "type": "esp32_adc_waveform",
  "data": {
    "channel": 0,
    "samples_u12_le": "<base64>",
    "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):

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

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

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:

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