Building a smart thermostat with an ESP32 is a rite of passage for embedded makers, but most tutorials fail in the real world because they ignore sensor drift, relay short-cycling, and I2C bus capacitance. If you wire a DHT22 to an ESP32 and run it inside a sealed project box, you will chase phantom temperature spikes for weeks. This guide cuts through the noise, giving you the exact hardware picks, a robust C++ codebase with hysteresis, and the specific debugging steps for when the I2C bus inevitably locks up.
The Verdict: Which Sensor and Board for an ESP32 Thermostat?
Before buying parts, we need to make a hard decision on the temperature sensor. The ESP32's dual-core architecture and Wi-Fi radio interrupts frequently disrupt the strict microsecond timing required by 1-Wire/DHT sensors. Here is the decision matrix for choosing your sensor:
| Sensor | Protocol | Accuracy | ESP32 Compatibility | Verdict |
|---|---|---|---|---|
| DHT22 / AM2302 | Custom 1-Wire | ±0.5°C | Poor (Interrupt conflicts cause read failures) | Reject for thermostats |
| BME280 | I2C / SPI | ±1.0°C | Excellent | Overkill (Pressure unused) |
| SHT31-D | I2C | ±0.2°C | Excellent (Hardware I2C, no timing deps) | DEFAULT PICK |
The Concrete Pick: Use the ESP32-WROOM-32 DevKit V1 (30-pin) paired with the Sensirion SHT31-D I2C breakout. The SHT31 uses standard I2C, meaning the ESP32's Wi-Fi interrupts won't corrupt the temperature readings, and its ±0.2°C accuracy prevents your HVAC system from short-cycling.
Parts List & Spec Sheet
Here is the exact bill of materials. Prices reflect typical 2026 maker-market averages.
- Microcontroller: ESP32-WROOM-32 DevKit V1 (30-pin, Type-C USB). Ensure it is the 30-pin variant; 38-pin variants have different GPIO mappings. (~$6.00)
- Sensor: SHT31-D I2C Breakout (Adafruit 2857 or generic equivalent with onboard 10k pull-ups). (~$9.00)
- Actuator: 5V 1-Channel Relay Module with Optocoupler (Active LOW trigger). (~$2.50)
- Power: 5V 2A USB-C Power Supply. (~$5.00)
- Wiring: 22 AWG solid core hookup wire, 4-pin JST-SM connectors for the sensor.
Pin Mapping & Wiring Steps
The code below targets the 30-pin DevKit V1. We use the default hardware I2C pins to leverage the ESP32's internal I2C peripheral, avoiding software bit-banging.
| Component | Component Pin | ESP32 GPIO | Notes |
|---|---|---|---|
| SHT31 Sensor | VIN / VCC | 3V3 | Do not use 5V; SHT31 is a 3.3V logic part. |
| SHT31 Sensor | GND | GND | Common ground required. |
| SHT31 Sensor | SCL | GPIO 22 | Default ESP32 I2C Clock. |
| SHT31 Sensor | SDA | GPIO 21 | Default ESP32 I2C Data. |
| Relay Module | VCC | VIN (5V) | Relay coil needs 5V, not 3.3V. |
| Relay Module | GND | GND | Common ground required. |
| Relay Module | IN (Signal) | GPIO 25 | Active LOW. 3.3V output is sufficient to trigger most optocouplers. |
Wiring Sequence:
- Connect the ESP32 3V3 and GND to the SHT31 breakout.
- Route SDA to GPIO 21 and SCL to GPIO 22. Keep these wires under 30cm to avoid I2C capacitance issues.
- Connect the Relay VCC to the ESP32 VIN (5V from USB), and Relay GND to ESP32 GND.
- Connect Relay IN to GPIO 25.
- Wire the relay's Common (COM) and Normally Open (NO) terminals in series with your 24VAC HVAC control circuit (e.g., between the R and W terminals on your furnace control board).
Complete ESP32 Thermostat Code
This code implements hysteresis. A naive thermostat turns the heat on at 21.9°C and off at 22.0°C. If the sensor reads 21.95°C and fluctuates by 0.1°C, the relay will click on and off every few seconds, destroying your relay contacts and your HVAC compressor. Hysteresis creates a deadband: we turn the heat ON at 21.5°C, and OFF at 22.5°C.
Dependencies: Install the Adafruit SHT31 Library via the Arduino Library Manager before compiling.
#include <Wire.h>
#include <Adafruit_SHT31.h>
#include <WiFi.h>
// --- PIN DEFINITIONS ---
#define RELAY_PIN 25
#define I2C_SDA 21
#define I2C_SCL 22
// --- WIFI CREDENTIALS ---
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// --- THERMOSTAT LOGIC ---
const float TARGET_TEMP_C = 22.0;
const float HYSTERESIS = 0.5; // Deadband of +/- 0.5C
const unsigned long READ_INTERVAL_MS = 10000; // Read every 10 seconds
Adafruit_SHT31 sht31 = Adafruit_SHT31();
bool heaterState = false;
unsigned long lastReadTime = 0;
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("ESP32 SHT31 Thermostat Booting...");
// Configure Relay Pin
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, HIGH); // Active LOW: HIGH means relay is OFF
heaterState = false;
// Initialize I2C with explicit pins
Wire.begin(I2C_SDA, I2C_SCL);
// Initialize SHT31 at default I2C address 0x44
if (!sht31.begin(0x44)) {
Serial.println("FATAL: Couldn't find SHT31. Check wiring and I2C address.");
// Blink LED or halt safely
while (1) {
digitalWrite(RELAY_PIN, HIGH); // Ensure heat is OFF on sensor failure
delay(1000);
}
}
Serial.println("SHT31 Sensor Initialized.");
// Connect to WiFi (Non-blocking style for simple setup)
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi Connected. IP: " + WiFi.localIP().toString());
} else {
Serial.println("\nWiFi Failed. Running in offline mode.");
}
}
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - lastReadTime >= READ_INTERVAL_MS) {
lastReadTime = currentMillis;
float tempC = sht31.readTemperature();
float humidity = sht31.readHumidity();
// Error Handling: Check for NaN (Not a Number) which indicates I2C read failure
if (isnan(tempC) || isnan(humidity)) {
Serial.println("ERROR: Failed to read temperature or humidity. I2C bus locked?");
// Failsafe: Turn off HVAC if we lose sensor data to prevent runaway heating
digitalWrite(RELAY_PIN, HIGH);
heaterState = false;
return;
}
Serial.printf("Temp: %.2f C | Humidity: %.2f %% | Heater: %s\n",
tempC, humidity, heaterState ? "ON" : "OFF");
// Hysteresis Logic
if (tempC < (TARGET_TEMP_C - HYSTERESIS) && !heaterState) {
// Temp dropped below lower threshold, turn heat ON
digitalWrite(RELAY_PIN, LOW); // Active LOW
heaterState = true;
Serial.println(">> ACTION: Heater TURNED ON");
}
else if (tempC > (TARGET_TEMP_C + HYSTERESIS) && heaterState) {
// Temp rose above upper threshold, turn heat OFF
digitalWrite(RELAY_PIN, HIGH); // Active LOW
heaterState = false;
Serial.println(">> ACTION: Heater TURNED OFF");
}
}
// Small delay to yield to WiFi/RTOS tasks
delay(100);
}
Debugging: "Couldn't find SHT31" & Relay Chatter
When your build fails, it usually happens at the I2C initialization stage or during the first physical relay click. Here is the exact decision path for troubleshooting.
First 3 Things to Check When It Fails
- Voltage Mismatch: Did you wire the SHT31 VCC to 5V instead of 3.3V? The SHT31 is a 3.3V part. While some breakouts have onboard regulators, feeding 5V directly to the raw chip will fry it instantly.
- Common Ground: Do the ESP32, the SHT31, and the 5V Relay module all share the exact same GND pin? If the relay module ground is floating, the optocoupler LED won't light, and the relay won't click.
- I2C Pull-ups: Does your specific SHT31 breakout board have physical 10k pull-up resistors on the SDA/SCL lines? If you bought a bare sensor without a breakout PCB, the ESP32's internal pull-ups (usually ~45k) are too weak for reliable I2C at 100kHz.
Exact Error String: Couldn't find SHT31
If the serial monitor prints the fatal error and halts, follow this ranked cause list:
| Rank | Cause | Fix / Measurement |
|---|---|---|
| 1 | Wrong I2C Address | The SHT31 default address is 0x44. If your breakout has the ADDR pad bridged to VCC, the address shifts to 0x45. Change sht31.begin(0x44) to 0x45 in the code. |
| 2 | SDA/SCL Swapped | Verify with a multimeter in continuity mode. SDA must be GPIO 21, SCL must be GPIO 22. Swapping them will cause Wire.begin() to silently fail to find devices. |
| 3 | Wire Length / Capacitance | If your I2C wires exceed 30cm, bus capacitance exceeds 400pF, corrupting the ACK bit. Shorten the wires or drop the I2C clock speed by adding Wire.setClock(50000); after Wire.begin(). |
Relay Chatter (Clicking repeatedly)
If the relay clicks on and off every 2-3 seconds, your hysteresis deadband is too tight, or your sensor is experiencing localized heating. If the ESP32 DevKit is mounted directly behind the SHT31 sensor inside a small enclosure, the ESP32's Wi-Fi radio will heat the air around the sensor. Fix: Mount the SHT31 at least 15cm away from the ESP32 board, or increase the HYSTERESIS constant in the code from 0.5 to 1.0.
Extending or Simplifying the Build
Depending on your deployment environment, you may need to scale this project up for production or down for a quick bench test.
How to Simplify (Bench Testing)
- Drop the Wi-Fi: If you just want to test the logic on your desk, delete the
#include <WiFi.h>block and the Wi-Fi connection loop insetup(). This reduces power consumption and eliminates RF interference during initial I2C debugging. - Swap to DHT11: If you are in a pinch and only have a DHT11, you can use it for bench testing. Use the
DHT sensor libraryby Adafruit. Just be aware that the DHT11 has a 2-second mandatory read delay and ±2.0°C accuracy, meaning you must increase your hysteresis to at least2.0to prevent chatter.
How to Extend (Production / Smart Home)
- Add MQTT for Home Assistant: Replace the local serial logging with the
PubSubClientlibrary. Publish thetempCandheaterStatevariables to an MQTT broker (like Mosquitto) every 10 seconds. This allows Home Assistant to graph your HVAC runtime and temperature deltas over time. - Implement a Solid State Relay (SSR): Mechanical relays have a finite lifespan (usually ~100,000 cycles). If your system cycles 10 times an hour, the relay will fail in roughly 14 months. Swap the mechanical relay module for a Fotek SSR-25DA (ensure you buy a genuine unit, not a counterfeit with a triac that fails shorted) to achieve silent, infinite-lifespan switching for resistive loads.
- Add an OLED Display: Wire an SSD1306 128x64 I2C OLED to the same SDA/SCL bus (I2C supports multiple devices). Use the
Adafruit_SSD1306library to display the current temperature and target setpoint locally without needing to check a phone app.
For deeper hardware design guidelines regarding the ESP32's I2C pull-up requirements and GPIO current limits, refer to the official Espressif ESP32 Hardware Design Guidelines. For specific I2C timing and calibration data on the sensor, consult the Sensirion SHT31-D Datasheet.






