16 KiB
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()andAdafruit_BMP280::begin()returntrueso that the serial monitor shows real sensor data instead of "MPU6050 not found!" / "BMP280 not found! Check wiring.". Target audience: future maintainers who need to understand why the I2C slave code is the way it is.
Table of Contents
- Background — how I2C slaves plug into the QEMU emulation
- Root cause 1 — wrong I2C event constants
- Root cause 2 — wrong ACK return value convention
- Root cause 3 — reg_ptr never set (WRITE events not firing)
- The picsimlab I2C protocol (ground truth)
- How write-then-read works in QEMU
- Final implementation of MPU6050Slave
- Other slaves fixed (BMP280, DS1307, DS3231)
- Root cause 4 — BMP280 wrong chip ID (0x60 vs 0x58)
- Test suite
- Debugging infrastructure added and later removed
- End-to-end verification
1. Background
The Velxio ESP32 simulation runs on the lcgamboa fork of QEMU
(third-party/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 — event constants
The original constants in esp32_i2c_slaves.py were:
# 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:
third-party/qemu-lcgamboa/hw/i2c/picsimlab_i2c.c
third-party/qemu-lcgamboa/include/hw/i2c/i2c.h
Reading i2c.h gives the QEMU i2c_event enum:
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:
// 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)
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 — ACK convention
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:
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_ptris 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
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 (0x58), 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. Root Cause 4 — BMP280 Wrong Chip ID
What was wrong — BMP280 chip ID
BMP280Slave._init_calibration() initialised register 0xD0 (chip ID) to 0x60:
self.regs[0xD0] = 0x60 # chip_id BMP280 ← WRONG
0x60 is the chip ID of the BME280 (the humidity-capable sibling). The real BMP280
production silicon returns 0x58.
Why it mattered
Adafruit_BMP280::begin() calls Adafruit_I2CDevice::begin(), which probes the I2C bus,
then reads register 0xD0 and compares it against the compile-time constant:
#define BMP280_CHIPID (0x58)
// inside begin():
if (chip_id != BMP280_CHIPID && chip_id != BME280_CHIPID) return false;
With the slave returning 0x60, the library matched the BME280_CHIPID branch — which only
works if Adafruit_BME280 is used, not Adafruit_BMP280. In practice the example sketch uses
Adafruit_BMP280, so begin() returned false and the serial monitor printed:
BMP280 not found! Check wiring.
Fix — chip ID 0x60 → 0x58
Changed esp32_i2c_slaves.py and I2CBusManager.ts (AVR/RP2040 path):
# esp32_i2c_slaves.py
self.regs[0xD0] = 0x58 # chip_id BMP280 (production silicon; BME280 uses 0x60)
// frontend/src/simulation/I2CBusManager.ts
r[0xD0] = 0x58; // chip_id BMP280 (production silicon; BME280 uses 0x60)
The frontend unit test in virtual-i2c-devices.test.ts was updated accordingly:
// Before
expect(dev.readByte()).toBe(0x60);
// After
expect(dev.readByte()).toBe(0x58);
10. Test Suite
File: backend/test_esp32_i2c_slaves.py
The test helper i2c_read_seq models the correct QEMU write-then-read sequence:
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:
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:
_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:
- Connects to the backend WebSocket
- Compiles and uploads the MPU6050 sketch
- Waits for
MPU6050 ready!in serial output - 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:
- Load the "ESP32: MPU-6050 Accelerometer" example from the gallery.
- Click Compile, then Run.
- Open the Serial Monitor (115200 baud).
- 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:
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 |