MicroPython for Microcontrollers Module 4 – Buses and device drivers: I2C, SPI and UART
Write a reusable device driver class
Build a MicroPython 1.29 I2C driver around an injected bus and const() registers, keep its reads allocation-free, and test it on a computer with a faulty fake.
What you will learn
- Structure a driver around an injected bus, a device address and const() register names
- Check a device's ID at start-up and report a missing device with OSError(ENODEV)
- Change one field of a register with read-modify-write, leaving the other bits alone
- Convert raw readings in whole numbers into buffers made once, so the hot path allocates nothing
- Test a driver on a computer with a fake bus that drops bytes on purpose
Before you start
What you’ll learn · Before you start
What you will learn
- Structure a driver around an injected bus, a device address and const() register names
- Check a device's ID at start-up and report a missing device with OSError(ENODEV)
- Change one field of a register with read-modify-write, leaving the other bits alone
- Convert raw readings in whole numbers into buffers made once, so the hot path allocates nothing
- Test a driver on a computer with a fake bus that drops bytes on purpose
Before you start
On this page
A driver that creates its own I2C(0) inside __init__ works on the board it was written for and nowhere else: not
on a board whose sensor sits on other pins, not on SoftI2C, and not in a test on your computer. Move one line out of
the class, so the bus becomes an argument, and the same driver runs in all three places.
This lesson writes a driver for MSC-ENV1, the made-up sensor of the bus lesson, and keeps every step of it testable:
the bus passed in, register numbers as const() names, an ID check that raises OSError(ENODEV), read-modify-write
of a control register, conversions in whole numbers into buffers made once, and tests on a computer with a fake bus
that drops bytes on purpose.
Pass the bus in, and the driver runs on any bus
A driver needs a bus, but it should not decide which one. The board’s start-up code knows the pins, so it creates
I2C(0, scl=Pin(5), sda=Pin(4)), or SoftI2C on a board whose free pins reach no hardware bus, and hands the object
to the driver; a test hands it a fake with the same methods. The driver only calls readfrom_mem_into() and
writeto_mem() on whatever it was given. This is dependency injection: Giving an object the things it works with, such as a bus, as arguments instead of letting it create them. A driver that takes its bus this way runs on any bus, a fake one in a test included., and it
costs one constructor argument. Step through the three buses one driver can take:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The board's start-up code. main.py knows the board, so it creates I2C(0, scl=Pin(5), sda=Pin(4)) and passes it to ENV1(i2c). The driver keeps the object; it never creates a bus of its own.
- Two methods, the whole contract. Everything the driver does goes through readfrom_mem_into() and writeto_mem() on the object it was given. Any object with those two methods will do.
- Another board. A board whose free pins reach no hardware bus passes SoftI2C(scl=Pin(15), sda=Pin(14)). It has the same methods, so the driver runs unchanged, only more slowly.
- A test on a computer. A test passes FakeI2C, forty lines of Python with a register map and a switch that drops the next reads. The same driver now runs on CPython and on the unix port, with no hardware.
- The cost of creating it inside. A driver that wrote self.i2c = I2C(0) in its constructor would allow only the first of these. The other board would need an edit, and the test could not run at all.
The driver below takes i2c and an address, with 0x41 as the default, and keeps both. The program beside it runs it
on the fake bus of this lesson, which holds one MSC-ENV1, so you can run it here:
driver/use_driver.py
"""The ENV1 driver on a fake bus: a reading, the conversions, a missing sensor and dropped bytes."""
import errno
from envsensor import ENV1
from fakebus import FakeENV1, FakeI2C
sensor = FakeENV1()
bus = FakeI2C(sensor)
env = ENV1(bus) # the same line takes machine.I2C(0, ...) on a board
env.reset()
env.set_averaging(16)
print("CTRL after set_averaging(16):", bin(sensor.regs[1]))
print("21.5 C, 48.25 %:", env.measure())
for temp, hum in ((-672, 0), (-1, 1), (12345, 6400)):
sensor.set(temp, hum)
print("raw", temp, hum, "->", env.measure())
print("CTRL after measure():", bin(sensor.regs[1]), "(averaging kept)")
try:
ENV1(FakeI2C()) # nothing on the bus
except OSError as e:
print("no sensor: OSError", errno.errorcode[e.args[0]], e.args[1])
try:
ENV1(FakeI2C(FakeENV1(chip_id=0x5A))) # another chip at 0x41
except OSError as e:
print("wrong chip: OSError", errno.errorcode[e.args[0]], e.args[1])
bus.fail_next(2) # two reads lost, the third succeeds
print("after 2 lost reads:", env.measure())
bus.fail_next(3)
try:
env.measure()
except OSError as e:
print("after 3 lost reads: OSError", e.args[0]) Output
CTRL after set_averaging(16): 0b100 21.5 C, 48.25 %: (2150, 483) raw -672 0 -> (-525, 0) raw -1 1 -> (-1, 0) raw 12345 6400 -> (9645, 1000) CTRL after measure(): 0b100 (averaging kept) no sensor: OSError ENODEV MSC-ENV1 not found at 0x41: no answer wrong chip: OSError ENODEV MSC-ENV1 not found at 0x41: ID 0x5a after 2 lost reads: (9645, 1000) after 3 lost reads: OSError 5
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 use_driver.py
envsensor.py (the driver) · driver/envsensor.py
"""Driver for MSC-ENV1, a temperature and humidity sensor made up for this track, on any I2C-like bus."""
import errno
try:
from micropython import const
except ImportError: # CPython: const() is only a marker
def const(x):
return x
_REG_ID = const(0x00)
_REG_CTRL = const(0x01)
_REG_STATUS = const(0x02)
_REG_DATA = const(0x03) # TEMP (2 bytes, signed) then HUM (2 bytes), big-endian
_CHIP_ID = const(0xE1)
_RESET = const(0x80)
_AVG_MASK = const(0x06) # bits 2 and 1: 1, 4, 16 or 64 samples averaged
_START = const(0x01)
_READY = const(0x01)
_TRIES = const(3)
class ENV1:
def __init__(self, i2c, address=0x41):
self.i2c = i2c # the caller's bus: I2C, SoftI2C or a fake
self.address = address
self._one = bytearray(1)
self._data = bytearray(4)
try:
chip = self._read_u8(_REG_ID)
except OSError:
chip = None
if chip != _CHIP_ID:
found = "no answer" if chip is None else "ID 0x%02x" % chip
raise OSError(errno.ENODEV, "MSC-ENV1 not found at 0x%02x: %s" % (address, found))
def _read_into(self, reg, buf):
for attempt in range(_TRIES):
try:
self.i2c.readfrom_mem_into(self.address, reg, buf)
return
except OSError:
if attempt == _TRIES - 1:
raise
def _read_u8(self, reg):
self._read_into(reg, self._one)
return self._one[0]
def _write_u8(self, reg, value):
self._one[0] = value
self.i2c.writeto_mem(self.address, reg, self._one)
def reset(self):
"""Soft reset: set the RESET bit; the sensor clears it when it has restarted."""
self._write_u8(_REG_CTRL, _RESET)
for _ in range(100):
if not self._read_u8(_REG_CTRL) & _RESET:
return
raise OSError(errno.ETIMEDOUT, "MSC-ENV1 did not finish its reset")
def set_averaging(self, samples):
"""Average 1, 4, 16 or 64 samples per reading: a read-modify-write of CTRL bits 2 and 1."""
code = (1, 4, 16, 64).index(samples)
ctrl = self._read_u8(_REG_CTRL)
self._write_u8(_REG_CTRL, (ctrl & ~_AVG_MASK) | code << 1)
def measure(self):
"""One measurement: (temperature in hundredths of a degree C, humidity in tenths of a percent)."""
self._write_u8(_REG_CTRL, self._read_u8(_REG_CTRL) | _START) # keep the other bits
for _ in range(100):
if self._read_u8(_REG_STATUS) & _READY:
break
else:
raise OSError(errno.ETIMEDOUT, "MSC-ENV1 measurement not ready")
d = self._data
self._read_into(_REG_DATA, d) # four registers in one transfer, into the same buffer
raw_t = d[0] << 8 | d[1]
if raw_t & 0x8000:
raw_t -= 0x10000 # signed 16-bit
raw_h = d[2] << 8 | d[3]
return (raw_t * 100 + 64) // 128, (raw_h * 10 + 32) // 64 # 1/128 °C and 1/64 % to whole units, rounded fakebus.py (the fake) · driver/fakebus.py
"""A fake I2C bus with one MSC-ENV1 on it, for testing drivers on a computer. It answers readfrom_mem_into() and
writeto_mem() like machine.I2C, and fail_next() makes the next reads raise OSError(5) as a missing acknowledgement
does on a Pico's hardware I2C."""
class FakeENV1:
def __init__(self, chip_id=0xE1, temp=2752, hum=3088):
self.regs = bytearray(8)
self.regs[0] = chip_id
self.set(temp, hum)
def set(self, temp, hum):
self.regs[3:5] = (temp & 0xFFFF).to_bytes(2, "big")
self.regs[5:7] = hum.to_bytes(2, "big")
def write(self, reg, data):
for i, b in enumerate(data):
r = reg + i
if r == 0x01:
self.regs[2] = 0x01 if b & 0x01 else 0x00 # START makes a reading ready
b &= 0x06 # RESET and START clear themselves; the averaging bits stay
self.regs[r] = b
class FakeI2C:
def __init__(self, device=None, address=0x41):
self.devices = {address: device} if device else {}
self.failures = 0
self.log = []
def fail_next(self, n):
"""The next n reads are not acknowledged."""
self.failures = n
def readfrom_mem_into(self, addr, reg, buf):
self.log.append(("read", reg, len(buf)))
if addr not in self.devices or self.failures:
self.failures = max(0, self.failures - 1)
raise OSError(5) # EIO
buf[:] = self.devices[addr].regs[reg:reg + len(buf)]
def writeto_mem(self, addr, reg, buf):
self.log.append(("write", reg, bytes(buf)))
if addr not in self.devices:
raise OSError(5)
self.devices[addr].write(reg, buf) driver/package.json
{
"version": "1.0.0",
"urls": [
["envsensor.py", "envsensor.py"]
]
} Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
ENV1(bus) is the only line that would change on a board, to ENV1(I2C(0, scl=Pin(5), sda=Pin(4))). The rest of
this lesson walks through the output: the averaging setting kept in CTRL, the readings in whole units, the two ways
a sensor can be missing, and the lost bytes the driver survives. The package.json tab is the whole of what
the mip lesson asks of a package: one file, installed as
envsensor.py.
Creating the bus inside the driver
self.i2c = I2C(0) in __init__ ties the class to one board’s pins and makes a test without hardware impossible.
Take the bus as the first argument, as the drivers of micropython-lib do, and create it once, in main.py or
boot.py.
Checkpoint
Check: Passing the bus into the driver
Name the registers with const(), and keep the names private
A driver is full of register numbers and bit masks, and const() makes them free. When the parser meets
NAME = const(expr), it works out the value and puts it straight into the bytecode wherever NAME is used, so no
global lookup happens at run time. A name that starts with an underscore is also left out of the module’s globals, so
it costs no RAM; without the underscore, the documentation says, each constant still takes at least two machine words
as a global that other modules can read. The forms side by side:
| Written as | Value put into the bytecode | Kept as a module global | On CPython |
|---|---|---|---|
REG_ID = const(0x00) |
yes | yes, two machine words or more | runs, with a stand-in const |
_REG_ID = const(0x00) |
yes | no | runs, with a stand-in const |
_REG_ID: int = const(0x00) |
yes, from MicroPython 1.29 | no | runs, with a stand-in const |
_REG_ID = 0x00 |
no: looked up on every use | yes | runs |
The parser recognises only the bare name const: micropython.const(0x00) or an alias does nothing special. On
CPython there is no micropython module, so the driver imports const in a try and defines a stand-in that returns
its argument, the pattern the documentation gives. The form with a type hint, _X: int = const(1), is new in 1.29;
older parsers run it, but as a plain global.
Underscore for register numbers, plain names for what callers use
Register addresses and masks are the driver’s business: _REG_CTRL = const(0x01). Values a caller passes, such as
a default address, can stay public as plain names.
Checkpoint
Check: Register names with const()
Check the ID first, and fail with a clear OSError
An I2C address says nothing about what answers there: two different chips can use the same one. So the driver’s first transfer reads register 0x00 and compares it with MSC-ENV1’s ID, 0xE1, and a constructor that returns has a working sensor behind it. Step through the check, from the first read to the error:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The first transfer reads the ID. The constructor's first bus call reads register 0x00 at the address it was given, 0x41 by default. A read that fails three times in a row, as on an empty bus, leaves no ID at all, and the driver notes it as None.
- The ID decides. MSC-ENV1 always returns 0xE1 there. Another chip that uses the same address returns its own value, such as the 0x5A of the demo, and None means that nothing answered.
- Every failure is one error. No answer and a wrong ID both raise OSError(errno.ENODEV), the code SoftI2C itself raises when no device acknowledges, with a message that names the address and what was found.
- A constructor that returns works. When the ID matches, the object is ready, and the caller knows a working MSC-ENV1 is behind it. reset() and measure() add limits of their own, at most 100 polls before OSError(errno.ETIMEDOUT).
When the read fails, or another chip answers, the driver raises
OSError(errno.ENODEV, "MSC-ENV1 not found at 0x41: …"), the code SoftI2C raises for a device that does not
acknowledge, with the address and what was found in the message. The demo printed both cases: no answer for an
empty bus, and ID 0x5a for another chip at the same address.
reset() follows the sensor’s own rule: it sets the RESET bit of CTRL and polls until the sensor clears it, at
most 100 times. A loop that waits for hardware always has a limit, and when the limit runs out the driver raises
OSError(errno.ETIMEDOUT) instead of hanging the board.
Key idea
A driver’s constructor proves the device is there: it reads an ID it can compare, and turns every way of not finding
the device into one OSError with ENODEV and a message a person can act on.
Checkpoint
Check: The ID check and its OSError
Change one field of a register with read-modify-write
CTRL holds three things in one byte: bit 0 starts a measurement, bits 2 and 1 choose how many samples the sensor
averages, and bit 7 resets it. Writing 0x01 to start a measurement also writes zeros into the averaging bits, so a
sensor set to average 16 samples quietly falls back to one. The fix is
read-modify-write: An update made in three steps, reading a value, changing it and writing it back, such as count += 1. An interrupt between the read and the write can make one of two updates vanish. More in “Interrupt handlers: hard IRQs, schedule and the heap”: read the register, change only your bits, write it back. Step
through both, on the values the driver uses:
Program · bits/rmw.py
AVG = 0b110
START = 0b001
ctrl = 0b100
naive = START
print("naive", bin(naive))
print("avg", (naive & AVG) >> 1)
kept = ctrl | START
print("kept", bin(kept))
print("avg", (kept & AVG) >> 1)
ctrl = (ctrl & ~AVG) | 3 << 1
print("new", bin(ctrl)) Variables
- changed in this step
Printed so far
Nothing printed yetnaive 0b1 avg 0 kept 0b101 avg 2 new 0b110
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 12 steps are listed below.
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
AVG = 0b110setsAVGto6. - Line 2.
START = 0b001setsSTARTto1. - Line 3.
ctrl = 0b100setsctrlto4. - Line 5.
naive = STARTsetsnaiveto1. The bug Writing START on its own makes the whole byte 0b001: the averaging code in bits 2 and 1, which was 2, becomes 0. - Line 6.
print("naive", bin(naive))printsnaive 0b1. - Line 7.
print("avg", (naive & AVG) >> 1)printsavg 0. An averaging code of 0 means one sample. The sensor has quietly stopped averaging, and nothing raised an error. - Line 9.
kept = ctrl | STARTsetskeptto5. The fix ctrl | START sets bit 0 and keeps every other bit, so the byte becomes 0b101. - Line 10.
print("kept", bin(kept))printskept 0b101. - Line 11.
print("avg", (kept & AVG) >> 1)printsavg 2. The averaging code is still 2, so the sensor keeps averaging 16 samples. - Line 13.
ctrl = (ctrl & ~AVG) | 3 << 1setsctrlto6(it was4). Key point & ~AVG clears the field, and | 3 << 1 writes the new code into it: 64 samples, with bit 0 untouched. - Line 14.
print("new", bin(ctrl))printsnew 0b110. That was the last line, so the program ends.
ctrl | START sets one bit and keeps the rest; (ctrl & ~AVG) | 3 << 1 clears a field and puts a new value into
it. The driver does exactly this in set_averaging() and in measure(), which is why the demo printed 0b100 for
CTRL both after set_averaging(16) and after the measurements.
Writing a whole register to set one bit
self._write_u8(_REG_CTRL, _START) clears every other bit of CTRL. Read it first and write back
ctrl | _START; for a field, (ctrl & ~_MASK) | value << shift.
Read-modify-write takes two transfers, so it is only safe while nothing else changes the register between them: a
second task driving the same sensor needs a lock around the pair, and an interrupt handler should leave the change to
the code that owns the register, through a flag or micropython.schedule().
Checkpoint
Check: Read-modify-write of a register field
Convert in whole numbers, into buffers made once
measure() reads TEMP and HUM, four registers in a row, with one readfrom_mem_into() into self._data, a
bytearray(4) made in the constructor. It then works in integers only: the two temperature bytes become a signed
16-bit number, and the conversions to hundredths of a degree and tenths of a percent round to the nearest unit with
integer division:
| Raw reading | What it means | Returned |
|---|---|---|
| TEMP 2752, HUM 3088 | 21.5 °C, 48.25 % | (2150, 483) |
| TEMP −672 | −5.25 °C | −525 |
| TEMP −1, HUM 1 | −0.0078 °C, 0.016 % | (−1, 0) |
| TEMP 12345, HUM 6400 | 96.445 °C, 100 % | (9645, 1000) |
(raw * 100 + 64) // 128 rounds to the nearest hundredth, because // rounds towards minus infinity and the 64
added first is half of 128. Whole numbers in this range are small integers in MicroPython, which live inside the
object pointer and need no heap, so the arithmetic allocates nothing, and nor does a read into a buffer that already
exists; the one object measure() makes is the tuple it returns. On a board you can prove a hot path with
micropython.heap_lock(): while the heap is locked, any allocation raises MemoryError.
Converting with floats, and in a new buffer each time
raw / 128 makes a float object on every call, and self.i2c.readfrom_mem(addr, 0x03, 4) a new bytes. On a
board that reads ten times a second for months, both feed the garbage collector. Keep a buffer from the constructor
and convert to whole units.
Checkpoint
Check: Whole-number conversions and fixed buffers
Test the driver on a computer with a fake bus that fails on purpose
The fake bus of this lesson, fakebus.py, is about forty lines: a register map for MSC-ENV1 that behaves like the
sensor’s datasheet, readfrom_mem_into() and writeto_mem() with the signatures of machine.I2C, a log of every
transfer, and fail_next(n), which makes the next n reads raise OSError(5), as a lost acknowledgement does on a
Pico. Because the driver takes its bus as an argument, the tests below run it on that fake, on CPython here and
unchanged on the unix port:
driver/test_driver.py
"""Tests of the ENV1 driver that run on a computer: plain asserts, so they run under CPython and the unix port."""
import errno
from envsensor import ENV1
from fakebus import FakeENV1, FakeI2C
def test_reading():
assert ENV1(FakeI2C(FakeENV1(temp=2752, hum=3088))).measure() == (2150, 483)
def test_negative_rounding():
sensor = FakeENV1(temp=-673, hum=0) # -5.2578 C
assert ENV1(FakeI2C(sensor)).measure() == (-526, 0)
def test_wrong_id():
try:
ENV1(FakeI2C(FakeENV1(chip_id=0x00)))
except OSError as e:
assert e.args[0] == errno.ENODEV
else:
raise AssertionError("no OSError")
def test_one_read_in_one_transfer():
bus = FakeI2C(FakeENV1())
env = ENV1(bus)
bus.log.clear()
env.measure()
assert ("read", 0x03, 4) in bus.log # TEMP and HUM together
def test_retry():
bus = FakeI2C(FakeENV1())
env = ENV1(bus)
bus.fail_next(2)
assert env.measure() == (2150, 483)
def test_averaging_kept():
sensor = FakeENV1()
env = ENV1(FakeI2C(sensor))
env.set_averaging(64)
env.measure()
assert sensor.regs[1] & 0x06 == 0x06
for name, test in sorted(globals().items()):
if name.startswith("test_"):
test()
print("ok", name) Output
ok test_averaging_kept ok test_negative_rounding ok test_one_read_in_one_transfer ok test_reading ok test_retry ok test_wrong_id
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_driver.py
envsensor.py (the driver) · driver/envsensor.py
"""Driver for MSC-ENV1, a temperature and humidity sensor made up for this track, on any I2C-like bus."""
import errno
try:
from micropython import const
except ImportError: # CPython: const() is only a marker
def const(x):
return x
_REG_ID = const(0x00)
_REG_CTRL = const(0x01)
_REG_STATUS = const(0x02)
_REG_DATA = const(0x03) # TEMP (2 bytes, signed) then HUM (2 bytes), big-endian
_CHIP_ID = const(0xE1)
_RESET = const(0x80)
_AVG_MASK = const(0x06) # bits 2 and 1: 1, 4, 16 or 64 samples averaged
_START = const(0x01)
_READY = const(0x01)
_TRIES = const(3)
class ENV1:
def __init__(self, i2c, address=0x41):
self.i2c = i2c # the caller's bus: I2C, SoftI2C or a fake
self.address = address
self._one = bytearray(1)
self._data = bytearray(4)
try:
chip = self._read_u8(_REG_ID)
except OSError:
chip = None
if chip != _CHIP_ID:
found = "no answer" if chip is None else "ID 0x%02x" % chip
raise OSError(errno.ENODEV, "MSC-ENV1 not found at 0x%02x: %s" % (address, found))
def _read_into(self, reg, buf):
for attempt in range(_TRIES):
try:
self.i2c.readfrom_mem_into(self.address, reg, buf)
return
except OSError:
if attempt == _TRIES - 1:
raise
def _read_u8(self, reg):
self._read_into(reg, self._one)
return self._one[0]
def _write_u8(self, reg, value):
self._one[0] = value
self.i2c.writeto_mem(self.address, reg, self._one)
def reset(self):
"""Soft reset: set the RESET bit; the sensor clears it when it has restarted."""
self._write_u8(_REG_CTRL, _RESET)
for _ in range(100):
if not self._read_u8(_REG_CTRL) & _RESET:
return
raise OSError(errno.ETIMEDOUT, "MSC-ENV1 did not finish its reset")
def set_averaging(self, samples):
"""Average 1, 4, 16 or 64 samples per reading: a read-modify-write of CTRL bits 2 and 1."""
code = (1, 4, 16, 64).index(samples)
ctrl = self._read_u8(_REG_CTRL)
self._write_u8(_REG_CTRL, (ctrl & ~_AVG_MASK) | code << 1)
def measure(self):
"""One measurement: (temperature in hundredths of a degree C, humidity in tenths of a percent)."""
self._write_u8(_REG_CTRL, self._read_u8(_REG_CTRL) | _START) # keep the other bits
for _ in range(100):
if self._read_u8(_REG_STATUS) & _READY:
break
else:
raise OSError(errno.ETIMEDOUT, "MSC-ENV1 measurement not ready")
d = self._data
self._read_into(_REG_DATA, d) # four registers in one transfer, into the same buffer
raw_t = d[0] << 8 | d[1]
if raw_t & 0x8000:
raw_t -= 0x10000 # signed 16-bit
raw_h = d[2] << 8 | d[3]
return (raw_t * 100 + 64) // 128, (raw_h * 10 + 32) // 64 # 1/128 °C and 1/64 % to whole units, rounded fakebus.py (the fake) · driver/fakebus.py
"""A fake I2C bus with one MSC-ENV1 on it, for testing drivers on a computer. It answers readfrom_mem_into() and
writeto_mem() like machine.I2C, and fail_next() makes the next reads raise OSError(5) as a missing acknowledgement
does on a Pico's hardware I2C."""
class FakeENV1:
def __init__(self, chip_id=0xE1, temp=2752, hum=3088):
self.regs = bytearray(8)
self.regs[0] = chip_id
self.set(temp, hum)
def set(self, temp, hum):
self.regs[3:5] = (temp & 0xFFFF).to_bytes(2, "big")
self.regs[5:7] = hum.to_bytes(2, "big")
def write(self, reg, data):
for i, b in enumerate(data):
r = reg + i
if r == 0x01:
self.regs[2] = 0x01 if b & 0x01 else 0x00 # START makes a reading ready
b &= 0x06 # RESET and START clear themselves; the averaging bits stay
self.regs[r] = b
class FakeI2C:
def __init__(self, device=None, address=0x41):
self.devices = {address: device} if device else {}
self.failures = 0
self.log = []
def fail_next(self, n):
"""The next n reads are not acknowledged."""
self.failures = n
def readfrom_mem_into(self, addr, reg, buf):
self.log.append(("read", reg, len(buf)))
if addr not in self.devices or self.failures:
self.failures = max(0, self.failures - 1)
raise OSError(5) # EIO
buf[:] = self.devices[addr].regs[reg:reg + len(buf)]
def writeto_mem(self, addr, reg, buf):
self.log.append(("write", reg, bytes(buf)))
if addr not in self.devices:
raise OSError(5)
self.devices[addr].write(reg, buf) Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each test pins one promise: the reading, rounding below zero, the ID check, one transfer for four registers, the
retry after two lost reads, and the averaging bits that survive a measurement. The demo showed the edge of the retry:
two lost reads cost nothing, and a third raises OSError 5 for the caller to handle. Press Edit, change
bus.fail_next(2) in test_retry to bus.fail_next(3) and run the tests again: the retry test now fails, which is
what a test of a limit should do.
Checkpoint
Check: Testing with a fake bus
Practise: build the ENV1 driver in two steps
Build the driver yourself, in the order this lesson took: first the register helpers on an injected bus, with retries, then the class that checks the ID, starts a measurement without touching the averaging bits, and converts the readings. The sample tests give each step a fake bus:
Code lab · 2 steps
Build the MSC-ENV1 driver
Write register helpers on an injected bus, then a driver class that checks its ID and returns readings in whole units.
Exercise · Medium · Python
Register helpers on an injected bus, with retries
Write the four register helpers of regs.py. Each takes the bus as its first argument, so the same code runs on machine.I2C, SoftI2C or a fake, and each reads into a buffer the caller made once.
read_into(i2c, addr, reg, buf, tries=3)callsi2c.readfrom_mem_into(addr, reg, buf). When it raisesOSError, try again, up totriesattempts in all, and let the lastOSErrorpropagate.read_u8(i2c, addr, reg, buf1)returns one register as an int from 0 to 255, read into the 1-bytebuf1.read_s16(i2c, addr, reg, buf2)returns two registers fromregas a signed big-endian 16-bit int, read into the 2-bytebuf2, withoutstructand withoutint.from_bytes().update_bits(i2c, addr, reg, mask, value, buf1)reads the register, replaces the bits inmaskwith the bits ofvalue(already shifted into place), writes the byte back withi2c.writeto_mem(addr, reg, buf1), and returns it.
The sample tests use a fake bus whose fail_next(n) makes the next n reads raise OSError(5).
Starter code · regs.py
def read_into(i2c, addr, reg, buf, tries=3):
"""i2c.readfrom_mem_into(addr, reg, buf), tried up to tries times."""
def read_u8(i2c, addr, reg, buf1):
"""One register as an int from 0 to 255."""
return 0
def read_s16(i2c, addr, reg, buf2):
"""Two registers as a signed big-endian 16-bit int."""
return 0
def update_bits(i2c, addr, reg, mask, value, buf1):
"""Replace the bits of mask with value, keep the others; return the byte written."""
return 0 The sample tests · test_regs.py
from regs import read_into, read_s16, read_u8, update_bits
class FakeBus:
def __init__(self, regs):
self.regs = bytearray(regs)
self.failures = 0
self.reads = 0
self.writes = []
def fail_next(self, n):
self.failures = n
def readfrom_mem_into(self, addr, reg, buf):
self.reads += 1
if self.failures:
self.failures -= 1
raise OSError(5)
buf[:] = self.regs[reg:reg + len(buf)]
def writeto_mem(self, addr, reg, buf):
self.writes.append((reg, bytes(buf)))
self.regs[reg:reg + len(buf)] = buf
def test_read_u8():
"""reads one register"""
assert read_u8(FakeBus(b"\xe1\x04"), 0x41, 0x00, bytearray(1)) == 0xE1
def test_read_s16():
"""reads a signed big-endian pair"""
bus = FakeBus(b"\x00\x00\x00\x0a\xc0\xfd\x60")
buf = bytearray(2)
assert read_s16(bus, 0x41, 0x03, buf) == 2752
assert read_s16(bus, 0x41, 0x05, buf) == -672
def test_retry_then_success():
"""retries two lost reads"""
bus = FakeBus(b"\xe1")
bus.fail_next(2)
assert read_u8(bus, 0x41, 0x00, bytearray(1)) == 0xE1
assert bus.reads == 3
def test_retry_limit():
"""raises after the last try"""
bus = FakeBus(b"\xe1")
bus.fail_next(3)
try:
read_into(bus, 0x41, 0x00, bytearray(1))
except OSError as e:
assert e.args[0] == 5 and bus.reads == 3
else:
raise AssertionError("no OSError")
def test_update_keeps_other_bits():
"""sets START and keeps the averaging bits"""
bus = FakeBus(b"\xe1\x04")
assert update_bits(bus, 0x41, 0x01, 0x01, 0x01, bytearray(1)) == 0x05
assert bus.writes == [(0x01, b"\x05")]
def test_update_field():
"""replaces a field with a new value"""
bus = FakeBus(b"\xe1\x05")
assert update_bits(bus, 0x41, 0x01, 0x06, 3 << 1, bytearray(1)) == 0x07 A hint
In read_into(), loop for attempt in range(tries), return after a read that works, and raise inside the except when attempt == tries - 1. For read_s16(), build buf2[0] << 8 | buf2[1] and subtract 0x10000 when bit 15 is set.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
The MSC-ENV1 driver class
Finish the ENV1 class of env1.py, a driver for the made-up MSC-ENV1 on an injected bus. _read_u8() and _write_u8() are written for you.
ENV1(i2c, address=0x41) keeps the bus and the address, makes a bytearray(4) for the readings, and reads register 0x00. Unless it reads 0xE1, raise OSError(errno.ENODEV, message); a read that raises OSError counts as not found, too.
measure() sets bit 0 of CTRL (register 0x01) with read-modify-write, so the averaging bits 2 and 1 stay as they are, and reads STATUS (0x02) until bit 0 is set, at most 100 times, raising OSError(errno.ETIMEDOUT, message) after that. It then reads the four registers from 0x03 in one transfer into its buffer: TEMP, a signed big-endian count of 1/128 °C, and HUM, an unsigned count of 1/64 %. It returns (t, h) in hundredths of a degree and tenths of a percent, rounded with (raw_t * 100 + 64) // 128 and (raw_h * 10 + 32) // 64.
Starter code · env1.py
import errno
_REG_ID, _REG_CTRL, _REG_STATUS, _REG_DATA = 0x00, 0x01, 0x02, 0x03
_CHIP_ID, _START, _READY = 0xE1, 0x01, 0x01
class ENV1:
def __init__(self, i2c, address=0x41):
self.i2c = i2c
self.address = address
self._one = bytearray(1)
def _read_u8(self, reg):
self.i2c.readfrom_mem_into(self.address, reg, self._one)
return self._one[0]
def _write_u8(self, reg, value):
self._one[0] = value
self.i2c.writeto_mem(self.address, reg, self._one)
def measure(self):
"""(hundredths of a degree C, tenths of a percent) from one measurement."""
return (0, 0) The sample tests · test_env1.py
import errno
from env1 import ENV1
class FakeENV1Bus:
"""A bus with one MSC-ENV1 at 0x41, logging every transfer."""
def __init__(self, chip_id=0xE1, temp=2752, hum=3088, ready_after=1, present=True):
self.regs = bytearray(8)
self.regs[0] = chip_id
self.regs[3:5] = (temp & 0xFFFF).to_bytes(2, "big")
self.regs[5:7] = hum.to_bytes(2, "big")
self.ready_after, self.present, self.log, self.polls = ready_after, present, [], 0
def readfrom_mem_into(self, addr, reg, buf):
self.log.append(("read", reg, len(buf)))
if not self.present or addr != 0x41:
raise OSError(5)
if reg == 0x02:
self.polls += 1
if self.ready_after is not None and self.polls >= self.ready_after:
self.regs[2] = 0x01
buf[:] = self.regs[reg:reg + len(buf)]
def writeto_mem(self, addr, reg, buf):
self.log.append(("write", reg, bytes(buf)))
if not self.present:
raise OSError(5)
self.regs[reg:reg + len(buf)] = buf
def raises(fn, code):
try:
fn()
except OSError as e:
return e.args[0] == code
return False
def test_reading():
"""returns hundredths of a degree and tenths of a percent"""
assert ENV1(FakeENV1Bus()).measure() == (2150, 483)
def test_negative():
"""rounds below zero to the nearest hundredth"""
assert ENV1(FakeENV1Bus(temp=-673, hum=0)).measure() == (-526, 0)
def test_not_found():
"""raises ENODEV for a wrong chip and for no answer"""
assert raises(lambda: ENV1(FakeENV1Bus(chip_id=0x5A)), errno.ENODEV)
assert raises(lambda: ENV1(FakeENV1Bus(present=False)), errno.ENODEV)
def test_keeps_averaging():
"""sets START without clearing the averaging bits"""
bus = FakeENV1Bus()
bus.regs[1] = 0b100
ENV1(bus).measure()
assert ("write", 0x01, b"\x05") in bus.log
def test_one_transfer():
"""reads the four data registers in one transfer"""
bus = FakeENV1Bus()
ENV1(bus).measure()
assert ("read", 0x03, 4) in bus.log
def test_waits_for_ready():
"""polls STATUS until ready, and gives up after 100 reads"""
assert ENV1(FakeENV1Bus(ready_after=5)).measure() == (2150, 483)
bus = FakeENV1Bus(ready_after=None)
env = ENV1(bus)
assert raises(env.measure, errno.ETIMEDOUT)
assert bus.polls == 100 A hint
In __init__, put the ID read in a try and set the result to None when it raises; then compare once. In measure(), write self._read_u8(0x01) | 0x01, loop over range(100) with break when the ready bit is set, and use the for ... else clause for the timeout.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself on injection, constants, ID checks, register fields, conversions and fakes:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why should a driver take its bus as an argument instead of creating it?
Because the driver then works with any object that has the bus’s methods. On one board the start-up code passes
I2C(0) on GP4 and GP5, on another a SoftI2C on whatever pins are free, and in a test a fake bus that runs on a
computer. A driver that creates I2C(0) itself is tied to one board’s pins and cannot be tested without the
hardware, and two drivers that each create the same bus fight over its setup. Creating the bus once, where the board
is described, and passing it in keeps every board difference in one place.
Two driver questions of the kind a design review asks are listed below; the link under each leads to the skills it checks and a first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A colleague's sensor driver creates I2C(0) in __init__, keeps register numbers as plain globals, returns floats and calls readfrom_mem() on every reading. It must now run on an ESP32 board, under SoftI2C and in CI. Review it: what do you change, in what order, and why?
-
Fresher to mid
A driver starts each measurement by writing 0x01 to the sensor's control register. After someone added a call that sets averaging to 16 samples in bits 2 and 1 of the same register, the readings are as noisy as before. Explain what happens, fix it, and say how a test on a computer would have caught it.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why should a driver take its bus as an argument?
The same class then runs on I2C, on SoftI2C and on a fake in a test, and the board's pins are chosen once, in main.py. A driver that creates I2C(0) itself is tied to one board.
-
What does _NAME = const(value) save in a driver?
The parser puts the value into the bytecode wherever the name is used, so nothing is looked up at run time, and the leading underscore keeps the name out of the module's globals, so it costs no RAM.
-
Does micropython.const(0x00) behave like const(0x00)?
No: the parser recognises only the bare name const, so an attribute call or an alias is an ordinary call.
-
Why does a driver read an ID register in its constructor?
An I2C address says nothing about which chip answers. Comparing the ID proves the right device is there; no answer, or another chip, raises OSError(errno.ENODEV) with the address and what was found in its message.
-
How do you set one bit of a register, or change one field, and keep the rest?
Read-modify-write: read the register, then write back ctrl | _START to set a bit, or (ctrl & ~_MASK) | value << shift to change a field.
-
How do you round raw / 128 to the nearest hundredth with integers?
(raw * 100 + 64) // 128: adding 64, half of 128, before the division rounds to the nearest hundredth, since // rounds towards minus infinity.
-
How can a board prove that a driver's hot path allocates nothing?
Call micropython.heap_lock() before the hot path runs: any object it still creates then stops it at once with MemoryError.
Key takeaways
- A driver takes its bus as an argument, so the same class runs on
I2C,SoftI2Cand a fake in a test, and the board’s pins are chosen in one place. _NAME = const(value)puts register numbers into the bytecode and keeps them out of the module’s globals; only the bare nameconstworks, and the form with a type hint is new in 1.29.- The constructor reads an ID and raises
OSError(errno.ENODEV)with a clear message when the device is missing or another chip answers, and every wait on hardware has a limit. - Read-modify-write changes one field of a register and keeps the rest:
ctrl | _START, or(ctrl & ~_MASK) | value << shift. - Reading into a buffer made once and converting with integer arithmetic keeps the hot path free of allocation,
which
micropython.heap_lock()can prove on a board. - A fake bus with the methods of
machine.I2Cand a way to drop bytes lets tests pin every promise of the driver on a computer, retries included.
Terms in this lesson
- dependency injection
- Giving an object the things it works with, such as a bus, as arguments instead of letting it create them. A driver that takes its bus this way runs on any bus, a fake one in a test included.
References
- micropython – const(), heap_lock() (MicroPython project)
- class I2C – a two-wire serial protocol (memory operations) (MicroPython project)
- errno – system error codes (MicroPython project)
- MicroPython on microcontrollers (constrained memory, buffers and allocation) (MicroPython project)
- Package management (package.json for mip) (MicroPython project)
- MicroPython v1.29.0 release notes (const assignments with type hints) (MicroPython project)
- The errno names a port knows (py/moderrno.c, v1.29.0) (MicroPython project)
- micropython-lib, the project's package collection with its drivers (MicroPython project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress