Skip to content
Fondazione ITS INCOM

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:

  1. a JSON schema decides the fields, and the model fills them;
  2. your code checks that every number in the answer is somewhere in the datasheet;
  3. 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.

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.

For a short text like this one, Gemma is enough, and faster.

Good to know

Exercise

All examples