Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step One driver, three buses Step through where the bus comes from on a board, on another board and in a test, and what the driver calls on it.
One driver, three buses, step 5 of 5. Showing main.py or a test, ENV1(bus, 0x41) (I2C(0) inside: one board), readfrom_mem_into, I2C(0) on GP4, GP5, SoftI2C, any pins, FakeI2C in a test. In focus: ENV1(bus, 0x41).passes the busmain.py or a testcreates the busENV1(bus, 0x41)I2C(0) inside: one boardreadfrom_mem_intoand writeto_memI2C(0) on GP4, GP5a Pico's hardware busSoftI2C, any pinsanother boardFakeI2C in a teston CPython or unix

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

The ENV1 driver on a fake bus

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"]
  ]
}

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

  1. Question 1 of 2 What does ENV1(i2c) gain over a driver that runs self.i2c = I2C(0) in its constructor?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It works with any bus object that has the methods it calls, on any pins, and with a fake in a test

    Creating the bus once, where the board is described, keeps the pins in one place and lets the same class run on hardware, software buses and fakes.

  2. Question 2 of 2 Which object can a test pass to ENV1 instead of a real bus?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Any object with readfrom_mem_into(addr, reg, buf) and writeto_mem(addr, reg, buf) that behaves like the sensor

    Duck typing does the work: the driver never checks the type of its bus, only calls two methods on it.

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()

  1. Question 1 of 2 What does the underscore in _REG_CTRL = const(0x01) change?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The name is left out of the module's globals, so it costs no RAM and other modules cannot read it

    Use _NAME = const(value) for the driver's own register numbers and masks: inlined, private and free in RAM.

  2. Question 2 of 2 A module writes import micropython, then _REG = micropython.const(0x03). What does the parser do with it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing special: only the bare name const is recognised, so _REG is an ordinary global looked up at run time

    Write from micropython import const at the top, with a stand-in in a try for CPython, and use the bare name.

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:

Interactive · stage by stage What the ENV1 constructor checks before it returns Step through the constructor and see how each way of not finding the sensor becomes the same error.
What the ENV1 constructor checks before it returns, step 4 of 4. Showing Read the ID register, Compare with 0xE1, Raise ENODEV if not, Return a working driver. In focus: Return a working driver.Read the ID register0x00, up to 3 triesCompare with 0xE1the MSC-ENV1 IDRaise ENODEV if notaddress and what answeredReturn a working driver

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Question 1 of 2 Why does the driver read register 0x00 in its constructor, even though scan() already lists 0x41?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An address says nothing about which chip answers; the ID proves it is MSC-ENV1

    Several chips can share an address. Comparing a known ID turns a wrong or missing device into a clear error at start-up instead of nonsense readings later.

  2. Question 2 of 2 reset() polls CTRL until the RESET bit clears. Why does the loop stop after 100 reads?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So a sensor that never clears the bit makes the driver raise OSError(ETIMEDOUT) instead of hanging the board

    The driver turns a stuck device into OSError(errno.ETIMEDOUT) with a message, which the caller can log or retry.

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:

Interactive · line by line Setting START with and without read-modify-write ctrl is what the register holds; AVG masks its averaging bits. Watch which bits survive each write.

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

After line 14: AVG is 6; START is 1; ctrl is 6; naive is 1; kept is 5.AVG6START1ctrl6naive1kept5
  • changed in this step

Printed so far

naive 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.

Line run 14
Next line end
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. AVG = 0b110 sets AVG to 6.
  3. Line 2. START = 0b001 sets START to 1.
  4. Line 3. ctrl = 0b100 sets ctrl to 4.
  5. Line 5. naive = START sets naive to 1. 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.
  6. Line 6. print("naive", bin(naive)) prints naive 0b1.
  7. Line 7. print("avg", (naive & AVG) >> 1) prints avg 0. An averaging code of 0 means one sample. The sensor has quietly stopped averaging, and nothing raised an error.
  8. Line 9. kept = ctrl | START sets kept to 5. The fix ctrl | START sets bit 0 and keeps every other bit, so the byte becomes 0b101.
  9. Line 10. print("kept", bin(kept)) prints kept 0b101.
  10. Line 11. print("avg", (kept & AVG) >> 1) prints avg 2. The averaging code is still 2, so the sensor keeps averaging 16 samples.
  11. Line 13. ctrl = (ctrl & ~AVG) | 3 << 1 sets ctrl to 6 (it was 4). Key point & ~AVG clears the field, and | 3 << 1 writes the new code into it: 64 samples, with bit 0 untouched.
  12. Line 14. print("new", bin(ctrl)) prints new 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

  1. Question 1 of 2 CTRL holds 0b100 (averaging code 2). Which write starts a measurement and keeps the averaging?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Write ctrl | 0b001, which is 0b101

    The trace printed kept 0b101 and avg 2: set a bit with OR on the value you read, then write the whole byte back.

  2. Question 2 of 2 A register keeps a 3-bit gain field in bits 5 to 3. Which expression sets the gain to 6 and keeps every other bit of reg?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: (reg & ~0b111000) | 6 << 3

    For a field: clear it with AND of the inverted mask, then OR in the new value shifted to its position. The driver's set_averaging() does the same for bits 2 and 1.

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

  1. Question 1 of 2 The driver returns (raw * 100 + 64) // 128 for the temperature. What does it return for raw = −673?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: -526 hundredths of °C

    −673 × 100 + 64 = −67,236, and −67,236 // 128 = −526 because // rounds towards minus infinity. The exact value is −525.78, so −526 is the nearest hundredth, as the driver's own test expects.

  2. Question 2 of 2 Which object does measure() still create on every call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The tuple it returns

    micropython.heap_lock() around a call would show the tuple as the one allocation. Everything else of the hot path reuses what the constructor made.

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:

Six driver tests that need no board

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)

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

  1. Question 1 of 2 In the demo, bus.fail_next(2) came before env.measure(), and the reading still arrived. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The driver tries each read up to three times, so two lost reads are retried and the third succeeds

    With fail_next(3) the third read fails too, and measure() raises OSError 5, which the demo printed. A test pins both sides of that limit.

  2. Question 2 of 2 What can a fake bus on a computer not tell you about a driver?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Whether the real sensor's timing and electrical behaviour match what the fake assumes

    Host tests pin the logic; a short run on the board, or against a second board with machine.I2CTarget, checks the parts a fake cannot.

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.

  1. Register helpers on an injected bus, with retries To do
  2. The MSC-ENV1 driver class To do

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) calls i2c.readfrom_mem_into(addr, reg, buf). When it raises OSError, try again, up to tries attempts in all, and let the last OSError propagate.
  • read_u8(i2c, addr, reg, buf1) returns one register as an int from 0 to 255, read into the 1-byte buf1.
  • read_s16(i2c, addr, reg, buf2) returns two registers from reg as a signed big-endian 16-bit int, read into the 2-byte buf2, without struct and without int.from_bytes().
  • update_bits(i2c, addr, reg, mask, value, buf1) reads the register, replaces the bits in mask with the bits of value (already shifted into place), writes the byte back with i2c.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.

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.

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.

  1. Question 1 of 12 Where should the I2C object for a board be created?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Once, in main.py or a board module, which passes it to every driver that needs it

    The application knows the board; the driver knows the device. Keep each where it belongs.

  2. Question 2 of 12 Two drivers, for two sensors at 0x41 and 0x44, share one I2C bus. What should main.py do?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Create one I2C object and pass the same object to both drivers, each with its own address

    Injection makes sharing natural: drivers hold a reference to the same bus object and differ in address.

  3. Question 3 of 12 A driver must run on MicroPython 1.27 and 1.29. Which form of a register constant is inlined on both?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: _REG_TEMP = const(0x03)

    Use the type-hint form only where every target is 1.29 or later; otherwise keep the classic form.

  4. Question 4 of 12 What does REG_ID = const(0x00), without an underscore, cost?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: At least two machine words of RAM for its global entry, even though uses are inlined

    Make register names private unless another module needs to read them.

  5. Question 5 of 12 The ID read raises OSError 5 because nothing answers. What should the constructor raise?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: OSError(errno.ENODEV, "MSC-ENV1 not found at 0x41: no answer")

    The lesson's driver turns both a failed read and a wrong ID into ENODEV, the error MicroPython uses for a missing device, with the address in the message.

  6. Question 6 of 12 A new board revision has a different sensor at 0x41, and the old driver's constructor still succeeds, returning nonsense. What was missing?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: An ID check in the constructor

    Compare a known ID before anything else, so a board change shows up as a clear OSError at start-up.

  7. Question 7 of 12 A timer callback and the main loop both update CTRL with read-modify-write. What can go wrong?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: The callback can run between the main loop's read and write, and its change is then overwritten

    Do the update in one place only: the callback sets a flag or schedules the change for the code that owns CTRL. A lock suits two tasks, but a callback cannot wait for the main loop to release one.

  8. Question 8 of 12 Which expression clears bit 7 of ctrl and keeps the others?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: ctrl & ~0x80

    Set with OR, clear with AND of the inverted mask, and write the whole byte back.

  9. Question 9 of 12 HUM is a count of 1/64 % and the driver returns (raw * 10 + 32) // 64 tenths of a percent. What does it return for raw = 3088?

    Type a number.

    Show the answer: Question 9 of 12

    Answer: 483 tenths of a percent

    3,088 × 10 + 32 = 30,912, and 30,912 // 64 = 483. The exact value is 482.5 tenths, 48.25 %, and the + 32 rounds the half up.

  10. Question 10 of 12 Why does the driver convert with integers instead of returning raw / 128?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Integer arithmetic on small values allocates nothing, while each float result is a new object

    Whole units such as hundredths of a degree keep exact values and an untouched heap; the caller divides only when it displays them.

  11. Question 11 of 12 The test asserts ("read", 0x03, 4) in bus.log. What does it pin down?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: That measure() reads TEMP and HUM in one transfer of four bytes from register 0x03

    One transfer for four registers halves the bus traffic and keeps the two values from different measurements apart; the log makes that a tested promise.

  12. Question 12 of 12 Where can the six driver tests run without any change?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: On CPython and on MicroPython's unix port, because they use plain asserts and a fake bus

    Keeping tests to plain Python lets the same file run in CI on CPython and on the unix port, which has MicroPython's own behaviour.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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, SoftI2C and 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 name const works, 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.I2C and 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.