From ca6520e48d1eebdf936921dc7059a2f708c08def Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 8 Apr 2026 00:02:54 -0300 Subject: [PATCH] feat: Update I2C event handling and improve MPU-6050 slave emulation; enhance README and tests --- README.md | 25 ++++- backend/app/services/esp32_i2c_slaves.py | 93 ++++++++++++------- backend/app/services/esp32_worker.py | 12 ++- backend/test_esp32_i2c_slaves.py | 93 +++++++++++++++++-- .../src/simulation/parts/ProtocolParts.ts | 10 +- frontend/src/store/useSimulatorStore.ts | 19 +++- 6 files changed, 201 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index cfb5f00f..b9f5bd90 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/app/services/esp32_i2c_slaves.py b/backend/app/services/esp32_i2c_slaves.py index fef82052..4b6abe21 100644 --- a/backend/app/services/esp32_i2c_slaves.py +++ b/backend/app/services/esp32_i2c_slaves.py @@ -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)}) diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index b8669511..80b1144b 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -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 diff --git a/backend/test_esp32_i2c_slaves.py b/backend/test_esp32_i2c_slaves.py index df7a1553..90947ee5 100644 --- a/backend/test_esp32_i2c_slaves.py +++ b/backend/test_esp32_i2c_slaves.py @@ -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__': diff --git a/frontend/src/simulation/parts/ProtocolParts.ts b/frontend/src/simulation/parts/ProtocolParts.ts index a67c372e..b30b74c3 100644 --- a/frontend/src/simulation/parts/ProtocolParts.ts +++ b/frontend/src/simulation/parts/ProtocolParts.ts @@ -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 () => {}; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 3972b1e8..e19b1f87 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -38,9 +38,11 @@ const I2C_SENSOR_MAP: Record = { - '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((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;