Examples · Internet of Things
Sensor specs from a datasheet
Three short datasheets go in, and JSON comes out with the range, accuracy, supply voltage, interface and I2C address of each sensor. Your code checks that every number is really in the datasheet, and that only an I2C sensor has an I2C address.
Beginner · 25 min
The idea
Before you wire a sensor, you look up a few things: what it measures, how precise it is, which voltage it wants, how it talks, and at which address. For a project with ten sensors, that is ten datasheets. A model can read them and fill a table for you, as JSON.
A table is useful only if its numbers are right. So:
- a JSON schema decides the fields, and the model fills them;
- your code checks that every number in the answer is somewhere in the datasheet;
- a second rule checks that an address goes only with I2C.
What you need
A key and the openai library, as in Your first API call in Python.
The datasheets
Three invented sensors, in a few lines each. Real datasheets are longer, but the hard parts look the same. Save them as datasheets.txt:
TH-42 temperature and humidity sensor
Temperature from -40 to 125 °C, accuracy ±0.3 °C between 0 and 60 °C.
Relative humidity from 0 to 100 %, accuracy ±2 %.
Supply 1.8 to 3.6 V, 0.4 mA while measuring. Interface: I2C, up to 400 kHz.
Address 0x42, or 0x43 with the ADDR pin to VDD.
LX-7 ambient light sensor
Range 0.01 to 83000 lux, accuracy ±15 % of the reading.
Supply 3.3 V ±10 %. I2C interface, fast mode.
Slave address: 0x52 for write, 0x53 for read (8-bit).
PM-25 particulate matter sensor
Measures PM2.5 and PM10 from 0 to 1000 µg/m³.
Accuracy ±10 µg/m³ up to 100 µg/m³, ±10 % above.
Supply 5 V (4.5 to 5.5 V), 80 mA with the fan running.
Interface: UART, 9600 baud, 3.3 V logic.
The program
Save it as specs.py, next to the datasheets:
import json
import os
import re
import sys
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 "gemma-4-26b"
datasheet = open("datasheets.txt").read()
schema = {
"type": "object",
"properties": {
"sensors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "enum": ["TH-42", "LX-7", "PM-25"]},
"measures": {
"type": "array",
"items": {
"type": "object",
"properties": {
"quantity": {"type": "string"},
"unit": {"type": "string"},
"min": {"type": "number"},
"max": {"type": "number"},
"accuracy": {"type": "string"},
},
"required": ["quantity", "unit", "min", "max", "accuracy"],
},
},
"supply_min_v": {"type": "number"},
"supply_max_v": {"type": "number"},
"interface": {"type": "string", "enum": ["I2C", "SPI", "UART", "analog"]},
"i2c_address": {"type": ["string", "null"], "pattern": "^0x[0-9A-F]{2}$"},
},
"required": ["name", "measures", "supply_min_v", "supply_max_v", "interface", "i2c_address"],
},
},
},
"required": ["sensors"],
}
answer = client.chat.completions.create(
model=model,
temperature=0,
messages=[
{"role": "system", "content": "Extract the specifications of each sensor from the datasheet. "
"Use only what the datasheet says. i2c_address is the default 7-bit "
"address, or null when the sensor is not on I2C."},
{"role": "user", "content": datasheet},
],
response_format={"type": "json_schema", "json_schema": {"name": "sensors", "schema": schema}},
)
if answer.choices[0].finish_reason != "stop":
sys.exit("The answer was cut off: run it again.")
sensors = json.loads(answer.choices[0].message.content)["sensors"]
with open("sensors.json", "w") as f:
json.dump(sensors, f, indent=1, ensure_ascii=False)
for sensor in sensors:
bus = f"{sensor['interface']} {sensor['i2c_address'] or ''}".strip()
print(f"{sensor['name']:6} {bus}, supply {sensor['supply_min_v']} to {sensor['supply_max_v']} V")
for m in sensor["measures"]:
print(f" {m['quantity']}: {m['min']} to {m['max']} {m['unit']}, accuracy {m['accuracy']}")
# Rule 1: every number the model wrote must be somewhere in the datasheet.
NUMBER = r"0x[0-9A-Fa-f]+|\d+(?:\.\d+)?"
def normal(number):
"""0x29 stays as it is; 5, 5.0 and 5.00 all become the same number."""
return number.lower() if number.lower().startswith("0x") else float(number)
in_datasheet = {normal(number) for number in re.findall(NUMBER, datasheet)}
flagged = 0
for sensor in sensors:
values = [sensor["supply_min_v"], sensor["supply_max_v"], sensor["i2c_address"]]
for m in sensor["measures"]:
values += [m["min"], m["max"], m["accuracy"]]
for value in values:
for number in re.findall(NUMBER, str(value)):
if normal(number) not in in_datasheet:
print(f"NOT IN THE DATASHEET: {sensor['name']} {number}")
flagged += 1
# Rule 2: an I2C sensor has an address, and any other sensor has none.
if (sensor["interface"] == "I2C") != (sensor["i2c_address"] is not None):
print(f"DOES NOT ADD UP: {sensor['name']} on {sensor['interface']} with address {sensor['i2c_address']}")
flagged += 1
print(f"{flagged} values to check by hand")
Run it with python3 specs.py. It takes a few seconds, and saves the table in sensors.json as well.
What you should see
TH-42 I2C 0x42, supply 1.8 to 3.6 V
temperature: -40 to 125 °C, accuracy ±0.3 °C between 0 and 60 °C
relative humidity: 0 to 100 %, accuracy ±2 %
LX-7 I2C 0x29, supply 2.97 to 3.63 V
ambient light: 0.01 to 83000 lux, accuracy ±15 % of the reading
PM-25 UART, supply 4.5 to 5.5 V
PM2.5 and PM10: 0 to 1000 µg/m³, accuracy ±10 µg/m³ up to 100 µg/m³, ±10 % above
NOT IN THE DATASHEET: LX-7 2.97
NOT IN THE DATASHEET: LX-7 3.63
NOT IN THE DATASHEET: LX-7 0x29
3 values to check by hand
We got these same values in all three runs.
Three numbers the model worked out
The three flags are not mistakes: they are numbers the model calculated, and the datasheet does not contain them.
- 2.97 and 3.63 V. The datasheet says “3.3 V ±10 %”. 10 % of 3.3 is 0.33, so the supply goes from 2.97 to 3.63 V. Right.
- 0x29. The datasheet gives the address in its 8-bit form: 0x52 to write, 0x53 to read. MicroPython and Arduino want the 7-bit address, which is the 8-bit one shifted right by one bit: 0x52 >> 1 = 0x29. Right too, and on a board
i2c.scan()would show 41, which is 0x29.
A flag means “the model did some work here: check it”. It does not mean “wrong”. Checking a calculation takes ten seconds; finding a wrong address on a breadboard can take an hour.
When the check says nothing
The last sentence of the system message, about the 7-bit address, was not there in our first version. Without it, both models gave the LX-7 the address 0x52 in all three runs. The check stayed silent, because 0x52 is in the datasheet. It is the wrong address for your code: i2c.readfrom_mem(0x52, …) gets no answer.
In the same three runs, Gemma also gave the PM-25 an I2C address. The PM-25 talks over UART:
PM-25 UART 0x52, supply 4.5 to 5.5 V
…
DOES NOT ADD UP: PM-25 on UART with address 0x52
The address of another sensor, put in the wrong place. Rule 1 cannot see it, because 0x52 is in the datasheet; rule 2 can, because a UART sensor has no I2C address. That is why the program has two rules: in the datasheet is not the same as in the right place.
Gemma or Qwen
We ran the program three times with each model: python3 specs.py gemma-4-26b and python3 specs.py qwen3.8-27b.
- Both found the same values in every run, and both calculated 2.97, 3.63 and 0x29.
- Gemma put PM2.5 and PM10 on one line in all three runs; Qwen3.8 made two lines, one for each.
- Gemma took 5 to 7 seconds; Qwen3.8 took 10 to 16, because it reasons before it answers.
For a short text like this one, Gemma is enough, and faster.
Good to know
- The schema decides the shape, not the truth. It makes
supply_min_va number andinterfaceone of four words. Whether that number is right is for your code, and for you. - The
patternkeeps the address short. In our first version, without the pattern and without the last sentence of the system message, Gemma wrote a whole sentence in the field: “0x52 for write, 0x53 for read (8-bit)”. - The check ignores the sign. For it, -40 and 40 are the same number: the minus is not part of what it looks for.
- Keep the datasheet next to the JSON. When a value looks odd in six months, you want to see where it came from.
Exercise
- Recompute the supply of the LX-7 in Python, from 3.3 and 10. Does your result match the model's?
- Write a third rule: in every measure,
minmust be smaller thanmax, andsupply_min_vmust not be larger thansupply_max_v. - Add a field
current_mato the schema, with the current from the datasheet. Which sensor has no current in its datasheet, and what does the model write for it? - Copy the text of a real datasheet, a few lines from the page with the electrical characteristics, and try it. Which values does the model calculate, and which does it copy?
