Ask ChatGPT and Claude for room tips with a sensor and an LCD

A DHT11 measures the temperature and humidity in your room. Press one button to ask ChatGPT, or the other to ask Claude, what to do about it, and the tip appears on the LCD. Same readings, same question, two different...
  • Level:Intermediate
  • Duration: 1 h
  • Ages 12+
  • Platform: ESP32

Tools for this lesson

Helpful for this build. Open a tool to see tips and more projects that use it.

Ask ChatGPT and Claude for room tips with a sensor and an LCD – EEMENTOR STEM™ lesson

ChatGPT and Claude are the two best-known AI chatbots. Most people type questions into them, but they can't see or feel anything about the world around you. In this lesson you give them senses. A DHT11 sensor measures the temperature and humidity in your room, and the ESP32 shows the readings on a 1602 LCD. Press the left button and the ESP32 sends the readings to ChatGPT; press the right one and it asks Claude. A few seconds later a tip appears on the LCD, such as "It's a bit dry in here. A bowl of water near the radiator will help." Ask both and see how two AIs from two different companies answer the same question.

What you'll learn

  • How a board talks to ChatGPT and Claude through their APIs
  • How to turn sensor readings into a question for an AI
  • How a system prompt keeps answers short enough for a tiny screen
  • How the two APIs are alike and how they differ

What you need

  • ESP32 board, expansion board and USB cable (ESP32 IoT Starter Kit)
  • 1602 LCD display, 10 kΩ potentiometer and 1 resistor, 220 Ω (ESP32 IoT Starter Kit)
  • DHT11 temperature and humidity module (ESP32 IoT Starter Kit)
  • 2 push buttons
  • An OpenAI API key (for ChatGPT) and a Claude API key, from an adult's accounts
  • A 2.4 GHz Wi-Fi network
  • Breadboard and jumper wires (ESP32 IoT Starter Kit)

Wiring

Breadboard wiring diagram: Ask ChatGPT and Claude for room tips with a sensor and an LCD
Breadboard view. Check each connection against the wiring table below.

Build it step by step

Plug in all the parts first, then add the wires in this order. In each picture the new wires are bright and the ones from earlier steps are faded. Click a picture to see it larger.

Step 1 of 5: Power rails: 5V and GND from the ESP32
Step 1. Power rails: 5V and GND from the ESP32
Step 2 of 5: 1602 LCD: RS, E and D4–D7 to the ESP32, plus power, contrast knob and backlight
Step 2. 1602 LCD: RS, E and D4–D7 to the ESP32, plus power, contrast knob and backlight
Step 3 of 5: DHT11: S → GPIO 27, + → 3V3, − → − rail
Step 3. DHT11: S → GPIO 27, + → 3V3, − → − rail
Step 4 of 5: Button "Ask ChatGPT": GPIO 32 and the − rail
Step 4. Button "Ask ChatGPT": GPIO 32 and the − rail
Step 5 of 5: Button "Ask Claude": GPIO 33 and the − rail
Step 5. Button "Ask Claude": GPIO 33 and the − rail
From To
LCD VSS, RW and K GND
LCD VDD 5V
LCD V0 Middle pin (W) of the potentiometer
LCD RS ESP32 GPIO 19
LCD E ESP32 GPIO 23
LCD D4, D5, D6, D7 ESP32 GPIO 18, 17, 16, 4
LCD D0 to D3 Not connected
LCD A (backlight +) 220 Ω resistor, then 5V
Potentiometer outer pins 5V and GND
DHT11 S (data) ESP32 GPIO 27
DHT11 + ESP32 3V3
DHT11 − GND
"Ask ChatGPT" button ESP32 GPIO 32 and GND
"Ask Claude" button ESP32 GPIO 33 and GND

The LCD is wired as in the AI chat buddy. It runs on 5V but only listens to the ESP32, so its 5V never reaches the ESP32's pins. The DHT11 runs on 3V3 instead, because it talks back to the ESP32.

Put the ChatGPT button (GPIO 32) on the left and the Claude button (GPIO 33) on the right, to match the arrows on the LCD.

Sensor modules from different makers can label and order their pins differently. Always follow the labels printed on your module.

Get the two API keys

ChatGPT is made by OpenAI and Claude by Anthropic. You know them as chat apps, but a board can reach the same AI models through each company's API. Both APIs are paid services that charge a tiny amount per question, and both accounts must belong to an adult (18+), so ask a parent or teacher to do these steps with you.

  1. For ChatGPT: create an account at platform.openai.com, add a small amount of credit under Billing and set a low monthly budget in the settings. Under API keys, create a new secret key and copy it. It starts with sk-.
  2. For Claude: create an account at console.anthropic.com, add a small amount of credit and set a low monthly spend limit under Billing. Under API keys, create a key and copy it. It starts with sk-ant-.
  3. An API key works like a password and a credit card in one. Never share it, never post your sketch online with a key in it, and delete the keys when the project is finished.

Only have one key? The sketch still works: just use that AI. The other one answers "Please check my API key."

The sketch

In the Arduino IDE, open Library Manager and install the ArduinoJson library by Benoit Blanchon and the DHT sensor library by Adafruit. LiquidCrystal comes with the Arduino IDE.

Replace YOUR_WIFI_NAME and YOUR_WIFI_PASSWORD with your own network details (2.4 GHz only).

Paste your two keys into OPENAI_KEY and CLAUDE_KEY.

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <LiquidCrystal.h>
#include <DHT.h>

const char* WIFI_NAME = "YOUR_WIFI_NAME";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";

const char* OPENAI_KEY = "YOUR_OPENAI_API_KEY";      // for ChatGPT: keep it secret!
const char* OPENAI_MODEL = "gpt-6-luna";
const char* CLAUDE_KEY = "YOUR_ANTHROPIC_API_KEY";   // for Claude: keep it secret!
const char* CLAUDE_MODEL = "claude-opus-5-5";
const char* SYSTEM = "You are a friendly room coach in a child's bedroom. "
  "You get readings from sensors in the room. Give one practical tip to make the room more comfortable "
  "for sleeping, reading or homework, based only on those readings. "
  "Your answer appears on a tiny 16x2 screen: one or two short sentences, at most 140 characters. "
  "Use only plain English letters, numbers and simple punctuation: no emoji, no symbols.";

const int DHT_PIN = 27, GPT_BUTTON = 32, CLAUDE_BUTTON = 33;
const unsigned long SHOW_MS = 30000;              // how long a tip stays on the LCD

LiquidCrystal lcd(19, 23, 18, 17, 16, 4);         // RS, E, D4, D5, D6, D7
DHT dht(DHT_PIN, DHT11);
unsigned long showUntil = 0, nextReading = 0;

String pageText = "";
int pageStart = 0;
unsigned long nextPage = 0;

// Only plain letters fit on the LCD: swap curly quotes and long dashes for plain ones,
// and replace any other symbol with one space
String plain(const String& s) {
  String out = "";
  for (unsigned int i = 0; i < s.length(); i++) {
    unsigned char ch = s[i];
    if (ch >= 32 && ch < 127) out += (char)ch;
    else if (ch == 0xE2 && i + 2 < s.length() && (unsigned char)s[i + 1] == 0x80) {
      unsigned char c = s[i + 2];
      out += (c == 0x98 || c == 0x99) ? '\'' : (c == 0x9C || c == 0x9D) ? '"' : (c == 0x93 || c == 0x94) ? '-' : ' ';
      i += 2;
    }
    else if (ch < 0x80 || ch >= 0xC0) out += ' ';   // the extra bytes of a symbol (0x80-0xBF) are skipped
  }
  return out;
}

// One LCD line of up to 16 characters starting at 'from', breaking at a space
int fitLine(const String& s, int from, String& line) {
  int end = min((int)s.length(), from + 16);
  if (end < (int)s.length()) {
    int space = s.lastIndexOf(' ', end);
    if (space > from) end = space;
  }
  line = s.substring(from, end);
  while (end < (int)s.length() && s[end] == ' ') end++;
  return end;
}

void showPage() {
  String one, two;
  int next = fitLine(pageText, pageStart, one);
  int after = fitLine(pageText, next, two);
  lcd.clear();
  lcd.print(one);
  lcd.setCursor(0, 1);
  lcd.print(two);
  pageStart = after >= (int)pageText.length() ? 0 : after;   // start again at the end
}

void showOnLcd(const String& text) {
  pageText = plain(text);
  pageStart = 0;
  nextPage = 0;
}

void turnPages() {
  if (pageText.length() == 0 || millis() < nextPage) return;
  nextPage = millis() + 3500;
  showPage();
}

bool aiOk = false;                               // did the last question get a real answer?

String errorText(const char* who, int status) {
  if (status == 401) return "Please check my API key.";
  if (status == 429) return "Too many questions, or no credit left. Wait a minute.";
  if (status == 503 || status == 529) return String(who) + " is busy. Try again in a minute.";
  return String("Sorry, I could not reach ") + who + ".";
}

// Ask ChatGPT through OpenAI's Responses API
String askChatGPT(const char* system, const String& question) {
  aiOk = false;
  JsonDocument request;
  request["model"] = OPENAI_MODEL;
  request["instructions"] = system;              // the system prompt
  request["input"] = question;                   // one question from the user
  request["max_output_tokens"] = 300;            // short answers only
  request["reasoning"]["effort"] = "none";       // answer straight away, no thinking
  request["store"] = false;                      // don't keep the conversation on the server
  String body;
  serializeJson(request, body);

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(30000);
  http.begin(client, "https://api.openai.com/v1/responses");
  http.addHeader("Content-Type", "application/json");
  http.addHeader("Authorization", String("Bearer ") + OPENAI_KEY);
  int status = http.POST(body);
  String response = http.getString();
  http.end();
  if (status != 200) {
    Serial.printf("ChatGPT error %d: %s\n", status, response.c_str());
    return errorText("ChatGPT", status);
  }

  JsonDocument filter;                           // keep only what we need from the answer
  filter["output"][0]["type"] = true;
  filter["output"][0]["content"][0]["type"] = true;
  filter["output"][0]["content"][0]["text"] = true;
  JsonDocument answer;
  if (deserializeJson(answer, response, DeserializationOption::Filter(filter))) return "Sorry, I got a strange answer.";
  String text = "";
  for (JsonObject item : answer["output"].as<JsonArray>()) {
    if (item["type"] != "message") continue;     // skip other kinds of item
    for (JsonObject part : item["content"].as<JsonArray>()) {
      if (part["type"] == "refusal") return "Sorry, I can't help with that one.";
      if (part["type"] == "output_text") text += part["text"].as<String>();
    }
  }
  text.trim();
  aiOk = text.length() > 0;
  return aiOk ? text : "Sorry, I got an empty answer.";
}

// Ask Claude through Anthropic's Messages API
String askClaude(const char* system, const String& question) {
  aiOk = false;
  JsonDocument request;
  request["model"] = CLAUDE_MODEL;
  request["max_tokens"] = 2000;                  // room for its thinking and a short answer
  request["output_config"]["effort"] = "low";    // quick, simple answers
  request["fallbacks"] = "default";              // if this model declines, try another one
  request["system"] = system;                    // the system prompt
  JsonObject m = request["messages"].to<JsonArray>().add<JsonObject>();
  m["role"] = "user";                            // one question from the user
  m["content"] = question;
  String body;
  serializeJson(request, body);

  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.setTimeout(60000);                        // thinking can take a few seconds
  http.begin(client, "https://api.anthropic.com/v1/messages");
  http.addHeader("content-type", "application/json");
  http.addHeader("x-api-key", CLAUDE_KEY);
  http.addHeader("anthropic-version", "2023-06-01");
  http.addHeader("anthropic-beta", "server-side-fallback-2026-07-01");
  int status = http.POST(body);
  String response = http.getString();
  http.end();
  if (status != 200) {
    Serial.printf("Claude error %d: %s\n", status, response.c_str());
    return errorText("Claude", status);
  }

  JsonDocument filter;
  filter["stop_reason"] = true;
  filter["content"][0]["type"] = true;
  filter["content"][0]["text"] = true;
  JsonDocument answer;
  if (deserializeJson(answer, response, DeserializationOption::Filter(filter))) return "Sorry, I got a strange answer.";
  if (answer["stop_reason"] == "refusal") return "Sorry, I can't help with that one.";
  String text = "";
  for (JsonObject block : answer["content"].as<JsonArray>()) {
    if (block["type"] == "text") text += block["text"].as<String>();   // skip the other kinds of block
  }
  text.trim();
  aiOk = text.length() > 0;
  return aiOk ? text : "Sorry, I got an empty answer.";
}

void showReadings() {
  float t = dht.readTemperature(), h = dht.readHumidity();
  lcd.clear();
  if (isnan(t) || isnan(h)) lcd.print("No DHT11 reading");
  else lcd.printf("%.1f%cC  %.0f%% hum", t, 223, h);   // 223 is the degree sign on the LCD
  lcd.setCursor(0, 1);
  lcd.print("<ChatGPT Claude>");
}

void ask(bool claude, int button) {
  while (digitalRead(button) == LOW) delay(10);   // wait until the button is let go
  float t = dht.readTemperature(), h = dht.readHumidity();
  if (isnan(t) || isnan(h)) {
    showOnLcd("I can't read the DHT11. Check its wires.");
    showUntil = millis() + 8000;
    return;
  }
  String who = claude ? "Claude" : "ChatGPT";
  lcd.clear();
  lcd.print("Asking " + who);
  lcd.setCursor(0, 1);
  lcd.print("Please wait...");

  String readings = "Room temperature: " + String(t, 1) + " C. Relative humidity: " + String(h, 0) + " %.";
  Serial.println(readings);
  unsigned long start = millis();
  String tip = claude ? askClaude(SYSTEM, readings) : askChatGPT(SYSTEM, readings);
  Serial.printf("%s (%lu ms): %s\n", who.c_str(), millis() - start, tip.c_str());
  showOnLcd(who + ": " + tip);
  showUntil = millis() + SHOW_MS;
}

void setup() {
  Serial.begin(115200);
  pinMode(GPT_BUTTON, INPUT_PULLUP);
  pinMode(CLAUDE_BUTTON, INPUT_PULLUP);
  dht.begin();
  lcd.begin(16, 2);
  lcd.print("Connecting...");
  WiFi.begin(WIFI_NAME, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.println("Connected. Press a button to ask for a tip.");
}

void loop() {
  if (digitalRead(GPT_BUTTON) == LOW) ask(false, GPT_BUTTON);
  else if (digitalRead(CLAUDE_BUTTON) == LOW) ask(true, CLAUDE_BUTTON);

  if (pageText.length() > 0 && millis() > showUntil) {   // the tip has been shown long enough
    pageText = "";
    nextReading = 0;
  }
  if (pageText.length() > 0) turnPages();
  else if (millis() >= nextReading) {                    // no tip: show the readings
    showReadings();
    nextReading = millis() + 3000;
  }
}

client.setInsecure() keeps the connection encrypted but skips checking the server's certificate. That keeps the sketch short and is fine for a learning project. For a device you leave running for years, load the server's root certificate with client.setCACert() instead.

Ask for tips

  1. Upload the sketch. The LCD says "Connecting...", then shows the temperature and humidity, such as 22.5°C 48% hum, and <ChatGPT Claude> under it. If the text is hard to read, turn the potentiometer.
  2. Press the left button. The LCD says "Asking ChatGPT", and a few seconds later the tip scrolls past, two lines at a time. The Serial Monitor at 115200 baud shows the readings, the whole tip and how many milliseconds the answer took.
  3. Press the right button to ask Claude. Compare the two tips: do they agree? Which one is more useful?
  4. Breathe gently on the DHT11 for a few seconds. The humidity goes up. Ask again and see if the tips change.

What you should see

Four LCD screens: the readings 22.5°C 48% hum with <ChatGPT Claude>, Asking ChatGPT, and two pages of the tip, plus the Serial Monitor at 115200 baud with a ChatGPT and a Claude tip

How it works

The round trip from DHT11 to ESP32, a text question over HTTPS to the AI, JSON back through a filter to the LCD, and the OpenAI Responses and Anthropic Messages request shapes side by side

Both AIs are large language models that run in big data centres. The ESP32 only sends them text and gets text back. askChatGPT() and askClaude() do the same job, but each company's API wants the request in its own shape:

ChatGPT (OpenAI Responses API) Claude (Anthropic Messages API)
Address api.openai.com/v1/responses api.anthropic.com/v1/messages
Key goes in Authorization: Bearer sk-... x-api-key: sk-ant-...
System prompt instructions system
The question input messages, with the role user
Length and speed max_output_tokens: 300, reasoning.effort: "none" max_tokens: 2000, effort: "low"
The answer is in output → message → output_text content → text blocks

The ArduinoJson library builds the request and reads the answer. The answer contains much more than the text, so a filter keeps only the parts the sketch needs and saves memory. If an AI refuses a question, both functions say so politely, and if something goes wrong the Serial Monitor shows the error the API sent back.

Notice the split of jobs. The facts come from the sensor: the AI never sees your room, only the sentence the ESP32 writes, such as "Room temperature: 22.5 C. Relative humidity: 48 %." The words come from the AI. The system prompt says "based only on those readings" so neither AI makes up things the sensor didn't measure, and "at most 140 characters" so the tip fits on a few LCD pages.

Each question costs less than a cent. The answer usually takes a few seconds, sometimes up to 15: the request travels to a data centre, the model writes the answer word by word, and the whole answer travels back.

Try this next

  • Press both buttons one after the other ten times and count which AI gave the tips you liked best. The last lesson of this set turns that into a fair blind test.
  • Change the system prompt: make the coach a grumpy pirate, or ask for tips for a reptile tank instead of a bedroom.
  • Add a third button that asks both AIs and shows both tips one after the other.

Troubleshooting

  • "Please check my API key": copy the key again. The OpenAI key starts with sk-, the Claude key with sk-ant-; make sure it is in the right line and has no spaces.
  • "Too many questions, or no credit left": wait a minute, and check that the account has credit and the monthly limit isn't reached.
  • "Sorry, I could not reach…": open the Serial Monitor at 115200 baud: it prints the error the API sent back. A message about the model means the model name has changed: look up a current one on the company's models page and put it in OPENAI_MODEL or CLAUDE_MODEL.
  • "No DHT11 reading": check that S goes to the right GPIO and + to 3V3, not 5V.
  • The LCD lights up but shows nothing, or only black boxes: turn the potentiometer slowly until the text appears.
  • Nothing happens when you press a button: the button must connect its GPIO to GND. Turn it a quarter turn on the breadboard: two of its legs are always connected.
  • It never connects to Wi-Fi: check the network name and password (both are case-sensitive) and make sure the network is 2.4 GHz.

Build it yourself

Everything used in this lesson comes in one kit, with parts tested by our engineers.

ESP32 IoT Starter Kit | Arduino IDE Compatible | Free Tutorials $57.00

Shop the kit