475 lines
16 KiB
Markdown
475 lines
16 KiB
Markdown
# 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()` and `Adafruit_BMP280::begin()`
|
||
> return `true` so 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
|
||
|
||
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. [Root cause 4 — BMP280 wrong chip ID (0x60 vs 0x58)](#9-root-cause-4--bmp280-wrong-chip-id)
|
||
10. [Test suite](#10-test-suite)
|
||
11. [Debugging infrastructure added and later removed](#11-debugging-infrastructure)
|
||
12. [End-to-end verification](#12-end-to-end-verification)
|
||
|
||
---
|
||
|
||
## 1. Background
|
||
|
||
The Velxio ESP32 simulation runs on the [lcgamboa fork of QEMU](https://github.com/lcgamboa/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:
|
||
|
||
```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:
|
||
|
||
```
|
||
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:
|
||
|
||
```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 — 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`:
|
||
|
||
```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 (`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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```cpp
|
||
#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):
|
||
|
||
```python
|
||
# esp32_i2c_slaves.py
|
||
self.regs[0xD0] = 0x58 # chip_id BMP280 (production silicon; BME280 uses 0x60)
|
||
```
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```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 |
|