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.
⚠️ Mains Voltage Safety Warning: The 5V relay module is rated for 10A at 120VAC, but HVAC control circuits (furnaces, AC handlers) typically use 24VAC. Never wire a cheap hobby relay directly to 120V/240V mains for HVAC control. Use the ESP32 relay to switch the 24VAC thermostat control wires (R and W/Y), or use it to trigger a properly rated 24VAC HVAC contactor. Always de-energize and verify dead with a multimeter before touching HVAC wiring.

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:

  1. Connect the ESP32 3V3 and GND to the SHT31 breakout.
  2. Route SDA to GPIO 21 and SCL to GPIO 22. Keep these wires under 30cm to avoid I2C capacitance issues.
  3. Connect the Relay VCC to the ESP32 VIN (5V from USB), and Relay GND to ESP32 GND.
  4. Connect Relay IN to GPIO 25.
  5. 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

  1. 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.
  2. 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.
  3. 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 in setup(). 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 library by 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 least 2.0 to prevent chatter.

How to Extend (Production / Smart Home)

  • Add MQTT for Home Assistant: Replace the local serial logging with the PubSubClient library. Publish the tempC and heaterState variables 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_SSD1306 library 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.