feat: Mark subproject commits as dirty for wokwi-libs; add documentation for ESP32 I2C slave simulation and debugging journey
This commit is contained in:
parent
46e459f51b
commit
44dcac1059
|
|
@ -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 (0–4)
|
||||||
|
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 |
|
||||||
Loading…
Reference in New Issue