Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

A MicroPython driver from a register table

A model writes a MicroPython driver for an I2C sensor from its register table. Your PC runs it against a fake I2C bus that answers with bytes you chose, and checks every value against numbers worked out by hand from the datasheet, before any board is plugged in.

Advanced · 45 min

The idea

A driver turns bytes into numbers. A small mistake in it does not crash anything: the sensor works, the board works, and the dashboard shows 645.36 °C. The same mistakes come back again and again: the sign of a negative value, the order of two bytes, a limit that is not applied, an I2C call that does not exist.

You do not need a board to find them. The datasheet says which bytes mean which value, so you can work out by hand what the driver must return for any bytes you choose. Then:

  1. the model writes th42.py from the register table and seven numbered requirements;
  2. a fake machine.I2C, in plain Python, plays the sensor and answers with your bytes;
  3. a test compares what the driver returns with your numbers, and also tries a different chip and a dead sensor.

What you need

A key and the openai library, as in Your first API call in Python. Python 3 on your PC is enough: no board, no MicroPython.

The TH-42 is invented, so there is no chip to buy. The method works the same for any real I2C sensor.

The datasheet

Save it as th42.txt:

TH-42 digital temperature and humidity sensor: register map (rev. 1.2)

I2C address: 0x42 (ADDR pin to GND) or 0x43 (ADDR pin to VDD), 7-bit. Clock up to 400 kHz.

Register  Name    Access  Description
0x00      ID      R       Always reads 0x5A.
0x01      CTRL    W       Write 0x01 to start one measurement. Write 0x80 for a soft reset.
0x02      STATUS  R       Bit 0 (READY) is 1 when a new measurement is in 0x03-0x06.
                          Reading register 0x03 clears it.
0x03      T_MSB   R       Temperature, most significant byte.
0x04      T_LSB   R       Temperature, least significant byte.
0x05      H_LSB   R       Humidity, least significant byte.
0x06      H_MSB   R       Humidity, most significant byte.

Registers 0x03-0x06 can be read in one burst of 4 bytes, starting at 0x03.

Temperature: 16-bit signed integer (two's complement), T_MSB first.
    T [°C] = raw x 0.01
Humidity: 16-bit unsigned integer, H_LSB first.
    RH [%] = raw x 125 / 65536 - 6
    The formula can give less than 0 or more than 100: clip the result to 0-100.

A measurement takes at most 15 ms. If READY is not set 50 ms after the start,
the sensor is not working.

The request

Seven numbered requirements, so you can check them one by one. Save them as request.txt:

Write a MicroPython driver for the TH-42 sensor, from its datasheet below. It must:
1. be a class TH42, created as TH42(i2c, address=0x42), where i2c is a machine.I2C object;
2. read the ID register when it is created, and raise OSError if it is not 0x5A;
3. have a method read() that starts one measurement, waits for READY, and returns (temperature, humidity) as two floats, in °C and %;
4. wait by reading STATUS every 2 ms, and raise OSError if READY is not set within 50 ms;
5. read the four data registers in one burst, starting at 0x03;
6. follow the formulas of the datasheet exactly;
7. import only machine, time and struct (time.sleep_ms, time.ticks_ms and time.ticks_diff are fine).
Only the code of th42.py.

1. The driver

Save it as write_driver.py, next to the two text files:

import os
import re
import sys
from pathlib import Path

from openai import OpenAI

client = OpenAI(
    base_url="https://api.ai.itsincom.org/v1",
    api_key=os.environ["API_KEY"],
)

model = sys.argv[1] if len(sys.argv) > 1 else "qwen3-coder-30b"
request = Path("request.txt").read_text() + "\n" + Path("th42.txt").read_text()

answer = client.chat.completions.create(
    model=model,
    temperature=0,
    messages=[{"role": "user", "content": request}],
)
if answer.choices[0].finish_reason != "stop":
    sys.exit("The answer was cut off: run it again.")
text = answer.choices[0].message.content

# The model puts the code between ``` lines, sometimes with words around it: keep the code.
code = re.search(r"```[a-z]*\n(.*?)```", text, re.S)
Path("th42.py").write_text(code.group(1) if code else text.strip() + "\n")
print(f"th42.py from {model}: {len(Path('th42.py').read_text().splitlines())} lines")
python3 write_driver.py
th42.py from qwen3-coder-30b: 50 lines

2. A fake bus, and the test

On a board, machine.I2C sends bytes down two wires. Here a class with the same methods plays the TH-42: it starts a measurement when the driver writes 0x01 to CTRL, sets READY 10 ms later, and puts your four bytes in registers 0x03 to 0x06. The clock is fake too, so 50 ms of waiting take no time.

Save it as test_th42.py:

import importlib.util
import sys
import types

# A fake MicroPython, just enough for the driver to run on your PC. The clock is fake too:
# sleep_ms() moves it forward at once, so the tests take no time.
clock = {"ms": 0}


def sleep_ms(ms):
    clock["ms"] += ms


fake_time = types.ModuleType("time")
fake_time.sleep_ms = sleep_ms
fake_time.sleep_us = lambda us: sleep_ms(us / 1000)
fake_time.sleep = lambda s: sleep_ms(s * 1000)
fake_time.ticks_ms = lambda: int(clock["ms"])
fake_time.ticks_diff = lambda new, old: new - old
fake_time.ticks_add = lambda ticks, delta: ticks + delta


class FakeTH42:
    """A TH-42 that follows the datasheet and measures the bytes we choose."""

    def __init__(self, address=0x42, chip_id=0x5A, data=b"\x00\x00\x00\x00", working=True):
        self.address, self.data, self.working = address, data, working
        self.regs = bytearray(8)
        self.regs[0x00] = chip_id
        self.ready_at = None
        self.pointer = 0
        self.log = []  # every transfer, to see how the driver talks to the chip

    def tick(self):
        clock["ms"] += 1  # every transfer takes time, so even a loop without sleep_ms ends
        if len(self.log) > 1000:
            raise RuntimeError("more than 1000 I2C transfers: the driver is stuck in a loop")
        if self.ready_at is not None and clock["ms"] >= self.ready_at:
            self.regs[0x03:0x07] = self.data
            self.regs[0x02] |= 0x01  # READY
            self.ready_at = None

    def write(self, reg, data):
        self.tick()
        self.log.append(("write", reg, bytes(data)))
        if reg == 0x01 and data and data[0] & 0x01 and self.working:
            self.ready_at = clock["ms"] + 10  # this TH-42 measures in 10 ms

    def read(self, reg, n):
        self.tick()
        self.log.append(("read", reg, n))
        out = bytes(self.regs[r] if r < len(self.regs) else 0 for r in range(reg, reg + n))
        if reg <= 0x03 < reg + n:
            self.regs[0x02] &= ~0x01  # reading 0x03 clears READY
        return out


class FakeI2C:
    """The methods of machine.I2C that drivers use, with one TH-42 on the bus."""

    def __init__(self, device, *args, **kwargs):
        self.device = device

    def check(self, addr):
        if addr != self.device.address:
            raise OSError(19, "ENODEV")  # nobody answers at this address, as on a real board

    def scan(self):
        return [self.device.address]

    def readfrom_mem(self, addr, reg, n, addrsize=8):
        self.check(addr)
        return self.device.read(reg, n)

    def readfrom_mem_into(self, addr, reg, buf, addrsize=8):
        self.check(addr)
        buf[:] = self.device.read(reg, len(buf))

    def writeto_mem(self, addr, reg, buf, addrsize=8):
        self.check(addr)
        self.device.write(reg, buf)

    def writeto(self, addr, buf, stop=True):
        self.check(addr)
        if len(buf) == 1:
            self.device.pointer = buf[0]  # only the register number: a read follows
        else:
            self.device.write(buf[0], buf[1:])
        return len(buf)

    def readfrom(self, addr, n, stop=True):
        self.check(addr)
        return self.device.read(self.device.pointer, n)

    def readfrom_into(self, addr, buf, stop=True):
        buf[:] = self.readfrom(addr, len(buf))


machine = types.ModuleType("machine")
machine.I2C = machine.SoftI2C = FakeI2C
machine.Pin = lambda *args, **kwargs: None
micropython = types.ModuleType("micropython")
micropython.const = lambda value: value
sys.modules.update(machine=machine, time=fake_time, utime=fake_time, micropython=micropython)

path = sys.argv[1] if len(sys.argv) > 1 else "th42.py"
spec = importlib.util.spec_from_file_location("th42", path)
th42 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(th42)

# The 4 bytes of registers 0x03-0x06, and the values worked out by hand from the datasheet.
CASES = [
    ("room", "09 29 00 80", 23.45, 56.50),         # T 0x0929 = 2345;  H 0x8000 = 32768
    ("cold", "fc 18 34 12", -10.00, 2.89),         # T 0xFC18 = -1000; H 0x1234 = 4660 -> 2.888
    ("just below 0", "ff ff 00 00", -0.01, 0.00),  # T 0xFFFF = -1;    H 0 -> -6, clipped to 0
    ("very humid", "00 00 ff f0", 0.00, 100.00),   # T 0;              H 0xF0FF -> 111.7, clipped to 100
]
failed = 0


def report(ok, what, detail=""):
    global failed
    failed += not ok
    print(f"{'ok  ' if ok else 'FAIL'}  {what}" + (f": {detail}" if detail else ""))


def error(e):
    return f"{type(e).__name__}: {e}"


try:
    th42.TH42(FakeI2C(FakeTH42()))
    report(True, "a TH-42 is accepted")
except Exception as e:
    report(False, "a TH-42 is accepted", error(e))

try:
    th42.TH42(FakeI2C(FakeTH42(chip_id=0x60)))
    report(False, "another chip is refused", "no error")
except OSError:
    report(True, "another chip is refused")
except Exception as e:
    report(False, "another chip is refused", error(e) + ", not OSError")

for name, data, temperature, humidity in CASES:
    device = FakeTH42(data=bytes.fromhex(data))
    try:
        t, h = th42.TH42(FakeI2C(device)).read()
    except Exception as e:
        report(False, f"{name:12} {data}", error(e))
        continue
    ok = abs(t - temperature) < 0.005 and abs(h - humidity) < 0.005
    report(ok, f"{name:12} {data}", f"{t:.2f} °C {h:.2f} %"
           + ("" if ok else f", expected {temperature:.2f} °C {humidity:.2f} %"))

report(("read", 0x03, 4) in device.log, "the 4 data bytes in one burst from 0x03")

try:
    th42.TH42(FakeI2C(FakeTH42(address=0x43, data=bytes.fromhex("09 29 00 80"))), address=0x43).read()
    report(True, "address 0x43")
except Exception as e:
    report(False, "address 0x43", error(e))

start = clock["ms"]
try:
    th42.TH42(FakeI2C(FakeTH42(working=False))).read()
    report(False, "a sensor that never gets READY", "no error")
except OSError:
    waited = clock["ms"] - start
    report(50 <= waited <= 60, "a sensor that never gets READY", f"OSError after {waited:.0f} ms")
except Exception as e:
    report(False, "a sensor that never gets READY", error(e) + ", not OSError")

print(f"{failed} failed")
sys.exit(1 if failed else 0)
python3 test_th42.py

What you should see

With the driver from qwen3-coder-30b:

ok    a TH-42 is accepted
ok    another chip is refused
ok    room         09 29 00 80: 23.45 °C 56.50 %
ok    cold         fc 18 34 12: -10.00 °C 2.89 %
ok    just below 0 ff ff 00 00: -0.01 °C 0.00 %
ok    very humid   00 00 ff f0: 0.00 °C 100.00 %
ok    the 4 data bytes in one burst from 0x03
ok    address 0x43
ok    a sensor that never gets READY: OSError after 54 ms
0 failed

Now ask the other model, and test again:

python3 write_driver.py qwen3.8-27b
python3 test_th42.py

This is one of our runs:

FAIL  a TH-42 is accepted: TypeError: %X format: an integer is required, not bytes
FAIL  another chip is refused: TypeError: %X format: an integer is required, not bytes, not OSError
FAIL  room         09 29 00 80: TypeError: %X format: an integer is required, not bytes
…
FAIL  a sensor that never gets READY: TypeError: %X format: an integer is required, not bytes, not OSError
9 failed

What the drivers got wrong

We asked each model five times, with the same request.

The two failures were not in the formulas. One driver called i2c.write_to() and i2c.read_from(): machine.I2C has writeto() and readfrom(), without the underscore. The other one is the run above. It reads a register like this:

    def _read_reg(self, reg, length=1):
        self._i2c.writeto(self._addr, bytes([reg]))
        return self._i2c.readfrom(self._addr, length)

readfrom() returns bytes, even for one byte. So id_reg != 0x5A compares b'Z' with the number 90: always different, and the driver refuses every TH-42. It even fails while it writes its error message, with the TypeError above. A few lines later, status & 0x01 on bytes would stop with a TypeError too.

The sign of the temperature, the order of the humidity bytes and the clipping were right in all ten drivers. When we fixed the one wrong line of each failing driver, both passed all nine checks. The model read the datasheet well; it was the plumbing that broke, and on a board you would have found it only after flashing.

Why these bytes

The four cases are not random. Each one catches a classic mistake. We broke a correct driver on purpose, one mistake at a time, and ran the test:

Mistake in the driver Case that fails What the driver returned
temperature read as unsigned cold, just below 0 645.36 °C, 655.35 °C
raw - 0xFFFF in place of raw - 0x10000 cold, just below 0 -9.99 °C, 0.00 °C
humidity read MSB first room, cold 0.00 %, 19.42 %
no clipping just below 0, very humid -6.00 %, 111.67 %
timeout of 500 ms the sensor that never gets READY OSError after 504 ms

The “room” case alone would have passed four of these five drivers. Test the edges: a negative value, -1 (ff ff), and both limits of the clip.

The expected values in CASES are worked out by hand, from the formulas of the datasheet. Never copy them from what the driver prints: then the test checks the driver against itself.

What the fake cannot tell you

The fake answers exactly as the datasheet says. A real chip may not: a datasheet can be wrong, and a revision of the chip can behave differently. The fake also knows nothing about wires: pull-up resistors, a loose cable, a bus at the wrong speed. On the board, start with i2c.scan(): if the address is not in the list, no driver can help.

Some chips want the register number and the read in one transfer, with no STOP between them. readfrom_mem() does that; writeto() followed by readfrom() sends a STOP in the middle, unless you pass stop=False to writeto(). The datasheet of the TH-42 says nothing about it, so the fake accepts both.

Good to know

Exercise

All examples