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:
- the model writes
th42.pyfrom the register table and seven numbered requirements; - a fake
machine.I2C, in plain Python, plays the sensor and answers with your bytes; - 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.
qwen3-coder-30b: five drivers out of five passed all nine checks. Withtemperature=0the code was almost the same every time: three of the five files were identical. The runs we timed took about 5 seconds.qwen3.8-27b: three drivers out of five passed. Each took between 12 and 35 seconds: the model reasons before it answers.
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
- The test is worth more than the driver. The next time the model writes a different driver, the same test checks it in a second.
- Change the model, keep the test. Two models, two kinds of mistakes: Coder was right every time here, Qwen3.8 failed in two runs out of five. With the test, you do not need to trust either one.
- MicroPython is not CPython.
time.sleep_ms(),time.ticks_ms()andmachineexist only in MicroPython: that is why the test brings its own.
Exercise
- Break the driver yourself: change
0x10000to0xFFFFinth42.pyand run the test. Which cases fail? - Add an eighth requirement: a method
reset()that writes0x80to CTRL. Write a check that looks for("write", 0x01, b"\x80")indevice.log. - A second round: send the model its own driver and the FAIL lines of the test, and ask it to fix them. Does it fix only what failed?
- Do the same for a real sensor you have in the lab, for example an SHT31 or a BME280: its datasheet, a fake that answers with bytes from its examples, and the expected values worked out by hand.
