feat: Update I2C event handling and improve MPU-6050 slave emulation; enhance README and tests
This commit is contained in:
parent
0f2c39f23b
commit
ca6520e48d
25
README.md
25
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
|
||||
|
|
|
|||
|
|
@ -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,12 +308,14 @@ 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
|
||||
if self._buf:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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 () => {};
|
||||
|
|
|
|||
|
|
@ -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,12 +704,19 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
if (i2cDef.addrProp) {
|
||||
const rawAddr = comp.properties[i2cDef.addrProp];
|
||||
if (rawAddr !== undefined) {
|
||||
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;
|
||||
const props: Record<string, unknown> = { sensor_type: i2cDef.sensorType, pin: virtualPin, addr };
|
||||
for (const key of (i2cDef.propertyKeys ?? [])) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue