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:
- the model writes the sketch from seven numbered requirements;
arduino-clicompiles it for the ESP32;- a short program prints, for each requirement, the lines of the sketch to read;
- 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
- Compiling proves the sketch is C++, not that it does what you asked. The compiler stopped
client.setWill(), a function that does not exist. It said nothing about a last will in the wrong format. - Numbered requirements are a checklist. Without them there is nothing to check against. When we asked only for “a sensor that publishes every minute”, both sketches we got froze in a
whileloop until the broker came back. - Read the libraries you depend on. They are on your disk:
arduino-cli lib listsays which versions, and the source is in the folder they were installed in. - The key never goes into the board. The program that calls the model runs on your computer; the ESP32 only talks to your broker.
Exercise
- Check the payload against the schema of MQTT topics for a smart classroom: does
{"temperature": 21.5, "humidity": 48.2}pass? And the last will? - Add an eighth requirement: the CO2 from an SCD41 on I2C, in the same JSON. Run both rounds again.
- Write a check for requirement 6 that a line finder can do: no
client.publishand nodht.readinside anelseofclient.connected(). Is it worth it, or is reading faster? - Ask for the sketch with
temperature=0.7, three times. How many requirements does each version meet?
