feat: Update I2C event handling and improve MPU-6050 slave emulation; enhance README and tests

This commit is contained in:
David Montero Crespo 2026-04-08 00:02:54 -03:00
parent 0f2c39f23b
commit ca6520e48d
6 changed files with 201 additions and 51 deletions

View File

@ -289,9 +289,32 @@ docker compose -f docker-compose.prod.yml up -d
**Prerequisites:** Node.js 18+, Python 3.12+, arduino-cli
```bash
git clone https://github.com/davidmonterocrespo24/velxio.git
git clone --recurse-submodules https://github.com/davidmonterocrespo24/velxio.git
cd velxio
```
> **Already cloned without `--recurse-submodules`?** The `wokwi-libs/` directories will be empty. Run:
> ```bash
> git submodule update --init --recursive
> ```
> If that fails because the submodule pointers are stale, clone the libs fresh:
> ```bash
> cd wokwi-libs
> git clone --depth=1 https://github.com/wokwi/avr8js.git avr8js
> git clone --depth=1 https://github.com/wokwi/wokwi-elements.git wokwi-elements
> git clone --depth=1 https://github.com/wokwi/rp2040js.git rp2040js
> cd ..
> ```
**Build the Wokwi libraries** (required before running the frontend):
```bash
cd wokwi-libs/avr8js && npm install && npm run build && cd ../..
cd wokwi-libs/wokwi-elements && npm install && npm run build && cd ../..
cd wokwi-libs/rp2040js && npm install && npm run build && cd ../..
```
```bash
# Backend
cd backend
python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate

View File

@ -4,11 +4,14 @@ esp32_i2c_slaves.py — Standalone I2C slave state machines for ESP32 QEMU simul
Each class emulates the I2C register map of a real sensor, handling the picsimlab
I2C event protocol:
event & 0xFF00:
0x0100 = START return 1 (ACK = device present)
0x0200 = WRITE (byte in bits 7:0) return 1 (ACK)
0x0300 = READ return register byte at current pointer
0x0000 = STOP
event & 0x00FF (LOW byte) = operation type:
0x01 = START return 1 (ACK = device present)
0x05 = WRITE (byte in bits 15:8) return 1 (ACK)
0x06 = WRITE (byte in bits 15:8) return 1 (ACK) continuation byte
0x03 = READ return register byte at current pointer
0x00 = STOP
Data byte for WRITE: (event >> 8) & 0xFF (HIGH byte)
These classes are imported by esp32_worker.py and by test_esp32_i2c_slaves.py.
"""
@ -16,20 +19,31 @@ These classes are imported by esp32_worker.py and by test_esp32_i2c_slaves.py.
import datetime as _datetime
# ── Protocol constants (actual picsimlab encoding) ────────────────────────────
I2C_STOP = 0x00 # event & 0xFF
I2C_START = 0x01 # event & 0xFF
I2C_READ = 0x03 # event & 0xFF
# WRITE uses two codes:
# 0x05 = first byte in a write burst (register address)
# 0x06 = subsequent byte in a write burst (data)
# Both are handled identically by slaves — first_byte flag distinguishes address vs data.
_I2C_WRITE_CODES = (0x05, 0x06)
# ── MPU-6050 IMU ──────────────────────────────────────────────────────────────
class MPU6050Slave:
"""Full MPU-6050 register-map I2C slave emulation (address 0x68 or 0x69)."""
I2C_START = 0x0100
I2C_WRITE = 0x0200
I2C_READ = 0x0300
def __init__(self, addr: int = 0x68):
self.addr = addr
self.regs = bytearray(256)
self.reg_ptr = 0
# Default reg_ptr to WHO_AM_I so the first READ (without a preceding
# WRITE, as happens with Adafruit BusIO write-then-read) returns 0x68.
self.reg_ptr = 0x75
self.first_byte = True
self._first_read_done = False # True after begin() WHO_AM_I is read
# WHO_AM_I
self.regs[0x75] = 0x68
@ -49,22 +63,35 @@ class MPU6050Slave:
# GYRO all zero (stationary)
def handle_event(self, event: int) -> int:
phase = event & 0xFF00
if phase == self.I2C_START:
op = event & 0xFF # low byte = operation type
data = (event >> 8) & 0xFF # high byte = data byte (for WRITE)
if op == I2C_START:
self.first_byte = True
if self._first_read_done:
# picsimlab does not fire WRITE callbacks for write-then-read
# transactions (endTransmission(false) + requestFrom). After
# begin() has succeeded, reset reg_ptr to the accel/gyro/temp
# block so data reads return the right bytes even without a
# prior WRITE setting the register address.
self.reg_ptr = 0x3B
return 1 # ACK — device present
elif phase == self.I2C_WRITE:
data = event & 0xFF
elif op in _I2C_WRITE_CODES:
if self.first_byte:
self.reg_ptr = data
self.first_byte = False
else:
self.regs[self.reg_ptr] = data
# Auto-clear DEVICE_RESET bit (reg 0x6B bit 7) so the
# Adafruit begin() reset-wait loop exits immediately.
if self.reg_ptr == 0x6B:
self.regs[0x6B] &= 0x7F
self.reg_ptr = (self.reg_ptr + 1) & 0xFF
return 1 # ACK
elif phase == self.I2C_READ:
elif op == I2C_READ:
val = self.regs[self.reg_ptr]
self.reg_ptr = (self.reg_ptr + 1) & 0xFF
self._first_read_done = True
return val
else: # STOP / unknown
self.first_byte = True
@ -190,18 +217,19 @@ class BMP280Slave:
self._update_measurements()
def handle_event(self, event: int) -> int:
phase = event & 0xFF00
if phase == 0x0100:
op = event & 0xFF
data = (event >> 8) & 0xFF
if op == I2C_START:
self.first_byte = True; return 1
elif phase == 0x0200:
data = event & 0xFF
elif op in _I2C_WRITE_CODES:
if self.first_byte:
self.reg_ptr = data; self.first_byte = False
else:
self.regs[self.reg_ptr] = data
self.reg_ptr = (self.reg_ptr + 1) & 0xFF
return 1
elif phase == 0x0300:
elif op == I2C_READ:
val = self.regs[self.reg_ptr]
self.reg_ptr = (self.reg_ptr + 1) & 0xFF
return val
@ -234,15 +262,16 @@ class DS1307Slave:
return 0x00
def handle_event(self, event: int) -> int:
phase = event & 0xFF00
if phase == 0x0100:
op = event & 0xFF
data = (event >> 8) & 0xFF
if op == I2C_START:
self.first_byte = True; return 1
elif phase == 0x0200:
data = event & 0xFF
elif op in _I2C_WRITE_CODES:
if self.first_byte:
self.reg_ptr = data; self.first_byte = False
return 1
elif phase == 0x0300:
elif op == I2C_READ:
val = self._read_reg(self.reg_ptr)
self.reg_ptr = (self.reg_ptr + 1) & 0x3F
return val
@ -279,14 +308,16 @@ class I2CWriteSink:
self._buf: list[int] = []
def handle_event(self, event: int) -> int:
phase = event & 0xFF00
if phase == 0x0100: # START — reset buffer
op = event & 0xFF
data = (event >> 8) & 0xFF
if op == I2C_START: # START — reset buffer
self._buf = []; return 1
elif phase == 0x0200: # WRITE — accumulate byte
self._buf.append(event & 0xFF); return 1
elif phase == 0x0300: # READ — write-only device
elif op in _I2C_WRITE_CODES: # WRITE — accumulate byte
self._buf.append(data); return 1
elif op == I2C_READ: # READ — write-only device
return 0xFF
else: # STOP — emit transaction
else: # STOP — emit transaction
if self._buf:
self._emit({'type': 'i2c_transaction',
'addr': self.addr, 'data': list(self._buf)})

View File

@ -585,8 +585,13 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
"""Synchronous — must return immediately; called from QEMU thread."""
# Register-map slaves (MPU-6050, etc.) take priority over static responses
slave = _i2c_slaves.get(addr)
op = event & 0xFF
data = (event >> 8) & 0xFF
if slave is not None:
return slave.handle_event(event)
result = slave.handle_event(event)
_log(f'I2C bus={bus_id} addr=0x{addr:02x} event=0x{event:04x} op=0x{op:02x} data=0x{data:02x} result=0x{result:02x} slave={type(slave).__name__} reg_ptr=0x{getattr(slave,"reg_ptr",0):02x}')
return result
_log(f'I2C bus={bus_id} addr=0x{addr:02x} event=0x{event:04x} op=0x{op:02x} NO_SLAVE registered={list(_i2c_slaves.keys())}')
resp = _i2c_responses.get(addr, 0)
if not _stopped.is_set():
_emit({'type': 'i2c_event', 'bus': bus_id, 'addr': addr,
@ -705,8 +710,9 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = sink
_sensors[gpio] = sensor_data
_log(f'Pre-registered sensor {sensor_type} on GPIO {gpio}')
_sensors_ready.set()
_log(f'esp32_i2c_slaves: MPU6050Slave default reg_ptr=0x{_MPU6050Slave().reg_ptr:02x} (expect 0x75)')
_log(f'_i2c_slaves registered: {list(_i2c_slaves.keys())}')
_emit({'type': 'system', 'event': 'booted'})
_log(f'QEMU started: machine={machine} firmware={firmware_path}')
@ -802,7 +808,7 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
sensor_data['i2c_addr'] = i2c_addr
sensor_data['slave'] = slave
elif sensor_type in ('ds1307', 'ds3231'):
i2c_addr = 0x68
i2c_addr = int(cmd.get('addr', 0x68))
slave = _DS3231Slave() if sensor_type == 'ds3231' else _DS1307Slave()
_i2c_slaves[i2c_addr] = slave
sensor_data['i2c_addr'] = i2c_addr

View File

@ -5,11 +5,14 @@ Covers BMP280Slave, DS1307Slave, DS3231Slave, and I2CWriteSink from
app/services/esp32_i2c_slaves.py the Python register-map emulators
that handle I2C traffic from QEMU-simulated ESP32 firmware.
I2C event encoding (picsimlab convention):
0x0100 = START slave must return 1 (ACK = device present)
0x0200 | b = WRITE byte b slave returns 1 (ACK)
0x0300 = READ slave returns register byte at current pointer
0x0000 = STOP slave resets state
Actual picsimlab I2C event encoding (confirmed by observing real QEMU events):
event & 0xFF = operation type:
0x01 = START slave must return 1 (ACK = device present)
0x05 = WRITE (first byte / register address) slave returns 1 (ACK)
0x06 = WRITE (subsequent bytes / data) slave returns 1 (ACK)
0x03 = READ slave returns register byte at current pointer
0x00 = STOP slave resets state
(event >> 8) & 0xFF = data byte (for WRITE events)
Run from the backend/ directory:
python test_esp32_i2c_slaves.py
@ -27,18 +30,20 @@ from app.services.esp32_i2c_slaves import (
DS1307Slave,
DS3231Slave,
I2CWriteSink,
MPU6050Slave,
)
# ── I2C protocol helpers ───────────────────────────────────────────────────────
# ── I2C protocol helpers (correct picsimlab encoding) ─────────────────────────
I2C_START = 0x0100
I2C_START = 0x0001
I2C_STOP = 0x0000
I2C_READ = 0x0300
I2C_READ = 0x0003
def i2c_write(byte: int) -> int:
return 0x0200 | (byte & 0xFF)
"""WRITE event: data in high byte, type 0x05 in low byte."""
return ((byte & 0xFF) << 8) | 0x05
def i2c_read_seq(slave, reg: int, n: int) -> list[int]:
@ -366,6 +371,76 @@ class TestI2CWriteSink(unittest.TestCase):
self.assertEqual(result, 0)
# ══════════════════════════════════════════════════════════════════════════════
# MPU6050 Slave Tests
# ══════════════════════════════════════════════════════════════════════════════
class TestMPU6050Slave(unittest.TestCase):
def setUp(self):
self.mpu = MPU6050Slave()
def test_who_am_i_default_addr(self):
"""WHO_AM_I register (0x75) must return 0x68."""
result = i2c_read_seq(self.mpu, 0x75, 1)
self.assertEqual(result[0], 0x68)
def test_start_ack(self):
"""START event must return 1 (ACK — device present)."""
self.assertEqual(self.mpu.handle_event(I2C_START), 1)
def test_begin_no_write_events(self):
"""Simulates Adafruit BusIO write-then-read: START then READ, no WRITE.
picsimlab does not fire WRITE callbacks for write-then-read transactions,
so the slave must return WHO_AM_I (0x68) on the first READ regardless."""
m = MPU6050Slave()
self.assertEqual(m.handle_event(I2C_START), 1) # START → ACK
result = m.handle_event(I2C_READ) # READ without prior WRITE
self.assertEqual(result, 0x68,
f"Expected WHO_AM_I=0x68 without WRITE, got 0x{result:02x}")
def test_data_read_after_begin(self):
"""After begin() succeeds, START should reset reg_ptr to 0x3B (accel block)."""
m = MPU6050Slave()
# Simulate begin(): START + READ (no WRITE) → gets WHO_AM_I
m.handle_event(I2C_START)
m.handle_event(I2C_READ) # _first_read_done = True
# Next transaction: START should reset reg_ptr to 0x3B
m.handle_event(I2C_START)
first_accel_byte = m.handle_event(I2C_READ)
self.assertEqual(first_accel_byte, m.regs[0x3B],
"After begin(), START should reset reg_ptr to 0x3B (accel block)")
def test_accel_z_default_1g(self):
"""ACCEL_Z should default to +1g = 0x4000 (MSB=0x40, LSB=0x00)."""
result = i2c_read_seq(self.mpu, 0x3F, 2)
accel_z = (result[0] << 8) | result[1]
self.assertEqual(accel_z, 0x4000, f"Expected ACCEL_Z=0x4000, got 0x{accel_z:04x}")
def test_update_accel(self):
"""update() must reflect new accel values in register reads."""
self.mpu.update(accel_x=1.0, accel_y=0.0, accel_z=0.0)
result = i2c_read_seq(self.mpu, 0x3B, 2)
accel_x = (result[0] << 8) | result[1]
# 1g at ±2g full-scale = 16384 = 0x4000
self.assertEqual(accel_x, 0x4000)
def test_device_reset_bit_auto_cleared(self):
"""Writing 0x80 to PWR_MGMT_1 (0x6B) must auto-clear bit 7 immediately."""
self.mpu.handle_event(I2C_START)
self.mpu.handle_event(i2c_write(0x6B)) # register address
self.mpu.handle_event(0x8006) # write 0x80 (DEVICE_RESET)
# Read back: bit 7 should be 0 (reset complete)
result = i2c_read_seq(self.mpu, 0x6B, 1)
self.assertEqual(result[0] & 0x80, 0, "DEVICE_RESET bit must auto-clear")
def test_alternate_address(self):
"""MPU6050 at address 0x69 (AD0=HIGH) must still return WHO_AM_I=0x68."""
mpu69 = MPU6050Slave(addr=0x69)
result = i2c_read_seq(mpu69, 0x75, 1)
self.assertEqual(result[0], 0x68)
# ── Runner ────────────────────────────────────────────────────────────────────
if __name__ == '__main__':

View File

@ -1082,7 +1082,7 @@ PartSimulationRegistry.register('bmp280', {
* Ambient temperature defaults to 25°C; override via `element.temperature`.
*/
PartSimulationRegistry.register('ds3231', {
attachEvents: (element, simulator, _getPin) => {
attachEvents: (element, simulator, _getPin, componentId) => {
const sim = simulator as any;
const el = element as any;
@ -1098,7 +1098,13 @@ PartSimulationRegistry.register('ds3231', {
const virtualPin = 200 + 0x68;
const initTemp = el.temperature !== undefined ? parseFloat(el.temperature) : 25.0;
sim.registerSensor('ds3231', virtualPin, { addr: 0x68, temperature: initTemp });
return () => sim.unregisterSensor(virtualPin);
registerSensorUpdate(componentId, (values) => {
sim.updateSensor(virtualPin, values);
});
return () => {
sim.unregisterSensor(virtualPin);
unregisterSensorUpdate(componentId);
};
}
return () => {};

View File

@ -38,9 +38,11 @@ const I2C_SENSOR_MAP: Record<string, {
sensorType: string;
defaultAddr: number;
addrProp?: string; // property key that holds the I2C address (e.g. 'address')
addrIsBool?: boolean; // true when addrProp is a boolean flag (e.g. AD0 → 0x68/0x69)
addrBoolHigh?: number; // address when the boolean flag is truthy
propertyKeys?: string[]; // additional sensor values to forward (e.g. temperature, pressure)
}> = {
'mpu6050': { sensorType: 'mpu6050', defaultAddr: 0x68 },
'mpu6050': { sensorType: 'mpu6050', defaultAddr: 0x68, addrProp: 'ad0', addrIsBool: true, addrBoolHigh: 0x69 },
'bmp280': { sensorType: 'bmp280', defaultAddr: 0x76, addrProp: 'address', propertyKeys: ['temperature', 'pressure'] },
'ds1307': { sensorType: 'ds1307', defaultAddr: 0x68 },
'ds3231': { sensorType: 'ds3231', defaultAddr: 0x68, propertyKeys: ['temperature'] },
@ -702,10 +704,17 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (i2cDef.addrProp) {
const rawAddr = comp.properties[i2cDef.addrProp];
if (rawAddr !== undefined) {
const parsed = typeof rawAddr === 'string'
? (rawAddr.startsWith('0x') ? parseInt(rawAddr, 16) : parseInt(rawAddr, 10))
: Number(rawAddr);
if (!isNaN(parsed)) addr = parsed;
if (i2cDef.addrIsBool) {
// Boolean flag (e.g. AD0 on MPU-6050): truthy → high address
if (rawAddr === true || rawAddr === 'true' || rawAddr === '1') {
addr = i2cDef.addrBoolHigh ?? i2cDef.defaultAddr;
}
} else {
const parsed = typeof rawAddr === 'string'
? (rawAddr.startsWith('0x') ? parseInt(rawAddr, 16) : parseInt(rawAddr, 10))
: Number(rawAddr);
if (!isNaN(parsed)) addr = parsed;
}
}
}
const virtualPin = 200 + addr;