Community project
ESP32 Smart Thermostat
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

Gather all the parts
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"
2. Connections between "rectifier_1" and "ESP32"
3. Connections between "capacitor_1" and "ESP32"
4. Connections between "buck_1" and "ESP32"
5. Connections between "relay_1" and "ESP32"
6. Connections between "sht31_1" and "ESP32"
7. Connections between "fan_keyswitch_1" and "ESP32"
8. Connections between "ui_module_1" and "ESP32"
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.




