feat: Mark subproject commits as dirty for wokwi-libs; add documentation for ESP32 I2C slave simulation and debugging journey

This commit is contained in:
David Montero Crespo 2026-04-09 15:10:32 -03:00
parent 46e459f51b
commit 44dcac1059
1 changed files with 414 additions and 0 deletions

View File

@ -0,0 +1,414 @@
# ESP32 I2C Slave Simulation — Investigation, Root Causes & Fixes
> **Scope**: This document covers the full debugging journey and all fixes applied to make
> I2C sensor simulation work correctly in the lcgamboa QEMU ESP32 emulation layer used by Velxio.
> Specifically, it documents the work to make `Adafruit_MPU6050::begin()` return `true` so that
> the serial monitor shows real sensor data instead of "MPU6050 not found!".
> Target audience: future maintainers who need to understand *why* the I2C slave code is the way it is.
---
## Table of Contents
1. [Background — how I2C slaves plug into the QEMU emulation](#1-background)
2. [Root cause 1 — wrong I2C event constants](#2-root-cause-1--wrong-i2c-event-constants)
3. [Root cause 2 — wrong ACK return value convention](#3-root-cause-2--wrong-ack-return-value-convention)
4. [Root cause 3 — reg_ptr never set (WRITE events not firing)](#4-root-cause-3--reg_ptr-never-set)
5. [The picsimlab I2C protocol (ground truth)](#5-the-picsimlab-i2c-protocol-ground-truth)
6. [How write-then-read works in QEMU](#6-how-write-then-read-works-in-qemu)
7. [Final implementation of MPU6050Slave](#7-final-implementation-of-mpu6050slave)
8. [Other slaves fixed (BMP280, DS1307, DS3231)](#8-other-slaves-fixed)
9. [Test suite](#9-test-suite)
10. [Debugging infrastructure added and later removed](#10-debugging-infrastructure)
11. [End-to-end verification](#11-end-to-end-verification)
---
## 1. Background
The Velxio ESP32 simulation runs on the [lcgamboa fork of QEMU](https://github.com/lcgamboa/qemu)
(`wokwi-libs/qemu-lcgamboa`), which exposes a set of C callback hooks called **picsimlab hooks**.
These allow Python code to respond to hardware events — GPIO changes, UART bytes, and I2C
transactions — without modifying QEMU itself.
The I2C slave machinery lives in two files:
| File | Purpose |
|---|---|
| `backend/app/services/esp32_i2c_slaves.py` | One Python class per I2C device (MPU6050, BMP280, DS1307, DS3231). Each class implements `handle_event(event: int) -> int`. |
| `backend/app/services/esp32_worker.py` | Registers `_on_i2c_event` as the QEMU I2C callback. Dispatches events to the correct slave by I2C address. |
When firmware calls `Wire.beginTransmission(addr)` / `Wire.write(reg)` / `Wire.endTransmission()` /
`Wire.requestFrom(addr, n)`, QEMU fires a sequence of events at the registered callback.
---
## 2. Root Cause 1 — Wrong I2C Event Constants
### What was wrong
The original constants in `esp32_i2c_slaves.py` were:
```python
# WRONG — do not use
I2C_STOP = 0x00 # was actually START_RECV
I2C_START = 0x01 # correct label but wrong meaning assigned
I2C_READ = 0x03 # was actually FINISH
_I2C_WRITE_CODES = (0x05, 0x06) # 0x06 was actually READ
```
These were guessed without consulting the QEMU source and were wrong for three of the five event
types — causing every I2C transaction to be misinterpreted.
### How we found the ground truth
The picsimlab I2C C source lives at:
```
wokwi-libs/qemu-lcgamboa/hw/i2c/picsimlab_i2c.c
wokwi-libs/qemu-lcgamboa/include/hw/i2c/i2c.h
```
Reading `i2c.h` gives the QEMU `i2c_event` enum:
```c
typedef enum {
I2C_START_RECV = 0, // firmware called requestFrom (read direction)
I2C_START_SEND = 1, // firmware called beginTransmission (write direction)
I2C_START_SEND_ASYNC = 2,
I2C_FINISH = 3, // end of transaction (STOP or repeated-START)
I2C_NACK = 4,
} i2c_event;
```
Reading `picsimlab_i2c.c` gives the encoding for the Python `event` integer:
```c
// A byte written by firmware → (data << 8) | (I2C_NACK + 1) = (data << 8) | 5
picsimlab_i2c_tx(data): event = (data << 8) | 0x05
// Firmware requesting a byte → I2C_NACK + 2 = 6
picsimlab_i2c_rx(): event = 0x06 // return value = byte to send back
// A bus event (start/finish/nack) → raw enum value (04)
picsimlab_i2c_ev(event): event = enum value
```
### Correct constants (current code)
```python
I2C_START_RECV = 0x00 # firmware called requestFrom
I2C_START_SEND = 0x01 # firmware called beginTransmission
I2C_FINISH = 0x03 # end of transaction (STOP or repeated-START)
I2C_WRITE = 0x05 # data byte written by firmware; data = (event >> 8) & 0xFF
I2C_READ = 0x06 # firmware requesting a byte; return value = the byte
```
---
## 3. Root Cause 2 — Wrong ACK Return Value Convention
### What was wrong
All slave `handle_event` methods returned `1` for "device present / ACK" and `0` for "not present".
This is the **opposite** of what QEMU expects.
### QEMU ACK convention
From the QEMU I2C core (`hw/i2c/core.c`):
```
i2c_start_transfer() returns:
0 → ACK (device acknowledged, transfer proceeds)
1 → NACK (device not present, transfer aborted immediately)
```
### Consequence of the bug
Every `START_SEND` (beginTransmission) returned `1` = NACK.
QEMU saw NACK on the very first event and **aborted the transfer**.
No subsequent WRITE events were ever delivered.
`detected()` always failed → `begin()` always returned `false`.
### Fix
Changed every ACK return from `1` to `0`:
```python
if op in (I2C_START_RECV, I2C_START_SEND):
self.first_byte = True
return 0 # 0 = ACK in QEMU convention
```
This single change was what made WRITE events start firing.
---
## 4. Root Cause 3 — reg_ptr Never Set
### What was wrong (earlier attempt)
Before root causes 1 & 2 were found, the symptom was: WRITE events never fired, so `reg_ptr`
was never updated from its default of `0`. Every READ returned `regs[0] = 0x00` instead of
`regs[0x75] = 0x68` (WHO_AM_I).
Multiple heuristic workarounds were attempted (counting WHO_AM_I reads, defaulting `reg_ptr`
to `0x75`, a `_first_read_done` flag, auto-advancing to `0x3B` after the first read). All of
these were band-aids on the wrong root cause.
### Why they are no longer needed
Once root causes 1 & 2 were fixed:
- WRITE events fire correctly for every `Wire.write(reg)` call.
- `reg_ptr` is set by the WRITE phase and preserved into the READ phase via RSTART.
- No heuristics are needed. The code is simple and correct.
---
## 5. The picsimlab I2C Protocol (Ground Truth)
### Event encoding summary
| Python `event` value | Meaning | data byte |
|---|---|---|
| `0x00` | START_RECV — firmware called `requestFrom` | — |
| `0x01` | START_SEND — firmware called `beginTransmission` | — |
| `0x02` | START_SEND_ASYNC | — |
| `0x03` | FINISH — STOP bit or repeated-START | — |
| `0x04` | NACK | — |
| `(data<<8)\|0x05` | WRITE — firmware wrote byte `data` | `(event >> 8) & 0xFF` |
| `0x06` | READ — firmware is reading; return value = byte | return byte |
### Return value convention
| Return value | Meaning |
|---|---|
| `0` | ACK — device is present, operation succeeded |
| non-zero | NACK — device absent or error |
**For READ events**, the return value is the data byte, not an ACK/NACK. QEMU uses the
return value directly as the byte to deliver to the firmware.
---
## 6. How Write-Then-Read Works in QEMU
The Adafruit BusIO `write_then_read` pattern (used by `MPU6050::begin()`, `getEvent()`, etc.)
maps to:
```
Wire.beginTransmission(addr) → START_SEND (0x01)
Wire.write(reg) → WRITE (reg<<8)|0x05
Wire.endTransmission(false) → FINISH (0x03) ← repeated-START, NOT a STOP
Wire.requestFrom(addr, n) → START_RECV (0x00)
Wire.read() × n → READ (0x06) × n
→ FINISH (0x03)
```
**Critical**: `reg_ptr` must NOT be reset on FINISH when it is a repeated-START. The write
phase sets `reg_ptr` and the read phase (which starts immediately after) uses it. In the
implementation, `reg_ptr` is only reset implicitly — `first_byte` is reset on START so the
next WRITE byte becomes the new register pointer.
### `Adafruit_MPU6050::detected()` pattern
`detected()` only calls `endTransmission()` (no `requestFrom`):
```
START_SEND → FINISH
```
The slave must return `0` (ACK) on `START_SEND` for `detected()` to return `true`.
---
## 7. Final Implementation of MPU6050Slave
```python
I2C_START_RECV = 0x00
I2C_START_SEND = 0x01
I2C_FINISH = 0x03
I2C_WRITE = 0x05
I2C_READ = 0x06
class MPU6050Slave:
def __init__(self, addr: int = 0x68):
self.addr = addr
self.regs = bytearray(256)
self.reg_ptr = 0
self.first_byte = True
# Register defaults
self.regs[0x75] = 0x68 # WHO_AM_I
self.regs[0x6B] = 0x00 # PWR_MGMT_1 — awake (SLEEP bit cleared)
self.regs[0x3B] = 0x00 # ACCEL_XOUT_H
self.regs[0x3C] = 0x00 # ACCEL_XOUT_L
self.regs[0x3D] = 0x00 # ACCEL_YOUT_H
self.regs[0x3E] = 0x00 # ACCEL_YOUT_L
self.regs[0x3F] = 0x40 # ACCEL_ZOUT_H (+1g, 16384 LSB/g at ±2g range)
self.regs[0x40] = 0x00 # ACCEL_ZOUT_L
self.regs[0x41] = 0x62 # TEMP_OUT_H (25 °C = 0x6240 raw)
self.regs[0x42] = 0x40 # TEMP_OUT_L
self.regs[0x43] = 0x00 # GYRO_XOUT_H (0 °/s)
# ... GYRO Y/Z also 0x00
def handle_event(self, event: int) -> int:
op = event & 0xFF
data = (event >> 8) & 0xFF
if op in (I2C_START_RECV, I2C_START_SEND):
self.first_byte = True
return 0 # ACK
elif op == I2C_WRITE:
if self.first_byte:
self.reg_ptr = data # first WRITE byte = register address
self.first_byte = False
else:
self.regs[self.reg_ptr] = data
if self.reg_ptr == 0x6B:
self.regs[0x6B] &= 0x7F # auto-clear DEVICE_RESET bit
self.reg_ptr = (self.reg_ptr + 1) & 0xFF
return 0 # ACK
elif op == I2C_READ:
val = self.regs[self.reg_ptr]
self.reg_ptr = (self.reg_ptr + 1) & 0xFF
return val # data byte (not ACK/NACK)
else: # I2C_FINISH, I2C_NACK, unknown
self.first_byte = True
return 0
```
### Why `regs[0x6B] = 0x00` at init (not `0x40`)
The real MPU6050 powers up with `PWR_MGMT_1 = 0x40` (SLEEP bit set). The Adafruit library
writes `0x00` to wake it, then reads back the register in a reset-wait loop. To avoid needing
to implement the full reset-wait, we pre-set `regs[0x6B] = 0x00` so the device appears already
awake. The `auto-clear DEVICE_RESET` line in the WRITE handler is a belt-and-suspenders measure
in case firmware writes `0x80` (DEVICE_RESET).
---
## 8. Other Slaves Fixed
All I2C slaves had the same two bugs (wrong constants + wrong ACK return). They were all
updated to use the correct constants and `return 0` for ACK.
| Class | Address | Notable registers |
|---|---|---|
| `BMP280Slave` | 0x76 / 0x77 | `0xD0` = chip ID (`0x60`), calibration regs, temperature/pressure raw data |
| `DS1307Slave` | 0x68 | Timekeeping registers (seconds, minutes, hours, day, date, month, year) |
| `DS3231Slave` | 0x68 | Same layout as DS1307 plus temperature registers |
| `I2CWriteSink` | configurable | Accepts any WRITE silently (for LCD, OLED, etc.) |
---
## 9. Test Suite
File: `backend/test_esp32_i2c_slaves.py`
The test helper `i2c_read_seq` models the correct QEMU write-then-read sequence:
```python
def i2c_read_seq(slave, reg, n):
slave.handle_event(I2C_START_SEND) # write direction START → ACK
slave.handle_event((reg << 8) | I2C_WRITE) # set register pointer
slave.handle_event(I2C_FINISH) # repeated-START
slave.handle_event(I2C_START_RECV) # read direction START → ACK
data = [slave.handle_event(I2C_READ) for _ in range(n)]
slave.handle_event(I2C_FINISH) # STOP
return data
```
Key tests added for MPU6050:
| Test | What it verifies |
|---|---|
| `test_detected_pattern` | START_SEND → FINISH returns ACK (0) — `detected()` succeeds |
| `test_write_then_read_who_am_i` | Full write-then-read returns `0x68` from register `0x75` |
| `test_reg_ptr_preserved_across_rstart` | FINISH + START_RECV does not reset reg_ptr |
| `test_sequential_read_14_bytes` | Reading 14 bytes from 0x3B (accel+temp+gyro block) |
| `test_write_to_reg` | Writing to an arbitrary register updates regs[] correctly |
| `test_pwr_mgmt_reset_bit_autocleared` | Writing 0x80 to 0x6B auto-clears bit 7 |
Total test count: **54 tests, all passing**.
Run with:
```bash
cd backend
python test_esp32_i2c_slaves.py
```
---
## 10. Debugging Infrastructure
During debugging, temporary instrumentation was added and later cleaned up:
### `esp32_worker.py``_on_i2c_event` debug telemetry
A debug version emitted `i2c_debug` WebSocket messages with decoded op names for every event.
The op name map:
```python
_I2C_OP_NAME = {
0x00: 'START_RECV', 0x01: 'START_SEND', 0x02: 'START_ASYNC',
0x03: 'FINISH', 0x04: 'NACK',
0x05: 'WRITE', 0x06: 'READ',
}
```
An `i2c_trace` WebSocket message was also added (and kept) to make I2C activity visible in
the serial monitor area for slave-handled events.
### `test_mpu6050_simulation.mjs` — Node.js end-to-end test
A standalone Node.js test that:
1. Connects to the backend WebSocket
2. Compiles and uploads the MPU6050 sketch
3. Waits for `MPU6050 ready!` in serial output
4. Confirms accelerometer/gyroscope data lines arrive
Configurable via `BACKEND_URL` env var or `--backend=<url>` CLI arg (default: `ws://localhost:8002`).
---
## 11. End-to-End Verification
After all fixes:
1. Load the **"ESP32: MPU-6050 Accelerometer"** example from the gallery.
2. Click **Compile**, then **Run**.
3. Open the Serial Monitor (115200 baud).
4. Expected output:
```
MPU6050 ready!
Accel X=0.00 Y=0.00 Z=9.81 m/s²
Gyro X=0.00 Y=0.00 Z=0.00 rad/s
Temp: 25.0 C
---
```
The fix to compile the example in the frontend was separate: the Vite dev server proxy
in `frontend/vite.config.ts` must point to the correct backend port (default `8001`).
If Docker or another service occupies port 8001, stop it before starting the backend with:
```bash
cd backend
venv\Scripts\activate
uvicorn app.main:app --reload --port 8001
```
---
## Summary of All Changes
| File | Change |
|---|---|
| `backend/app/services/esp32_i2c_slaves.py` | Complete rewrite: correct constants, ACK=0, all slaves updated |
| `backend/app/services/esp32_worker.py` | Fixed `_I2C_OP_NAME` map; added `i2c_trace` emission |
| `backend/test_esp32_i2c_slaves.py` | Complete rewrite: correct event sequences, 54 tests |
| `backend/test_mpu6050_simulation.mjs` | Added `i2c_trace` handler; configurable backend URL |
| `frontend/vite.config.ts` | Proxy target must match the port where backend is listening |