velxio/test/backend/unit/test_i2c_slaves.py

512 lines
23 KiB
Python
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.

"""
Tests for ESP32 I2C slave state machines.
Covers BMP280Slave, DS1307Slave, DS3231Slave, I2CWriteSink, and MPU6050Slave
from app/services/esp32_i2c_slaves.py.
Correct picsimlab I2C event encoding (from hw/i2c/picsimlab_i2c.c + QEMU i2c.h):
event & 0xFF = operation type:
0x00 = I2C_START_RECV — firmware called requestFrom (read direction START)
0x01 = I2C_START_SEND — firmware called beginTransmission (write direction START)
0x03 = I2C_FINISH — end of transaction (STOP or RSTART between write+read)
0x05 = WRITE byte — (event >> 8) & 0xFF is the data byte
0x06 = READ byte — return value is the data byte to deliver to firmware
ACK convention: return 0 = ACK (success), non-zero = NACK.
For READ events: return value is the data byte sent to the firmware.
Run from the backend/ directory:
python test_esp32_i2c_slaves.py
"""
import sys
import unittest
from pathlib import Path
# Ensure backend/ is importable (for direct execution; pytest uses conftest.py)
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend'))
from app.services.esp32_i2c_slaves import (
BMP280Slave,
DS1307Slave,
DS3231Slave,
I2CWriteSink,
MPU6050Slave,
I2C_START_RECV,
I2C_START_SEND,
I2C_FINISH,
I2C_WRITE,
I2C_READ,
)
# ── I2C protocol helpers ──────────────────────────────────────────────────────
def i2c_write(byte: int) -> int:
"""WRITE event: data in high byte, op 0x05 in low byte."""
return ((byte & 0xFF) << 8) | I2C_WRITE
def i2c_read_seq(slave, reg: int, n: int) -> list[int]:
"""Simulate write-then-read: write register address, then read n bytes.
Models the Adafruit BusIO write_then_read pattern:
beginTransmission → write(reg) → endTransmission(false) → requestFrom → read()*n
"""
slave.handle_event(I2C_START_SEND) # write direction START
slave.handle_event(i2c_write(reg)) # set register pointer
slave.handle_event(I2C_FINISH) # RSTART (repeated start before read phase)
slave.handle_event(I2C_START_RECV) # read direction START
data = [slave.handle_event(I2C_READ) for _ in range(n)]
slave.handle_event(I2C_FINISH) # STOP
return data
def read_u16_le(regs: bytearray, addr: int) -> int:
"""Read unsigned 16-bit little-endian from register array."""
return regs[addr] | (regs[addr + 1] << 8)
def bcd_valid(value: int) -> bool:
"""Return True if both nibbles of a BCD byte are in 09."""
return (value >> 4) <= 9 and (value & 0xF) <= 9
# ══════════════════════════════════════════════════════════════════════════════
# BMP280 Slave Tests
# ══════════════════════════════════════════════════════════════════════════════
class TestBMP280Slave(unittest.TestCase):
def setUp(self):
self.slave = BMP280Slave()
# ── I2C protocol ───────────────────────────────────────────────────────────
def test_ack_on_start_send(self):
result = self.slave.handle_event(I2C_START_SEND)
self.assertEqual(result, 0, 'START_SEND must return 0 (ACK)')
def test_ack_on_start_recv(self):
result = self.slave.handle_event(I2C_START_RECV)
self.assertEqual(result, 0, 'START_RECV must return 0 (ACK)')
def test_ack_on_write(self):
self.slave.handle_event(I2C_START_SEND)
result = self.slave.handle_event(i2c_write(0xD0))
self.assertEqual(result, 0, 'WRITE must return 0 (ACK)')
def test_finish_returns_zero(self):
self.slave.handle_event(I2C_START_SEND)
result = self.slave.handle_event(I2C_FINISH)
self.assertEqual(result, 0)
# ── Chip identity ──────────────────────────────────────────────────────────
def test_chip_id_register_0xd0(self):
chip_id = i2c_read_seq(self.slave, 0xD0, 1)[0]
self.assertEqual(chip_id, 0x58, 'chip_id must be 0x58 for BMP280')
# ── Calibration registers ──────────────────────────────────────────────────
def test_calibration_t1_little_endian(self):
# DIG_T1=27504 stored at 0x88 (LSB) and 0x89 (MSB)
t1 = read_u16_le(self.slave.regs, 0x88)
self.assertEqual(t1, BMP280Slave.DIG_T1)
def test_calibration_p1_little_endian(self):
p1 = read_u16_le(self.slave.regs, 0x8E)
self.assertEqual(p1, BMP280Slave.DIG_P1)
# ── Default measurement values ─────────────────────────────────────────────
def test_default_temp_25c(self):
"""Default state: 25°C → compensated centidegrees ≈ 2500."""
adc_t_bytes = i2c_read_seq(self.slave, 0xFA, 3)
adc_t = ((adc_t_bytes[0] << 12) | (adc_t_bytes[1] << 4) | (adc_t_bytes[2] >> 4))
compensated = self.slave._compensate_t(adc_t)
# Allow ±1 centidegree tolerance (binary-search rounding)
self.assertAlmostEqual(compensated, 2500, delta=1)
def test_default_pressure_1013hpa(self):
"""Default state: 1013.25 hPa → compensated Pa within 100 Pa tolerance."""
adc_t_bytes = i2c_read_seq(self.slave, 0xFA, 3)
adc_t = ((adc_t_bytes[0] << 12) | (adc_t_bytes[1] << 4) | (adc_t_bytes[2] >> 4))
adc_p_bytes = i2c_read_seq(self.slave, 0xF7, 3)
adc_p = ((adc_p_bytes[0] << 12) | (adc_p_bytes[1] << 4) | (adc_p_bytes[2] >> 4))
compensated_pa = self.slave._compensate_p(adc_p, adc_t)
target_pa = 1013.25 * 100.0
self.assertAlmostEqual(compensated_pa, target_pa, delta=100) # ±1 hPa
# ── update() changes ADC registers ────────────────────────────────────────
def test_update_changes_temp_regs(self):
before = list(self.slave.regs[0xFA:0xFD])
self.slave.update(30.0, 1013.25)
after = list(self.slave.regs[0xFA:0xFD])
self.assertNotEqual(before, after, 'Temp ADC regs must change after update(30.0,...)')
def test_update_changes_pressure_regs(self):
before = list(self.slave.regs[0xF7:0xFA])
self.slave.update(25.0, 900.0)
after = list(self.slave.regs[0xF7:0xFA])
self.assertNotEqual(before, after, 'Pressure ADC regs must change after update(...,900.0)')
def test_update_temp_compensates_correctly(self):
self.slave.update(40.0, 1013.25)
adc_t_bytes = i2c_read_seq(self.slave, 0xFA, 3)
adc_t = ((adc_t_bytes[0] << 12) | (adc_t_bytes[1] << 4) | (adc_t_bytes[2] >> 4))
compensated = self.slave._compensate_t(adc_t)
self.assertAlmostEqual(compensated, 4000, delta=1)
# ── Sequential register reads ──────────────────────────────────────────────
def test_sequential_read_advances_pointer(self):
"""Reading 3 bytes from 0xF7 must yield distinct pressure MSB/LSB/XLSB."""
bytes_ = i2c_read_seq(self.slave, 0xF7, 3)
self.assertEqual(len(bytes_), 3)
# At least two of the three bytes must differ (non-trivial measurement)
self.assertFalse(bytes_[0] == bytes_[1] == bytes_[2],
'All three pressure ADC bytes should not be identical')
# ── State machine ─────────────────────────────────────────────────────────
def test_write_sets_register_ptr(self):
"""Write-then-read: write reg address 0xD0, then read returns chip_id."""
self.slave.handle_event(I2C_START_SEND)
self.slave.handle_event(i2c_write(0xD0)) # set ptr to chip_id reg
self.slave.handle_event(I2C_FINISH)
self.slave.handle_event(I2C_START_RECV)
val = self.slave.handle_event(I2C_READ)
self.assertEqual(val, 0x58)
def test_finish_resets_first_byte_flag(self):
"""After FINISH, the next transaction's first WRITE must set reg_ptr."""
self.slave.handle_event(I2C_START_SEND)
self.slave.handle_event(i2c_write(0xD0))
self.slave.handle_event(I2C_FINISH)
# New transaction: WRITE 0xD0 again → should set reg_ptr, not write data
self.slave.handle_event(I2C_START_SEND)
self.slave.handle_event(i2c_write(0xD0))
self.slave.handle_event(I2C_FINISH)
self.slave.handle_event(I2C_START_RECV)
val = self.slave.handle_event(I2C_READ)
self.assertEqual(val, 0x58, 'chip_id should still be 0x58 after FINISH + new transaction')
# ══════════════════════════════════════════════════════════════════════════════
# DS1307 Slave Tests
# ══════════════════════════════════════════════════════════════════════════════
class TestDS1307Slave(unittest.TestCase):
def setUp(self):
self.slave = DS1307Slave()
def test_ack_on_start_send(self):
self.assertEqual(self.slave.handle_event(I2C_START_SEND), 0)
def test_ack_on_write(self):
self.slave.handle_event(I2C_START_SEND)
self.assertEqual(self.slave.handle_event(i2c_write(0x00)), 0)
def test_seconds_is_valid_bcd(self):
seconds = i2c_read_seq(self.slave, 0x00, 1)[0]
tens = (seconds >> 4) & 0xF
units = seconds & 0xF
self.assertLessEqual(tens, 5, 'Seconds tens digit must be ≤ 5')
self.assertLessEqual(units, 9, 'Seconds units digit must be ≤ 9')
def test_minutes_is_valid_bcd(self):
minutes = i2c_read_seq(self.slave, 0x01, 1)[0]
self.assertLessEqual((minutes >> 4) & 0xF, 5)
self.assertLessEqual( minutes & 0xF, 9)
def test_hours_is_valid_bcd(self):
hours = i2c_read_seq(self.slave, 0x02, 1)[0]
# BCD 0023
tens = (hours >> 4) & 0xF
units = hours & 0xF
value = tens * 10 + units
self.assertGreaterEqual(value, 0)
self.assertLessEqual(value, 23)
def test_year_is_valid_bcd(self):
year = i2c_read_seq(self.slave, 0x06, 1)[0]
self.assertTrue(bcd_valid(year), f'Year 0x{year:02X} is not valid BCD')
def test_sequential_read_7_regs(self):
"""Read all 7 time registers in one transaction — all must be valid BCD."""
data = i2c_read_seq(self.slave, 0x00, 7)
self.assertEqual(len(data), 7)
for i, b in enumerate(data):
self.assertTrue(bcd_valid(b), f'Register {i} value 0x{b:02X} is not valid BCD')
# ══════════════════════════════════════════════════════════════════════════════
# DS3231 Slave Tests
# ══════════════════════════════════════════════════════════════════════════════
class TestDS3231Slave(unittest.TestCase):
def setUp(self):
self.slave = DS3231Slave()
def test_inherits_time_registers(self):
"""DS3231 regs 06 must return the same valid BCD as DS1307."""
data = i2c_read_seq(self.slave, 0x00, 7)
self.assertEqual(len(data), 7)
for i, b in enumerate(data):
self.assertTrue(bcd_valid(b), f'Register {i} value 0x{b:02X} is not valid BCD')
def test_control_register_0x0e(self):
val = self.slave._read_reg(0x0E)
self.assertEqual(val, 0x00)
def test_status_register_0x0f(self):
val = self.slave._read_reg(0x0F)
self.assertEqual(val, 0x00)
def test_temperature_msb_default_25c(self):
val = self.slave._read_reg(0x11)
self.assertEqual(val, 25, 'Temp MSB must be 25 (integer °C) at default 25.0°C')
def test_temperature_lsb_zero_frac(self):
val = self.slave._read_reg(0x12)
self.assertEqual(val, 0, 'Temp LSB must be 0 when fractional part is .00')
def test_temperature_update_quarter_degree(self):
"""25.25°C → LSB bits 7:6 == 0b01 (0.25°C step)."""
self.slave.temperatureC = 25.25
msb = self.slave._read_reg(0x11)
lsb = self.slave._read_reg(0x12)
self.assertEqual(msb, 25)
frac_bits = (lsb >> 6) & 0x03
self.assertEqual(frac_bits, 1, '0.25°C → bits 7:6 must be 0b01')
def test_temperature_update_half_degree(self):
"""25.5°C → LSB bits 7:6 == 0b10 (0.50°C step)."""
self.slave.temperatureC = 25.5
lsb = self.slave._read_reg(0x12)
frac_bits = (lsb >> 6) & 0x03
self.assertEqual(frac_bits, 2, '0.50°C → bits 7:6 must be 0b10')
def test_temperature_read_via_i2c(self):
"""Temperature registers are accessible via I2C READ from 0x11."""
self.slave.temperatureC = 30.0
data = i2c_read_seq(self.slave, 0x11, 2)
self.assertEqual(data[0], 30) # MSB = integer °C
self.assertEqual(data[1], 0) # LSB = 0 (no fractional part)
# ══════════════════════════════════════════════════════════════════════════════
# I2CWriteSink Tests
# ══════════════════════════════════════════════════════════════════════════════
class TestI2CWriteSink(unittest.TestCase):
def setUp(self):
self.emitted: list[dict] = []
self.sink = I2CWriteSink(addr=0x3C, emit_fn=self.emitted.append)
def test_ack_on_start_send(self):
self.assertEqual(self.sink.handle_event(I2C_START_SEND), 0)
def test_ack_on_start_recv(self):
self.assertEqual(self.sink.handle_event(I2C_START_RECV), 0)
def test_ack_on_write(self):
self.sink.handle_event(I2C_START_SEND)
for byte in [0x00, 0x21, 0xAB]:
result = self.sink.handle_event(i2c_write(byte))
self.assertEqual(result, 0, f'WRITE 0x{byte:02X} must return 0 (ACK)')
def test_read_returns_0xff(self):
"""Write-only device: READ must return 0xFF (no data to send)."""
self.sink.handle_event(I2C_START_SEND)
result = self.sink.handle_event(I2C_READ)
self.assertEqual(result, 0xFF)
def test_emits_on_finish(self):
"""After 3 writes + FINISH, emit_fn must be called exactly once."""
self.sink.handle_event(I2C_START_SEND)
for b in [0x00, 0x21, 0x7F]:
self.sink.handle_event(i2c_write(b))
self.sink.handle_event(I2C_FINISH)
self.assertEqual(len(self.emitted), 1, 'emit_fn must be called once on FINISH')
def test_emit_payload_addr(self):
self.sink.handle_event(I2C_START_SEND)
self.sink.handle_event(i2c_write(0xAB))
self.sink.handle_event(I2C_FINISH)
self.assertEqual(self.emitted[0]['addr'], 0x3C)
def test_emit_payload_type(self):
self.sink.handle_event(I2C_START_SEND)
self.sink.handle_event(i2c_write(0xAB))
self.sink.handle_event(I2C_FINISH)
self.assertEqual(self.emitted[0]['type'], 'i2c_transaction')
def test_emit_payload_data(self):
bytes_ = [0x00, 0x21, 0x7F]
self.sink.handle_event(I2C_START_SEND)
for b in bytes_:
self.sink.handle_event(i2c_write(b))
self.sink.handle_event(I2C_FINISH)
self.assertEqual(self.emitted[0]['data'], bytes_)
def test_no_emit_for_empty_buffer(self):
"""START then immediate FINISH with no writes must NOT emit."""
self.sink.handle_event(I2C_START_SEND)
self.sink.handle_event(I2C_FINISH)
self.assertEqual(len(self.emitted), 0, 'Empty buffer must not trigger emit_fn')
def test_resets_buffer_after_emit(self):
"""Second transaction accumulates fresh bytes, not leftover from first."""
# First transaction: writes [0xAA]
self.sink.handle_event(I2C_START_SEND)
self.sink.handle_event(i2c_write(0xAA))
self.sink.handle_event(I2C_FINISH)
# Second transaction: writes [0xBB]
self.sink.handle_event(I2C_START_SEND)
self.sink.handle_event(i2c_write(0xBB))
self.sink.handle_event(I2C_FINISH)
self.assertEqual(len(self.emitted), 2)
self.assertEqual(self.emitted[0]['data'], [0xAA])
self.assertEqual(self.emitted[1]['data'], [0xBB])
def test_custom_addr_forwarded(self):
"""Sink created with addr=0x27 emits with that addr."""
sink = I2CWriteSink(addr=0x27, emit_fn=self.emitted.append)
sink.handle_event(I2C_START_SEND)
sink.handle_event(i2c_write(0x38))
sink.handle_event(I2C_FINISH)
self.assertEqual(self.emitted[0]['addr'], 0x27)
def test_finish_return_value(self):
"""FINISH always returns 0."""
self.sink.handle_event(I2C_START_SEND)
result = self.sink.handle_event(I2C_FINISH)
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_send_ack(self):
"""START_SEND event must return 0 (ACK)."""
self.assertEqual(self.mpu.handle_event(I2C_START_SEND), 0)
def test_start_recv_ack(self):
"""START_RECV event must return 0 (ACK)."""
self.assertEqual(self.mpu.handle_event(I2C_START_RECV), 0)
def test_write_then_read_who_am_i(self):
"""Full write-then-read: write reg 0x75, then read returns 0x68."""
m = MPU6050Slave()
m.handle_event(I2C_START_SEND) # beginTransmission
m.handle_event(i2c_write(0x75)) # write register address
m.handle_event(I2C_FINISH) # RSTART
m.handle_event(I2C_START_RECV) # requestFrom
result = m.handle_event(I2C_READ) # read byte
self.assertEqual(result, 0x68, f"WHO_AM_I must be 0x68, got 0x{result:02x}")
def test_detected_pattern(self):
"""detected() pattern: START_SEND then FINISH (no data bytes) returns ACK."""
m = MPU6050Slave()
# beginTransmission + endTransmission (no data)
r1 = m.handle_event(I2C_START_SEND)
self.assertEqual(r1, 0, "START_SEND must return 0 (ACK) for detected()")
r2 = m.handle_event(I2C_FINISH)
self.assertEqual(r2, 0, "FINISH must return 0")
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_sequential_read_14_bytes(self):
"""getEvent() reads 14 bytes from 0x3B — all must come from correct regs."""
result = i2c_read_seq(self.mpu, 0x3B, 14)
self.assertEqual(len(result), 14)
expected = list(self.mpu.regs[0x3B:0x3B + 14])
self.assertEqual(result, expected, "14-byte read from 0x3B must match register map")
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_SEND)
self.mpu.handle_event(i2c_write(0x6B)) # register address
self.mpu.handle_event(i2c_write(0x80)) # write 0x80 (DEVICE_RESET bit)
self.mpu.handle_event(I2C_FINISH)
# Read back: bit 7 should be 0 (auto-cleared)
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)
def test_reg_ptr_preserved_across_rstart(self):
"""Write sets reg_ptr; FINISH then START_RECV should NOT reset reg_ptr."""
m = MPU6050Slave()
m.handle_event(I2C_START_SEND)
m.handle_event(i2c_write(0x75)) # set reg_ptr = 0x75
m.handle_event(I2C_FINISH) # RSTART — must NOT reset reg_ptr
m.handle_event(I2C_START_RECV) # read direction START
val = m.handle_event(I2C_READ)
self.assertEqual(val, 0x68, "reg_ptr must be preserved across RSTART")
def test_write_to_reg(self):
"""Writing two bytes sets reg address then reg value."""
m = MPU6050Slave()
m.handle_event(I2C_START_SEND)
m.handle_event(i2c_write(0x6B)) # reg address
m.handle_event(i2c_write(0x01)) # value
m.handle_event(I2C_FINISH)
result = i2c_read_seq(m, 0x6B, 1)
self.assertEqual(result[0], 0x01)
# ── Runner ────────────────────────────────────────────────────────────────────
if __name__ == '__main__':
print('=' * 60)
print('ESP32 I2C Slave Tests')
print('=' * 60)
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for cls in [
TestBMP280Slave,
TestDS1307Slave,
TestDS3231Slave,
TestI2CWriteSink,
TestMPU6050Slave,
]:
suite.addTests(loader.loadTestsFromTestCase(cls))
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
sys.exit(0 if result.wasSuccessful() else 1)