Overview
In this beginner project you build a temperature-controlled relay switch using an ESP32 DevKit and a DHT22 sensor. Every five seconds the ESP32 reads the current temperature. When it climbs above your chosen threshold — default 25 °C — GPIO 26 goes HIGH and energises a 5 V relay module, which can switch a fan, air conditioner, or space heater.
This project teaches the three core pillars of every embedded system: sense, decide, act. You will learn how to use the Adafruit DHT library, compare float values against a threshold, and drive a digital output to switch a real-world load. The relay is an electromagnetically operated switch that lets your low-power ESP32 safely control mains-voltage appliances without any electrical contact between the control and power circuits.
By the end you will have a fully automatic temperature controller. Adjust TEMP_THRESHOLD at the top of the sketch to set your desired cut-in temperature without touching any other code. The project is a foundation for the intermediate and advanced levels, where you add an OLED display, web dashboard, and MQTT smart-home integration.
What you will learn: DHT22 wiring with pull-up resistor, reading temperature and humidity, basic relay control, digital output logic, serial debugging.
Components
- 1× ESP32 DevKit V1 (30-pin or 38-pin) — Any standard ESP32 board
- 1× DHT22 Temperature & Humidity Sensor — More accurate than DHT11 — ±0.5 °C
- 1× 5 V Single-Channel Relay Module — Optocoupler-isolated type for safety
- 1× 10 kΩ Resistor — Pull-up for DHT22 data line
- 1× Breadboard (400-tie or larger)
- 1× Jumper Wires (male-to-male) — ~20 assorted
- 1× USB Micro-B Cable — Programming and power
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| DHT22 Pin 1 (VCC) | 3.3 V | Do NOT use 5 V — DHT22 is 3.3 V tolerant |
| DHT22 Pin 2 (DATA) | GPIO 4 | Place 10 kΩ pull-up between DATA and 3.3 V |
| DHT22 Pin 4 (GND) | GND | |
| Relay IN | GPIO 26 | Active-HIGH on most modules; check your datasheet |
| Relay VCC | VIN (5 V) | Relay coil needs 5 V — use VIN when powered via USB |
| Relay GND | GND |
Arduino Code
/*
* ESP32 Smart Thermostat — Beginner
* Switches a relay ON when temperature exceeds TEMP_THRESHOLD.
* Reads DHT22 every 5 s. Logs readings to Serial Monitor.
*
* Wiring:
* DHT22 DATA → GPIO 4 (+ 10 kΩ pull-up to 3.3 V)
* Relay IN → GPIO 26
*
* Library: "DHT sensor library" by Adafruit (Library Manager)
*/
#include <DHT.h>
#define DHT_PIN 4
#define DHT_TYPE DHT22
#define RELAY_PIN 26
#define TEMP_THRESHOLD 25.0f // Change this to your desired cut-in temp (°C)
DHT dht(DHT_PIN, DHT_TYPE);
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW); // Relay OFF at boot
dht.begin();
Serial.printf("Thermostat started — threshold: %.1f °Cn", TEMP_THRESHOLD);
}
void loop() {
delay(5000); // DHT22 min sample interval: 2 s; 5 s is safe
float h = dht.readHumidity();
float t = dht.readTemperature(); // Default: Celsius
if (isnan(h) || isnan(t)) {
Serial.println("Sensor read failed — check wiring and pull-up resistor.");
return;
}
Serial.printf("Temp: %.1f °C | Hum: %.1f %% | ", t, h);
if (t >= TEMP_THRESHOLD) {
digitalWrite(RELAY_PIN, HIGH);
Serial.println("Relay ON (cooling active)");
} else {
digitalWrite(RELAY_PIN, LOW);
Serial.println("Relay OFF");
}
}How It Works
DHT22 Protocol: The DHT22 uses a single-wire protocol requiring a 10 kΩ pull-up resistor to keep the data line HIGH when idle. Without it, readings fail. The library handles all timing automatically.
5-Second Sample Interval: delay(5000) prevents reading the sensor faster than its 2-second minimum interval, which causes "nan" errors. It also gives the ESP32 CPU a rest during which it draws less power.
isnan() Guard: If a read fails (bad wiring, noise), the library returns NaN (Not a Number). The isnan() check catches this and skips the bad sample instead of accidentally switching the relay on corrupted data.
Threshold Comparison: A simple >= comparison decides relay state. Adjust TEMP_THRESHOLD once at the top of the file. The relay activates for cooling loads (fan, AC) when above threshold, or for heating loads (heater) when below — swap HIGH/LOW in the if/else branches to invert.
Active-HIGH vs Active-LOW Relays: Most relay modules with an optocoupler activate when IN is HIGH. Some older "active-LOW" modules activate when IN is LOW. If your relay clicks on at boot or behaves inverted, swap HIGH and LOW in the sketch and set the initial state accordingly.
Applications
- Automatic fan controller for 3D printer enclosure or electronics cabinet
- Server rack over-temperature emergency shutdown
- Reptile vivarium basking-spot heat lamp controller
- Home brewing fermentation temperature regulation
- Seedling germination heating mat controller
Troubleshooting
Serial Monitor prints "Sensor read failed" on every cycle
1) Verify DHT22 is on 3.3 V, not 5 V. 2) Confirm the 10 kΩ pull-up is between DATA and 3.3 V. 3) Try a 4.7 kΩ resistor — some clone sensors prefer it. 4) Check the DHT_PIN definition matches your physical GPIO.
Relay chatters ON/OFF rapidly when temperature is near threshold
Add a 2 °C hysteresis band: turn ON at threshold + 1 and OFF at threshold - 1. This avoids the relay toggling every sample when temperature hovers at the setpoint.
Temperature reads 5–10 °C higher than actual room temperature
The ESP32 radiates heat during operation. Move the DHT22 at least 5 cm away from the board. If using Wi-Fi (later levels), the RF transceiver adds additional heat; enclosing both in a box makes this worse.
Upgrades
- Add hysteresis (2 °C dead-band) to prevent relay chattering
- Wire an LED indicator so relay state is visible without a computer
- Add a push button on GPIO 27 to force-override the relay manually
- Log readings to SD card for a week of temperature history
FAQ
You need an ESP32 DevKit, DHT22 Pin 2 (DATA), Relay IN, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Home Automation. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
At the intermediate level you add a 0.96-inch OLED display for local status readout and a Wi-Fi web dashboard so you can monitor readings and change the setpoint from any browser on your network — no app required, no cloud required, just a local IP address.
The dashboard auto-refreshes every 3 seconds using a small JavaScript fetch() loop. A number input lets you type a new threshold; the ESP32 saves it instantly to NVS flash via the Preferences library so the setting survives power cuts. The OLED shows temperature, humidity, relay state, and current threshold locally even when no browser is open.
You will learn: the ESP32 WebServer library, serving HTML/CSS/JS from PROGMEM, handling HTTP GET routes, building a JSON data endpoint, and storing persistent settings without needing an EEPROM chip. You will also learn non-blocking timing with millis() so the web server stays responsive while sensor reads happen in the background.
Components
- 1× ESP32 DevKit V1
- 1× DHT22 Sensor + 10 kΩ resistor — Same wiring as beginner level
- 1× 5 V Relay Module
- 1× 0.96" OLED Display (SSD1306, I2C) — 128×64 px, I2C address 0x3C (most common)
- 1× Jumper wires
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| DHT22 DATA | GPIO 4 | 10 kΩ pull-up to 3.3 V |
| Relay IN | GPIO 26 | |
| OLED SDA | GPIO 21 | I2C data — default ESP32 I2C bus |
| OLED SCL | GPIO 22 | I2C clock |
| OLED VCC | 3.3 V | |
| OLED GND | GND |
Arduino Code
/*
* ESP32 Smart Thermostat — Intermediate
* Adds: OLED display, Wi-Fi web dashboard, persistent setpoint (NVS)
* Libraries: DHT (Adafruit), Adafruit_SSD1306, Adafruit_GFX
* WebServer & Preferences (ESP32 built-in)
*/
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <DHT.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// ── CONFIGURE THESE ──────────────────────────────────────────────
const char* SSID = "YOUR_WIFI_SSID";
const char* PASSWORD = "YOUR_WIFI_PASSWORD";
// ────────────────────────────────────────────────────────────────
#define DHT_PIN 4
#define DHT_TYPE DHT22
#define RELAY_PIN 26
DHT dht(DHT_PIN, DHT_TYPE);
WebServer server(80);
Preferences prefs;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
float g_temp = 0, g_hum = 0, g_threshold = 25.0f;
bool g_relay = false;
/* ── Dashboard HTML (stored in flash) ─────────────────────────── */
const char HTML[] PROGMEM = R"HTML(<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 Thermostat</title>
<style>
body{font-family:sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:20px}
h1{color:#60a5fa;margin-bottom:20px}
.card{background:#1e293b;border-radius:12px;padding:24px;margin:12px 0;max-width:420px}
.big{font-size:3.5rem;font-weight:700;color:#60a5fa;line-height:1}
.sub{color:#94a3b8;margin-top:6px}
.on{color:#4ade80}.off{color:#f87171}
input[type=number]{background:#334155;border:1px solid #475569;color:#e2e8f0;
padding:8px 12px;border-radius:8px;width:90px;font-size:1rem}
button{background:#2563eb;color:#fff;border:none;padding:10px 20px;
border-radius:8px;cursor:pointer;font-size:1rem;margin-left:10px}
button:hover{background:#1d4ed8}
</style></head><body>
<h1>ESP32 Thermostat</h1>
<div class="card">
<div class="big" id="t">--</div>
<div class="sub" id="h">Humidity: --%</div>
<div class="sub" id="r" style="margin-top:10px">Relay: --</div>
</div>
<div class="card">
<label>Setpoint (°C):
<input type="number" id="sp" step="0.5" min="5" max="45">
</label>
<button onclick="save()">Save</button>
</div>
<script>
function refresh(){
fetch("/data").then(r=>r.json()).then(d=>{
document.getElementById("t").textContent=d.temp.toFixed(1)+"°C";
document.getElementById("h").textContent="Humidity: "+d.hum.toFixed(1)+"%";
const r=document.getElementById("r");
r.textContent="Relay: "+(d.relay?"ON — cooling active":"OFF");
r.className=d.relay?"on":"off";
document.getElementById("sp").value=d.threshold;
});
}
function save(){
const v=document.getElementById("sp").value;
fetch("/set?threshold="+v).then(refresh);
}
setInterval(refresh,3000);refresh();
</script></body></html>)HTML";
void updateOLED() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2); oled.setCursor(0,0);
oled.printf("%.1fC", g_temp);
oled.setTextSize(1); oled.setCursor(0,20);
oled.printf("Hum: %.1f%%", g_hum);
oled.setCursor(0,32);
oled.print(g_relay ? "RELAY: ON " : "RELAY: OFF");
oled.setCursor(0,44);
oled.printf("Set: %.1fC", g_threshold);
oled.display();
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT); digitalWrite(RELAY_PIN, LOW);
dht.begin();
Wire.begin();
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.clearDisplay(); oled.display();
prefs.begin("thermo", false);
g_threshold = prefs.getFloat("sp", 25.0f);
WiFi.begin(SSID, PASSWORD);
Serial.print("Wi-Fi connecting");
while (WiFi.status() != WL_CONNECTED) { delay(400); Serial.print("."); }
Serial.printf("nDashboard: http://%sn", WiFi.localIP().toString().c_str());
server.on("/", [](){
server.send_P(200, "text/html", HTML);
});
server.on("/data", [](){
String j = "{"temp":" + String(g_temp,1) +
","hum":" + String(g_hum,1) +
","relay":" + (g_relay?"true":"false") +
","threshold":" + String(g_threshold,1) + "}";
server.send(200, "application/json", j);
});
server.on("/set", [](){
if (server.hasArg("threshold")) {
g_threshold = server.arg("threshold").toFloat();
prefs.putFloat("sp", g_threshold);
}
server.send(200, "text/plain", "OK");
});
server.begin();
}
void loop() {
server.handleClient();
static unsigned long last = 0;
if (millis() - last >= 5000) {
last = millis();
float h = dht.readHumidity(), t = dht.readTemperature();
if (!isnan(h) && !isnan(t)) {
g_temp = t; g_hum = h;
g_relay = (t >= g_threshold);
digitalWrite(RELAY_PIN, g_relay ? HIGH : LOW);
updateOLED();
}
}
}How It Works
Non-blocking Loop: server.handleClient() is called on every loop iteration to process incoming HTTP requests. Sensor reading uses a millis() timer instead of delay() so the web server never blocks mid-request.
PROGMEM HTML: The HTML/CSS/JS dashboard is stored in flash memory using the PROGMEM keyword, not RAM. With 520 KB RAM on the ESP32 this matters less than on Arduino, but the pattern keeps heap free for the web server buffers.
JSON Data Endpoint: GET /data returns a small JSON string. The browser JavaScript calls this every 3 seconds and updates the DOM without reloading the page. This pattern (separate HTML and API endpoints) is the same approach used by full-stack web applications.
Persistent Setpoint: Preferences wraps ESP32's built-in NVS (Non-Volatile Storage) flash. putFloat("sp", value) writes in under 1 ms with no wear concern for this use case (100,000+ write cycles rated). The threshold survives firmware resets, power cuts, and watchdog reboots.
OLED Update: updateOLED() clears the display buffer, renders four lines of text at two different font sizes, then calls display() to push the buffer to the screen via I2C. Calling display() is the expensive step; always buffer all drawing before committing.
Applications
- Field trial with visible OLED feedback
- Manual override for maintenance or testing
- Calibrated setup for daily use
Troubleshooting
Cannot reach the dashboard in a browser
1) Confirm SSID and PASSWORD are correct. 2) Check Serial Monitor for the assigned IP. 3) Ensure your computer is on the same network (2.4 GHz — ESP32 does not support 5 GHz). 4) Temporarily disable Windows Firewall to rule out port blocking.
OLED shows nothing or garbled output
Run an I2C scanner sketch to find your display's address. Some SSD1306 modules use 0x3D instead of 0x3C. Change the address in oled.begin(SSD1306_SWITCHCAPVCC, 0x3D).
Setpoint resets to 25 °C after every restart
The Preferences namespace must match between putFloat and getFloat. Both must use "thermo" (the first argument to prefs.begin()). If you cloned from beginner and renamed the namespace, the stored key is not found and falls back to 25.0.
Upgrades
- Assign a static IP or use mDNS (WiFi.setHostname("thermostat")) to access via http://thermostat.local
- Add a 7-day temperature history graph using Chart.js fetched from a /history endpoint
- Add a second relay for heating so the board can control both cooling and heating automatically
FAQ
You need an ESP32 DevKit, DHT22 Pin 2 (DATA), Relay IN, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Home Automation. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.
Overview
The advanced level turns your thermostat into a proper smart-home device: it publishes temperature, humidity, and relay state to an MQTT broker every 30 seconds; subscribes to a command topic so automations in Home Assistant, Node-RED, or Apple HomeKit (via Homebridge) can change the setpoint remotely; announces itself as unavailable via a Last Will Testament if it drops offline; and supports over-the-air firmware updates so you never need to physically access the device again.
You will integrate with Home Assistant using the standard MQTT sensor platform, and optionally enable MQTT Auto-Discovery so the thermostat appears in Home Assistant automatically without any YAML configuration. The sketch also adds an NTP time client so log messages carry real timestamps for debugging.
What you will learn: MQTT publish/subscribe patterns, Last Will Testament (LWT), MQTT retain flags, ArduinoOTA for wireless firmware updates, NTP time synchronisation, JSON payload design, Home Assistant integration.
Components
- 1× ESP32 DevKit V1
- 1× DHT22 + 10 kΩ resistor
- 1× 5 V Relay Module
- 1× 0.96" OLED (SSD1306) — Optional — shows MQTT connection status locally
- 1× MQTT Broker — Mosquitto on Raspberry Pi, or cloud broker (HiveMQ free tier)
Wiring
| Component Pin | ESP32 Pin | Notes |
|---|---|---|
| DHT22 DATA | GPIO 4 | 10 kΩ pull-up to 3.3 V |
| Relay IN | GPIO 26 | |
| OLED SDA / SCL | GPIO 21 / 22 | Optional |
Arduino Code
/*
* ESP32 Smart Thermostat — Advanced
* MQTT publish/subscribe + ArduinoOTA + NTP timestamps
* Libraries: DHT (Adafruit), PubSubClient (Nick O'Leary), ArduinoOTA (built-in)
*/
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoOTA.h>
#include <Preferences.h>
#include <DHT.h>
#include <time.h>
const char* SSID = "YOUR_WIFI_SSID";
const char* PASSWORD = "YOUR_WIFI_PASSWORD";
const char* BROKER = "192.168.1.100"; // Your broker IP
const int MQTT_PORT = 1883;
const char* DEV_ID = "thermostat-esp32-01";
#define DHT_PIN 4
#define DHT_TYPE DHT22
#define RELAY_PIN 26
#define PUB_MS 30000UL // Publish interval
// Topic helpers
String T(const char* sub) { return String("esp32engine/thermostat/") + DEV_ID + "/" + sub; }
DHT dht(DHT_PIN, DHT_TYPE);
WiFiClient net;
PubSubClient mqtt(net);
Preferences prefs;
float g_temp=0,g_hum=0,g_threshold=25.0f;
bool g_relay=false;
void onMqttMessage(char* topic, byte* pl, unsigned int len) {
String msg; for(unsigned int i=0;i<len;i++) msg+=(char)pl[i];
if(String(topic)==T("set/threshold")){
float v=msg.toFloat();
if(v>0&&v<60){ g_threshold=v; prefs.putFloat("sp",v); }
}
if(String(topic)==T("set/relay")){
// Manual override: "ON" or "OFF"
bool on=(msg=="ON"||msg=="1");
g_relay=on;
digitalWrite(RELAY_PIN,on?HIGH:LOW);
}
}
void mqttConnect(){
while(!mqtt.connected()){
if(mqtt.connect(DEV_ID,"","",T("avail").c_str(),0,true,"offline")){
mqtt.publish(T("avail").c_str(),"online",true);
mqtt.subscribe(T("set/threshold").c_str());
mqtt.subscribe(T("set/relay").c_str());
Serial.println("MQTT connected");
} else {
Serial.printf("MQTT rc=%d retry in 5sn",mqtt.state());
delay(5000);
}
}
}
void publishState(){
char ts[20]="";
struct tm ti; if(getLocalTime(&ti)) strftime(ts,sizeof(ts),"%H:%M:%S",&ti);
String j = "{"temperature":" + String(g_temp,2) +
","humidity":" + String(g_hum,2) +
","relay":" + (g_relay?"true":"false") +
","threshold":" + String(g_threshold,1) +
","time":"" + ts + ""}";
mqtt.publish(T("state").c_str(), j.c_str(), true);
}
void setup(){
Serial.begin(115200);
pinMode(RELAY_PIN,OUTPUT); digitalWrite(RELAY_PIN,LOW);
dht.begin();
prefs.begin("thermo",false);
g_threshold=prefs.getFloat("sp",25.0f);
WiFi.begin(SSID,PASSWORD);
while(WiFi.status()!=WL_CONNECTED) delay(400);
Serial.printf("IP: %sn",WiFi.localIP().toString().c_str());
configTime(0,0,"pool.ntp.org"); // UTC — adjust offset for your timezone
ArduinoOTA.setHostname(DEV_ID);
ArduinoOTA.begin();
mqtt.setServer(BROKER,MQTT_PORT);
mqtt.setCallback(onMqttMessage);
mqtt.setKeepAlive(60);
mqttConnect();
}
void loop(){
ArduinoOTA.handle();
if(!mqtt.connected()) mqttConnect();
mqtt.loop();
static unsigned long last=0;
if(millis()-last>=PUB_MS){
last=millis();
float h=dht.readHumidity(),t=dht.readTemperature();
if(!isnan(h)&&!isnan(t)){
g_temp=t; g_hum=h;
bool shouldBeOn=(t>=g_threshold);
if(shouldBeOn!=g_relay){
g_relay=shouldBeOn;
digitalWrite(RELAY_PIN,g_relay?HIGH:LOW);
}
publishState();
}
}
}How It Works
MQTT Topic Hierarchy: All topics follow the pattern esp32engine/thermostat/{device-id}/{sub-topic}. Using a device ID in the path allows multiple thermostats on the same broker without topic collisions. The /state topic carries the full JSON payload; /avail carries "online"/"offline".
Last Will Testament (LWT): The MQTT CONNECT packet includes a LWT message: if the TCP connection drops unexpectedly (power cut, network loss), the broker automatically publishes "offline" to the /avail topic. Home Assistant uses this to mark the device as unavailable on its dashboard.
Retained Messages: mqtt.publish(..., true) sets the RETAIN flag. The broker caches the last retained message per topic and sends it immediately to any new subscriber. This means Home Assistant receives current temperature the instant it connects — even if the ESP32 last published 25 minutes ago.
ArduinoOTA: ArduinoOTA.begin() starts an mDNS service and a TCP listener for firmware updates. Once running, the device appears as a network port in Arduino IDE. Select it and upload as normal. The bootloader handles the swap safely — on failure it rolls back to the previous firmware.
NTP Timestamps: configTime() syncs the ESP32 real-time clock with pool.ntp.org. getLocalTime() fills a tm struct for formatting. Published JSON payloads include a "time" field, which makes Grafana and InfluxDB time-series graphs accurate even across broker restarts.
Applications
- Remote monitoring from phone or laptop
- Automated alerts when limits are crossed
- Long-term trend logging for optimization
Troubleshooting
MQTT connection fails with state -2
rc=-2 means the broker was not found on the network. Verify BROKER IP and that Mosquitto is running: sudo systemctl status mosquitto. Test from another device: mosquitto_pub -h BROKER_IP -t test -m hello
OTA upload fails — port not visible in Arduino IDE
ESP32 must be powered and running the loop (not in a crash loop). Ensure your PC and the ESP32 are on the same subnet. Temporarily disable OS firewall. Check that ArduinoOTA.handle() is called on every loop iteration.
Home Assistant shows "unavailable" even though device is running
Check the LWT topic string matches what HA is subscribed to. If the broker was restarted, the ESP32 may not have reconnected yet — restart the ESP32 or wait for the MQTT keep-alive timeout to trigger reconnection.
Upgrades
- Add MQTT Auto-Discovery: publish a config payload to homeassistant/sensor/{id}/config and HA adds the device automatically
- Replace simple ON/OFF with a PID controller for smoother temperature regulation with no overshoot
- Add multi-zone support: run multiple ESP32 thermostats reporting to one broker, view all zones in one HA dashboard
FAQ
You need an ESP32 DevKit, DHT22 Pin 2 (DATA), Relay IN, a breadboard, jumper wires, and a USB cable for power and programming.
Only the Advanced stage uses Wi-Fi. Beginner and Intermediate builds run offline on the ESP32 with USB power.
Start with Beginner if you are new to Home Automation. Use Intermediate for OLED feedback and Advanced for dashboards or connected monitoring.