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

270 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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": "<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`):
```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 |