Community project

ESP32 Smart Thermostat

ESP32
Photo of ESP32 Smart Thermostat
Generated with AI

lanb135

Published October 24, 2026

Build a WiFi-connected smart thermostat that monitors room temperature and humidity while controlling heating, cooling, and fan systems through relay contacts. This project combines an ESP32 microcontroller with a Sensirion temperature sensor, a 4-channel relay module for HVAC control, and an integrated OLED display with rotary encoder for local adjustment.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions that prioritize safety when working with furnace circuits. Firmware is included with support for multiple HVAC modes (heat, cool, auto, fan-only), web-based remote control, hysteresis logic to prevent short-cycling, and a responsive local interface with screen dimming to save power.

Wiring diagram

Wiring diagram for ESP32 Smart Thermostat

Gather all the parts

QtyComponent
1

Adafruit Sensirion SHT31-D Temperature & Humidity Breakout

I2C temperature and humidity sensor breakout (SHT31-D) with ±2% RH and ±0.3°C accuracy, PTFE-filtered housing, and 3.3 V/5 V compatibility. Good choice when DHT-series accuracy is insufficient.

1

4-channel 5 V optocoupled relay module with separate JD-VCC

A four-relay board that uses three relays to connect the furnace R wire to fan, cooling, or heat calls.

1

W10M Bridge Rectifier

Single-phase full-wave silicon bridge rectifier in WOM through-hole package. Rated 1000V peak reverse voltage and 1.5A average forward rectified current (Ifsm 50A surge). Four pins: two AC input (~), one DC+ output, one DC- output. Glass passivated, suitable for PCB mounting in any orientation. Converts 12V AC transformer secondary to ~16.97V pulsating DC. No firmware required — purely passive component.

1

470 µF 63 V electrolytic capacitor

A polarized capacitor that smooths the rectified furnace supply before the buck converter.

1

LM2596HV adjustable 60 V-to-5 V buck converter module

A high-voltage step-down module that converts the rectified furnace supply to a regulated 5 V rail.

1

500 mA slow-blow inline fuse and holder

A replaceable fuse in the furnace R supply that protects the thermostat power input and relay common feed.

1

Maintained fan On/Auto keyswitch

A maintained switch that selects continuous fan operation when closed.

1

Five-position HVAC wall terminal block

The existing furnace-control terminals where the thermostat connects to R, C, G, Y, and W wires.

1

EC11 rotary encoder + 1.3 inch SH1106 OLED integrated control module

A 3.3 V control panel combining a white 128×64 OLED screen, rotary knob with push switch, and Confirm and Return buttons.

Assemble it in 7 steps

1. Keep the furnace wires safe while you build

Turn off power to the furnace at its service switch or breaker. Label the existing wall wires R, C, G, Y, and W before changing any connection. Keep these five 24 VAC wires physically separate from the ESP32 and control-panel wiring.

  • Take a clear photo of the original wall terminals before disconnecting anything.
  • A loose furnace wire can blow the furnace’s low-voltage fuse or stop heating and cooling from working.

2. Build the 5 V power section

Connect wall-terminal R to fuse_1 IN (protection). Connect fuse_1 OUT to rectifier_1 AC1 (AC power), and wall-terminal C to rectifier_1 AC2 (the other AC power wire). Connect rectifier_1 DC+ to capacitor_1 + and buck_1 VIN+ (DC power). Connect the striped capacitor_1 − lead, rectifier_1 DC−, buck_1 VIN−, and buck_1 VOUT− together as DC ground (ground). Set buck_1 VOUT+ to exactly 5.0 V before connecting the ESP32.

  • Measure between buck VOUT+ and VOUT− while the ESP32 is disconnected.
  • The capacitor’s striped lead is its negative lead.
  • Never join furnace C to DC ground — that can short the rectifier and blow the furnace fuse.
  • Swapped capacitor leads can damage or burst the capacitor.
  • Do not connect USB while the external 5 V supply feeds the ESP32 VIN/5V pin.

3. Connect the ESP32 and relay-board power

Remove the relay_1 JD-VCC jumper. Connect buck_1 VOUT+ to the ESP32 VIN/5V pin and relay_1 JD-VCC (5 V relay-coil power). Connect DC ground to ESP32 GND and relay_1 GND (ground). Connect relay_1 VCC to ESP32 3V3 (3.3 V relay-input power).

  • With the relay inputs disconnected, measure 5 V at JD-VCC and 3.3 V at VCC before continuing.
  • Leaving the JD-VCC jumper in place while VCC is fed from 3.3 V can put 5 V onto the ESP32-side relay input circuit.

4. Wire the furnace-call contacts

Connect fuse_1 OUT to relay_1 COM1, COM2, and COM3 (fused 24 VAC R supply). Connect relay_1 NO1 to wall terminal G (fan call), NO2 to Y (cooling call), and NO3 to W (heating call). Leave the NC terminals unused. Connect relay_1 IN1 to GPIO25, IN2 to GPIO26, and IN3 to GPIO27 (relay control).

  • The COM-to-NO contacts only join fused R to G, Y, or W while their relay is active.
  • Do not connect C to relay COM, NO, NC, ESP32 ground, or rectifier negative.
  • Using NC could request heating or cooling when the controller is off.

5. Wire the sensor and combined control panel

Connect sht31_1 VCC and ui_module_1 VCC to ESP32 3V3 (power). Connect both GND pins to DC ground (ground). Connect both SDA pins to GPIO21 (data) and both SCL pins to GPIO22 (clock). On ui_module_1, connect TRA to GPIO32 (knob direction A), TRB to GPIO33 (knob direction B), PSH to GPIO4 (knob push), CON to GPIO13 (Confirm), and BAK to GPIO14 (Return).

  • Match the control-panel labels rather than header position: TRA, TRB, PSH, CON, and BAK.
  • Mount the SHT31 where room air reaches it, away from warm relay coils and the buck converter.
  • Make sure VCC and GND are not swapped — swapped power can damage the screen or sensor.
  • Do not connect any panel signal pin to 5 V; ESP32 signal pins use 3.3 V.

6. Wire the fan keyswitch

Connect fan_keyswitch_1 SIG to GPIO19 (fan On/Auto signal) and its other terminal to DC ground (ground).

  • The ESP32 uses its internal pull-up, so closing the switch connects GPIO19 to ground.
  • Do not connect the keyswitch to the 5 V rail; the ESP32 input is a 3.3 V signal.

7. Test before reconnecting the furnace controls

First flash and test the ESP32 from USB with the furnace wiring disconnected. Then remove USB, power it from the regulated 5 V supply, and test the screen, knob, Confirm, Return, fan keyswitch, and relays. Finally reconnect the furnace terminal block and test fan, cooling, and heat one at a time.

  • The intentional five-minute heating and compressor restart delay applies after boot and after a call ends.
  • Do not test heating and cooling at the same time. If a relay works opposite to the screen, stop and correct relay polarity before connecting the HVAC wires.

Review all connections

1. Connections between "fuse_1" and "ESP32"

Functionfuse_1ESP32
powerOUT → W10M Bridge Rectifier AC1EXT
powerIN → Five-position HVAC wall terminal block REXT

2. Connections between "rectifier_1" and "ESP32"

Functionrectifier_1ESP32
powerDC+ → 470 µF 63 V electrolytic capacitor +EXT
groundDC-GND
analogAC2 → Five-position HVAC wall terminal block CEXT

3. Connections between "capacitor_1" and "ESP32"

Functioncapacitor_1ESP32
ground-GND

4. Connections between "buck_1" and "ESP32"

Functionbuck_1ESP32
powerVIN+ → 470 µF 63 V electrolytic capacitor +EXT
groundVIN-GND
powerVOUT+5V
groundVOUT-GND

5. Connections between "relay_1" and "ESP32"

Functionrelay_1ESP32
powerJD-VCC5V
powerVCC3V3
groundGNDGND
digitalIN1GPIO 25
digitalIN2GPIO 26
digitalIN3GPIO 27
digitalCOM1 → 500 mA slow-blow inline fuse and holder OUTEXT
digitalCOM2 → 500 mA slow-blow inline fuse and holder OUTEXT
digitalCOM3 → 500 mA slow-blow inline fuse and holder OUTEXT
digitalNO1 → Five-position HVAC wall terminal block GEXT
digitalNO2 → Five-position HVAC wall terminal block YEXT
digitalNO3 → Five-position HVAC wall terminal block WEXT

6. Connections between "sht31_1" and "ESP32"

Functionsht31_1ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

7. Connections between "fan_keyswitch_1" and "ESP32"

Functionfan_keyswitch_1ESP32
digitalSIGGPIO 19
groundGNDGND

8. Connections between "ui_module_1" and "ESP32"

Functionui_module_1ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22
digitalTRAGPIO 32
digitalTRBGPIO 33
digitalPSHGPIO 4
digitalCONGPIO 13
digitalBAKGPIO 14

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include <Adafruit_SHT31.h>

constexpr uint8_t I2C_SDA = 21;
constexpr uint8_t I2C_SCL = 22;
constexpr uint8_t RELAY_G_PIN = 25;
constexpr uint8_t RELAY_Y_PIN = 26;
constexpr uint8_t RELAY_W_PIN = 27;
constexpr uint8_t ENCODER_A_PIN = 32;
constexpr uint8_t ENCODER_B_PIN = 33;
constexpr uint8_t ENCODER_PUSH_PIN = 4;
constexpr uint8_t CONFIRM_PIN = 13;
constexpr uint8_t BACK_PIN = 14;
constexpr uint8_t FAN_KEY_PIN = 19;

constexpr uint32_t COMPRESSOR_OFF_MS = 300000UL;
constexpr uint32_t HEAT_OFF_MS = 300000UL;
constexpr uint32_t MIN_RUN_MS = 180000UL;
constexpr uint32_t SENSOR_STALE_MS = 120000UL;
constexpr uint32_t SENSOR_SAMPLE_MS = 10000UL;
constexpr uint32_t SCREEN_DIM_MS = 30000UL;
constexpr uint32_t SCREEN_OFF_MS = 300000UL;
constexpr float MIN_C = 10.0f;
constexpr float MAX_C = 32.0f;
constexpr float HYST_C = 0.3f;

Adafruit_SH1106G oled(128, 64, &Wire, -1);
Adafruit_SHT31 sht31 = Adafruit_SHT31();
WebServer server(80);

enum Mode : uint8_t { MODE_OFF, MODE_HEAT, MODE_COOL, MODE_AUTO, MODE_FAN };
enum Page : uint8_t { PAGE_HOME, PAGE_MENU, PAGE_STATUS };
enum Action : uint8_t { ACTION_IDLE, ACTION_HEATING, ACTION_COOLING, ACTION_FAN };

Mode mode = MODE_OFF;
Mode menuMode = MODE_OFF;
Page page = PAGE_HOME;
Action action = ACTION_IDLE;
float roomC = NAN;
float humidity = NAN;
float heatSetC = 20.0f;
float coolSetC = 24.5f;
bool sensorFault = false;
bool fanOn = false;
bool relayG = false;
bool relayY = false;
bool relayW = false;
bool shtPresent = false;
uint32_t bootMs = 0;
uint32_t lastTemperatureMs = 0;
uint32_t lastSensorSampleMs = 0;
uint32_t lastInputMs = 0;
uint32_t lastDrawMs = 0;
uint32_t coolingOffSinceMs = 0;
uint32_t heatingOffSinceMs = 0;
uint32_t coolingOnSinceMs = 0;
uint32_t heatingOnSinceMs = 0;
int lastEncoderA = HIGH;
uint8_t lastButtons = 0xFF;
uint8_t screenState = 0; // 0 on, 1 dim, 2 off

float cToF(float c) { return c * 9.0f / 5.0f + 32.0f; }
float fToC(float f) { return (f - 32.0f) * 5.0f / 9.0f; }
float clampC(float value) { return constrain(value, MIN_C, MAX_C); }
const char *modeName(Mode value) {
  switch (value) {
    case MODE_HEAT: return "Heat";
    case MODE_COOL: return "Cool";
    case MODE_AUTO: return "Auto";
    case MODE_FAN: return "Fan";
    default: return "Off";
  }
}
const char *actionName(Action value) {
  switch (value) {
    case ACTION_HEATING: return "heating";
    case ACTION_COOLING: return "cooling";
    case ACTION_FAN: return "fan";
    default: return "idle";
  }
}

void setRelays(bool g, bool y, bool w) {
  // A second firmware-level guard prevents simultaneous heating and cooling.
  if (y && w) w = false;
  relayG = g;
  relayY = y;
  relayW = w;
  digitalWrite(RELAY_G_PIN, g ? LOW : HIGH);
  digitalWrite(RELAY_Y_PIN, y ? LOW : HIGH);
  digitalWrite(RELAY_W_PIN, w ? LOW : HIGH);
}

void forceOff() {
  setRelays(false, false, false);
  action = ACTION_IDLE;
}

void wakeDisplay() {
  if (screenState == 2) oled.oled_command(0xAF);
  oled.setContrast(255);
  screenState = 0;
  lastInputMs = millis();
}

void updateThermostat() {
  const uint32_t now = millis();
  if (sensorFault || mode == MODE_OFF) {
    forceOff();
    return;
  }
  if (mode == MODE_FAN) {
    setRelays(true, false, false);
    action = ACTION_FAN;
    return;
  }
  const bool startupLockout = now - bootMs < COMPRESSOR_OFF_MS;
  const bool coolingAvailable = !startupLockout && now - coolingOffSinceMs >= COMPRESSOR_OFF_MS;
  const bool heatAvailable = !startupLockout && now - heatingOffSinceMs >= HEAT_OFF_MS;
  const bool mustKeepCooling = relayY && now - coolingOnSinceMs < MIN_RUN_MS;
  const bool mustKeepHeating = relayW && now - heatingOnSinceMs < MIN_RUN_MS;
  bool wantHeat = mode == MODE_HEAT || mode == MODE_AUTO;
  bool wantCool = mode == MODE_COOL || mode == MODE_AUTO;
  bool coolCall = wantCool && roomC >= coolSetC + HYST_C;
  bool heatCall = wantHeat && roomC <= heatSetC - HYST_C;

  if (relayY && (mustKeepCooling || roomC > coolSetC - HYST_C)) {
    setRelays(true, true, false);
    action = ACTION_COOLING;
    return;
  }
  if (relayW && (mustKeepHeating || roomC < heatSetC + HYST_C)) {
    setRelays(fanOn, false, true);
    action = ACTION_HEATING;
    return;
  }
  if (coolCall && coolingAvailable) {
    if (!relayY) coolingOnSinceMs = now;
    setRelays(true, true, false);
    action = ACTION_COOLING;
  } else if (heatCall && heatAvailable) {
    if (!relayW) heatingOnSinceMs = now;
    setRelays(fanOn, false, true);
    action = ACTION_HEATING;
  } else {
    if (relayY) coolingOffSinceMs = now;
    if (relayW) heatingOffSinceMs = now;
    setRelays(fanOn, false, false);
    action = fanOn ? ACTION_FAN : ACTION_IDLE;
  }
}

void sampleSensor() {
  const uint32_t now = millis();
  if (now - lastSensorSampleMs < SENSOR_SAMPLE_MS) return;
  lastSensorSampleMs = now;
  float t = sht31.readTemperature();
  float h = sht31.readHumidity();
  if (!isnan(t) && t > 0.0f && t < 45.0f) {
    roomC = isnan(roomC) ? t : roomC * 0.7f + t * 0.3f;
    humidity = h;
    lastTemperatureMs = now;
  }
  bool bad = now - lastTemperatureMs > SENSOR_STALE_MS || (!isnan(roomC) && (roomC < 0.0f || roomC > 45.0f));
  if (bad && !sensorFault) {
    sensorFault = true;
    mode = MODE_OFF;
    forceOff();
  } else if (!bad) {
    sensorFault = false;
  }
}

void drawScreen() {
  const uint32_t now = millis();
  if (screenState == 2 || now - lastDrawMs < 500) return;
  lastDrawMs = now;
  oled.clearDisplay();
  oled.setTextColor(SH110X_WHITE);
  if (page == PAGE_MENU) {
    oled.setTextSize(1);
    oled.setCursor(48, 2);
    oled.print("MODE");
    oled.setTextSize(2);
    oled.setCursor(20, 25);
    oled.print("<"); oled.print(modeName(menuMode)); oled.print(">");
    oled.setTextSize(1);
    oled.setCursor(2, 54);
    oled.print("Push=set Back=cancel");
  } else if (page == PAGE_STATUS) {
    oled.setTextSize(1);
    oled.setCursor(0, 0); oled.print("IP "); oled.print(WiFi.localIP());
    oled.setCursor(0, 13); oled.print("WiFi "); oled.print(WiFi.RSSI()); oled.print(" dBm");
    oled.setCursor(0, 26); oled.printf("G %s Y %s W %s", relayG ? "ON" : "-", relayY ? "ON" : "-", relayW ? "ON" : "-");
    oled.setCursor(0, 39); oled.print("RH "); if (!isnan(humidity)) oled.printf("%.0f%%", humidity); else oled.print("--");
    oled.setCursor(0, 52); oled.print(sensorFault ? "SENSOR FAULT" : "Sensor OK");
  } else {
    oled.setTextSize(1);
    oled.setCursor(0, 0); oled.print(modeName(mode)); oled.print(" "); oled.print(actionName(action));
    if (sensorFault || isnan(roomC)) {
      oled.setTextSize(3); oled.setCursor(40, 22); oled.print("--");
      oled.setTextSize(1); oled.setCursor(24, 54); oled.print("SENSOR FAULT");
    } else {
      oled.setTextSize(3); oled.setCursor(22, 20); oled.printf("%.0fF", cToF(roomC));
      oled.setTextSize(1); oled.setCursor(0, 54);
      if (mode == MODE_HEAT) oled.printf("Set %.0fF", cToF(heatSetC));
      else if (mode == MODE_COOL) oled.printf("Set %.0fF", cToF(coolSetC));
      else if (mode == MODE_AUTO) oled.printf("%.0f-%.0fF", cToF(heatSetC), cToF(coolSetC));
      if (fanOn) { oled.setCursor(94, 54); oled.print("FAN"); }
    }
  }
  oled.display();
}

void handleInput() {
  int encoderA = digitalRead(ENCODER_A_PIN);
  if (lastEncoderA == HIGH && encoderA == LOW) {
    wakeDisplay();
    int dir = digitalRead(ENCODER_B_PIN) == HIGH ? 1 : -1;
    if (page == PAGE_MENU) {
      menuMode = static_cast<Mode>((static_cast<int>(menuMode) + dir + 5) % 5);
    } else if (page == PAGE_HOME && !sensorFault) {
      if (mode == MODE_HEAT) heatSetC = clampC(fToC(roundf(cToF(heatSetC)) + dir));
      else if (mode == MODE_COOL) coolSetC = clampC(fToC(roundf(cToF(coolSetC)) + dir));
      else if (mode == MODE_AUTO) {
        heatSetC = clampC(fToC(roundf(cToF(heatSetC)) + dir));
        coolSetC = clampC(fToC(roundf(cToF(coolSetC)) + dir));
      }
    }
  }
  lastEncoderA = encoderA;
  uint8_t buttons = (digitalRead(ENCODER_PUSH_PIN) << 0) | (digitalRead(CONFIRM_PIN) << 1) | (digitalRead(BACK_PIN) << 2) | (digitalRead(FAN_KEY_PIN) << 3);
  uint8_t changed = lastButtons & ~buttons;
  if (changed) wakeDisplay();
  if (changed & 0x03) {
    if (page == PAGE_MENU) { mode = menuMode; page = PAGE_HOME; }
    else { menuMode = mode; page = PAGE_MENU; }
  }
  if (changed & 0x04) page = page == PAGE_HOME ? PAGE_STATUS : PAGE_HOME;
  fanOn = (buttons & 0x08) == 0;
  lastButtons = buttons;
}

void handleRoot() {
  String html = "<!doctype html><html><body><h1>Thermostat</h1><p>Mode: ";
  html += modeName(mode);
  html += "</p><p>Temperature: ";
  html += isnan(roomC) ? "unavailable" : String(cToF(roomC), 1) + " F";
  html += "</p><p>Relays G/Y/W: ";
  html += relayG ? "on/" : "off/"; html += relayY ? "on/" : "off/"; html += relayW ? "on" : "off";
  html += "</p></body></html>";
  server.send(200, "text/html", html);
}

void setup() {
  pinMode(RELAY_G_PIN, OUTPUT); pinMode(RELAY_Y_PIN, OUTPUT); pinMode(RELAY_W_PIN, OUTPUT);
  setRelays(false, false, false);
  pinMode(ENCODER_A_PIN, INPUT_PULLUP); pinMode(ENCODER_B_PIN, INPUT_PULLUP);
  pinMode(ENCODER_PUSH_PIN, INPUT_PULLUP); pinMode(CONFIRM_PIN, INPUT_PULLUP);
  pinMode(BACK_PIN, INPUT_PULLUP); pinMode(FAN_KEY_PIN, INPUT_PULLUP);
  lastEncoderA = digitalRead(ENCODER_A_PIN);
  lastButtons = 0xFF;
  bootMs = millis(); lastInputMs = bootMs; coolingOffSinceMs = bootMs; heatingOffSinceMs = bootMs;
  Wire.begin(I2C_SDA, I2C_SCL);
  shtPresent = sht31.begin(0x44);
  oled.begin(0x3C, true);
  oled.clearDisplay(); oled.setTextColor(SH110X_WHITE); oled.setTextSize(1); oled.setCursor(0, 24); oled.print("Thermostat starting"); oled.display();
  WiFi.mode(WIFI_AP);
  WiFi.softAP("Thermostat Setup", "Thermostat-Setup-2026");
  server.on("/", handleRoot);
  server.begin();
}

void loop() {
  handleInput();
  sampleSensor();
  updateThermostat();
  uint32_t idle = millis() - lastInputMs;
  if (!sensorFault && screenState == 0 && idle > SCREEN_DIM_MS) { oled.setContrast(12); screenState = 1; }
  else if (!sensorFault && screenState == 1 && idle > SCREEN_OFF_MS) { oled.oled_command(0xAE); screenState = 2; }
  drawScreen();
  server.handleClient();
  delay(2);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik