Skip to content
Fondazione ITS INCOM

Examples · Internet of Things

An ESP32 sketch, written and reviewed

Qwen3-Coder writes the firmware of a classroom air sensor from seven written requirements. arduino-cli proves that it compiles; a short Python program shows you the lines behind each requirement; reading them finds what compiling cannot.

Advanced · 60 min

The idea

An air sensor in a classroom runs alone for months. The Wi-Fi drops, the broker is restarted, twenty boards share the same network. A sketch that works on your desk for ten minutes can fail in all of these, and it compiles anyway.

So the requirements are written down first, and checked one by one:

  1. the model writes the sketch from seven numbered requirements;
  2. arduino-cli compiles it for the ESP32;
  3. a short program prints, for each requirement, the lines of the sketch to read;
  4. what you find goes back to the model, and the new sketch is compiled and read again.

The board publishes on the topics of MQTT topics for a smart classroom.

What you need

A key and the openai library, as in Your first API call in Python, and arduino-cli with the ESP32 boards and three libraries:

arduino-cli core update-index --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli core install esp32:esp32 --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli lib install "DHT sensor library" "Adafruit Unified Sensor" PubSubClient

The ESP32 boards are big: when we installed version 3.3.12 the download was 1.7 GB, and they take about 6 GB on disk. Install them before the lesson, not during it. For the last part, an ESP32 with a DHT22 on GPIO 4.

The model

Qwen3-Coder 30B (qwen3-coder-30b), the model for programming. We asked the same requirements to three other models, once each, and none of their sketches compiled: gpt-oss-120b called client.setWill(), a function that PubSubClient does not have, and gemma-4-26b and qwen3.6-35b used ArduinoJson, a library we had not installed.

1. The requirements

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

Write an Arduino sketch for an ESP32 with a DHT22 sensor on GPIO 4. It must:
1. take the Wi-Fi name and password from secrets.h, as WIFI_SSID and WIFI_PASSWORD (do not write secrets.h);
2. read the temperature and the humidity every 60 seconds, with millis() and never with delay();
3. publish them as JSON, like {"temperature": 21.5, "humidity": 48.2}, on the MQTT topic school/room12/air_sensor/reading, to the broker 192.168.1.10 on port 1883, with the PubSubClient library;
4. skip a reading when the sensor fails;
5. when it connects to the broker, publish {"online": true}, retained, on school/room12/air_sensor/online, and leave a last will {"online": false}, retained, on the same topic;
6. when the Wi-Fi or the broker go away, keep reading the sensor, and try to connect again every 5 seconds without ever stopping loop();
7. use a client id made from the chip's MAC address, so that twenty boards in the same school do not throw each other off the broker.
Only the sketch.

2. The sketch

Save it as write_sketch.py, in the same folder:

import os
import re
from pathlib import Path

from openai import OpenAI

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

sketch = Path("air_sensor/air_sensor.ino")  # arduino-cli wants the sketch in a folder with its own name
findings = Path("findings.txt")

messages = [{"role": "user", "content": Path("request.txt").read_text()}]
if sketch.exists() and findings.exists():
    # Second round: the model sees its own sketch and what you found in it, and writes it again.
    messages += [
        {"role": "assistant", "content": "```cpp\n" + sketch.read_text() + "```"},
        {"role": "user", "content": findings.read_text()},
    ]

answer = client.chat.completions.create(model="qwen3-coder-30b", temperature=0, messages=messages)
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)
sketch.parent.mkdir(exist_ok=True)
sketch.write_text(code.group(1) if code else text)
print(f"{sketch}: {len(sketch.read_text().splitlines())} lines")
python3 write_sketch.py

It writes air_sensor/air_sensor.ino. The Wi-Fi name and password go in a file of their own, air_sensor/secrets.h, which never goes into git (add it to .gitignore):

#define WIFI_SSID "lab-wifi"
#define WIFI_PASSWORD "change-me"

3. Compile it

arduino-cli compile --fqbn esp32:esp32:esp32 air_sensor
Sketch uses 899636 bytes (68%) of program storage space. Maximum is 1310720 bytes.
Global variables use 47256 bytes (14%) of dynamic memory, leaving 280424 bytes for local variables. Maximum is 327680 bytes.

esp32:esp32:esp32 is the generic “ESP32 Dev Module”. The first compile takes a while; the next ones are faster. In our ten runs, all ten sketches compiled. Compiling was the easy part: only one of the ten did everything the seven requirements ask.

4. Read it, requirement by requirement

A program cannot judge whether a requirement is met, but it can show you where to look. Save it as find_lines.py:

import re
import sys
from pathlib import Path

# For each requirement of request.txt, the lines worth reading. The program finds them; you judge them.
LOOK_FOR = {
    "1. Wi-Fi name and password from secrets.h": r"secrets\.h|SSID|PASSWORD|[Pp]assword",
    "2. Every 60 seconds with millis(), never delay()": r"millis\(\)|delay\(",
    "3. The reading as JSON, on its topic": r"publish\(|printf\(",
    "4. A failed reading is skipped": r"isnan",
    "5. Online, retained, and the last will": r"\.connect\(|[Oo]nline|ONLINE",
    "6. Never stuck in a loop": r"\bwhile\b|\bfor\s*\(",
    "7. The client id from the MAC address": r"[Mm]ac|MAC|[Ee]fuse",
}

path = sys.argv[1] if len(sys.argv) > 1 else "air_sensor/air_sensor.ino"
lines = Path(path).read_text().splitlines()
for requirement, pattern in LOOK_FOR.items():
    print(requirement)
    found = [f"  {n:4}  {line.strip()}" for n, line in enumerate(lines, 1) if re.search(pattern, line)]
    print("\n".join(found) if found else "        nothing")
python3 find_lines.py

With temperature 0 the sketch is not always the same: in ten runs we got five different ones. This is the one we got most often, four times out of ten:

1. Wi-Fi name and password from secrets.h
     4  #include "secrets.h"
    43  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
2. Every 60 seconds with millis(), never delay()
    42  if (millis() - lastWifiCheck > 5000) {
    44  lastWifiCheck = millis();
    49  if (millis() - lastReconnectAttempt < 5000) {
    53  lastReconnectAttempt = millis();
    70  if (millis() - lastReading >= 60000) {
    87  lastReading = millis();
3. The reading as JSON, on its topic
    34  sprintf(clientId, "ESP32_%02X%02X%02X%02X%02X%02X",
    57  client.publish(MQTT_TOPIC_ONLINE, "{\"online\": true}", true);
    77  snprintf(jsonBuffer, sizeof(jsonBuffer),
    81  if (client.publish(MQTT_TOPIC_READING, jsonBuffer)) {
4. A failed reading is skipped
    74  if (isnan(humidity) || isnan(temperature)) {
5. Online, retained, and the last will
    12  #define MQTT_TOPIC_ONLINE "school/room12/air_sensor/online"
    55  if (client.connect(clientId, MQTT_TOPIC_ONLINE, 1, true, "false")) {
    57  client.publish(MQTT_TOPIC_ONLINE, "{\"online\": true}", true);
6. Never stuck in a loop
        nothing
7. The client id from the MAC address
    31  // Generate client ID from MAC address
    32  byte mac[6];
    33  WiFi.macAddress(mac);
    35  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);

Now read, with request.txt next to it.

Requirement 5, line 55: the last will is "false". The request asks for {"online": false}. When the board disappears, the broker sends a bare word to a dashboard that reads JSON.

Requirement 7, lines 32 and 33: the MAC is read in setup(), before the Wi-Fi starts. On paper the requirement is met: the id comes from the MAC. The catch is in the ESP32's own code. In version 3.3.12 of the core, WiFi.macAddress() gives up when the Wi-Fi interface does not exist yet, and writes nothing into the array (NetworkInterface.cpp, from line 657):

uint8_t *NetworkInterface::macAddress(uint8_t *mac) const {
  if (!mac || _esp_netif == NULL || _interface_id == ESP_NETIF_ID_PPP) {
    return NULL;
  }
  ...

So the client id is made of whatever was left in memory. ESP.getEfuseMac() reads the address from the chip itself, with or without Wi-Fi.

Requirement 6: no while, and still wrong. The finder found no loops, but the mistake is in the shape of loop():

void loop() {
  if (!client.connected()) {
    reconnect();
  } else {
    if (millis() - lastReading >= 60000) {
      float humidity = dht.readHumidity();
      float temperature = dht.readTemperature();

The sensor is read only in the else: while the broker is away, no readings at all. A line finder cannot see the shape of the code; you have to read the function.

In three of our other runs, the finder caught the most classic mistake on its own: delay(5000) inside while (!client.connected()), a loop() that stops until the broker comes back. Your findings are about your sketch: write those. For one of those three, “reconnect() stops loop() in a while loop with delay(5000)” was enough, and the second round removed the loop.

5. The second round

Write what you found in findings.txt, as you would say it to a colleague:

I checked your sketch against my request:
- 5: the last will is "false", it must be {"online": false};
- 6: the sensor is read only while the broker is connected; it must be read every 60 seconds even without Wi-Fi or broker;
- 7: WiFi.macAddress() is called before the Wi-Fi starts, and then it does not fill the array: use ESP.getEfuseMac().
Write the whole sketch again, with these fixed.

Run python3 write_sketch.py again: when it finds findings.txt, it sends the model its own sketch and your findings. In our four tries, the new sketch fixed all three points every time: {"online": false}, ESP.getEfuseMac(), and the reading outside the if. Compile it, and run find_lines.py again: every new version is read again, from the first requirement.

What no reading of the sketch shows

The sketch is not all the code that runs. PubSubClient's connect() waits for the broker's answer up to MQTT_SOCKET_TIMEOUT, 15 seconds in version 2.8, and the ESP32's network client waits up to 3 seconds to open a connection (WIFI_CLIENT_DEF_CONN_TIMEOUT_MS, in version 3.3.12 of the core). A sketch without a single delay() can still stand still in client.connect(). We read these numbers in the source code of the libraries, PubSubClient.h and NetworkClient.cpp; we did not measure them on a board. client.setSocketTimeout() makes the first one shorter.

On a board

We compiled every sketch on this page; we did not flash them. With an ESP32 on USB (on Windows the port is COM3 or similar):

arduino-cli upload -p /dev/ttyUSB0 --fqbn esp32:esp32:esp32 air_sensor
arduino-cli monitor -p /dev/ttyUSB0 --config 115200

And on the broker, to see the messages:

mosquitto_sub -h 192.168.1.10 -t 'school/room12/air_sensor/#' -v

Requirement 6 is proved only on the real thing: add a Serial.println of every reading, before the publish, then stop the broker for two minutes. The readings must keep coming on the serial monitor, and {"online": true} must come back on the broker when it restarts.

Good to know

Exercise

All examples