Skip to content

Writing Custom Sensor Drivers

Every sensor you've used so far (dht, ssd1306) came with a driver someone already wrote. Sooner or later you'll buy a part with only a PDF datasheet and no MicroPython support — this module is about turning that datasheet into a clean driver class: register maps, struct unpacking, calibration math, and packaging it so mip could install it. The register math and class logic run as pure Python via python3; the machine.I2C calls are reviewed against MicroPython docs since Wokwi doesn't expose an arbitrary configurable I2C sensor to test register reads against.

Reading a datasheet like a driver author

A typical I2C sensor datasheet gives you:

  • 7-bit I2C address (sometimes selectable via a pin, e.g. 0x68 or 0x69)
  • Register map — a table of addresses (0x00, 0x01, ...) and what each byte means
  • Data format — signed/unsigned, byte order (endianness), scaling factor
  • Init sequence — registers that must be written before readings are valid (power-on config, sample rate, range)

Take a made-up-but-typical accelerometer: address 0x1D, register 0x20 is "power control" (write 0x07 to enable all axes), registers 0x28-0x2D are X/Y/Z as three little-endian signed 16-bit values, scaled by a sensitivity factor from the datasheet.

Building the driver class

from machine import I2C
import struct

_ADDR = 0x1D
_REG_POWER_CTL = 0x20
_REG_DATA = 0x28
_SENSITIVITY = 0.004   # g per LSB, from the (fictional) datasheet

class Accelerometer:
    def __init__(self, i2c, addr=_ADDR):
        self.i2c = i2c
        self.addr = addr
        self._init_device()

    def _init_device(self):
        self.i2c.writeto_mem(self.addr, _REG_POWER_CTL, b"\x07")

    def read_raw(self):
        data = self.i2c.readfrom_mem(self.addr, _REG_DATA, 6)
        # 3 signed 16-bit little-endian values
        x, y, z = struct.unpack("<hhh", data)
        return x, y, z

    def read_g(self):
        x, y, z = self.read_raw()
        return (x * _SENSITIVITY, y * _SENSITIVITY, z * _SENSITIVITY)

struct.unpack("<hhh", data) is the workhorse: < means little-endian, h means signed 16-bit ("half word"), three of them for X/Y/Z. Get the endianness or signedness wrong and you get numbers that are almost right — off by a scale factor, or occasionally wildly negative — which is a much nastier bug than an outright crash.

i2c = I2C(0, scl=machine.Pin(22), sda=machine.Pin(21))
accel = Accelerometer(i2c)
print(accel.read_g())      # e.g. (0.02, -0.01, 0.98) — resting, Z ~ gravity

struct format cheat reference

Code Meaning Bytes
< / > little-endian / big-endian
b / B signed / unsigned byte 1
h / H signed / unsigned short 2
i / I signed / unsigned int 4
f float 4

Test the unpacking logic on made-up bytes before ever touching hardware — this part is pure Python and runs anywhere:

import struct
raw = bytes([0x10, 0x00, 0xF0, 0xFF, 0x00, 0x00])   # 16, -16, 0
print(struct.unpack("<hhh", raw))                    # (16, -16, 0)

Calibration

Raw sensor output usually needs an offset and/or scale correction found by measuring known reference points. A simple two-point linear calibration:

def calibrate_linear(raw_low, raw_high, true_low, true_high):
    """Return (scale, offset) such that true = raw * scale + offset."""
    scale = (true_high - true_low) / (raw_high - raw_low)
    offset = true_low - raw_low * scale
    return scale, offset

# e.g. a temperature sensor read 100 raw at 0 C and 500 raw at 40 C
scale, offset = calibrate_linear(100, 500, 0, 40)
def raw_to_celsius(raw):
    return raw * scale + offset

print(raw_to_celsius(300))   # ~20.0

Store scale/offset (or more calibration points) in a config file on flash (Level 1, module 7) so each physical unit can be calibrated once at build time, not hardcoded in the driver.

Packaging for mip

A driver becomes installable once it's a plain .py module hosted somewhere mip can fetch — a GitHub repo, or micropython-lib itself. Minimum shape:

my_accel_driver/
  accel.py          # the class above, no board-specific code inside

Keep board wiring (which I2C instance, which pins) out of the driver — the caller constructs I2C(...) and passes it in, exactly like the Accelerometer.__init__(self, i2c, addr=...) signature above. A driver that calls machine.I2C(0, scl=Pin(22), ...) internally only works on boards wired exactly that way; passing i2c in makes it reusable. Install with:

import mip
mip.install("github:yourname/my_accel_driver/accel.py")

Driver-writing traps

Endianness bugs look like calibration bugs

A byte-order mistake often produces values that are merely wrong, not obviously broken (e.g. 0x0100 read as 1 instead of 256). If numbers are "close but scaled weird," check struct format before suspecting your math.

I2C exceptions during init should not be silent

If _init_device() fails (device not wired, wrong address), let the OSError propagate from __init__ rather than swallowing it — a driver that "succeeds" to construct but never actually configured the device produces readings that are garbage, not obviously erroring.

Don't do slow work in __init__

Some devices need a settling delay (time.sleep_ms(50)) after power-up before the first read is valid. Do it once in __init__, not on every read_*() call — the latter silently triples your loop's cycle time.

How It Actually Works

Writing a driver from a datasheet forces you to confront what "reading a sensor" actually is underneath the Python: a specific sequence of raw bytes crossing a wire, with meaning that exists only in the datasheet's convention — nothing about the bytes themselves says what they mean.

  • struct.unpack is reversing a serialization format the sensor's own firmware chose, and MicroPython's struct module implements that reversal in C for speed, not in Python. A sensor's ADC produces a raw integer reading; its onboard controller decides how to lay that integer out as bytes (endianness, signed vs. unsigned, bit width) before shipping it over I2C — decisions baked into the sensor's firmware, unrelated to MicroPython or your driver. struct.unpack("<hhh", data) doesn't "know" anything about the sensor; it's a generic byte-reinterpretation operation, and getting the format string wrong (endianness, signedness) produces a different but still valid-looking integer, which is exactly why endianness bugs masquerade as calibration bugs — there's no error, just a wrong number that isn't obviously wrong.
  • i2c.writeto_mem/readfrom_mem are two back-to-back I2C transactions under one Python call, and the sensor's internal register file is a piece of hardware state entirely separate from anything in the ESP32. Writing 0x07 to a "power control" register doesn't change any Python state — it changes a latch or flip-flop bank physically inside the sensor's own silicon that its internal state machine reads to decide whether to power its ADC and sampling circuitry. That's why a botched or skipped _init_device() doesn't raise an error: the I2C transaction can succeed perfectly (the sensor ACKs the write) while the sensor's internal logic simply never turns on its measurement circuit — the bus doesn't know or care what a register means, only the sensor's firmware does, which is why read_raw() afterward returns "valid-looking" garbage rather than an exception.
  • The settling-delay warning reflects real analog physics, not a software quirk. Many sensors need tens of milliseconds after power-up for internal reference voltages, oscillators, or MEMS elements to stabilize — a genuine physical settling time no amount of fast I2C clocking can shorten. Doing that wait once in __init__ rather than per read_*() call is possible specifically because it's a one-time hardware warm-up, not a per-sample requirement — conflating the two costs real milliseconds on every reading for no benefit.
  • Passing i2c into __init__ instead of constructing it internally is what separates the driver's software abstraction from the board's physical wiringI2C(0, scl=Pin(22), sda=Pin(21)) claims specific GPIO pins and one of the ESP32's fixed hardware I2C controller instances; hardcoding that inside a driver silently assumes every board that uses it wired those exact pins to that exact bus, which is rarely true across projects even on the same chip family.

Cheat sheet

Function / idiom Purpose
i2c.readfrom_mem(addr, reg, n) Read raw bytes from a register
i2c.writeto_mem(addr, reg, bytes) Write bytes to configure a register
struct.unpack(fmt, data) Decode raw bytes into numbers
"<hhh" Little-endian, three signed 16-bit values
pass i2c into __init__, don't create it inside Keeps the driver board-agnostic
two-point linear calibration scale, offset from two known references
propagate OSError from init Fail loud, not silently-wrong

Exercise

Write (and test with python3, feeding it fabricated byte strings — no hardware needed for this part) a Thermistor driver class with __init__(self, i2c, addr=0x40), a read_raw() that unpacks one unsigned 16-bit big-endian value from register 0x00 via i2c.readfrom_mem(addr, 0x00, 2), and a read_celsius() that applies a calibrate_linear-derived scale/offset computed from two reference points you choose (e.g. raw 0 at -10 C, raw 65535 at 85 C — the sensor's full range). Prove the unpacking works by constructing raw bytes with struct.pack(">H", value) for a few test values and checking read_celsius() output against hand-calculated expected temperatures. Add a docstring noting which register/format assumptions would need to be confirmed against a real datasheet before running on hardware.