velxio/docs/wiki/esp32-i2c-slave-simulation.md

475 lines
16 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 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 (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 — 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 |