🔗Goal
Build a smart thermostat that reads temperature, humidity, and barometric pressure from a BME280 sensor, displays the current state on an OLED screen, and lets you adjust the setpoint with a rotary encoder. A relay controls a heater or fan based on hysteresis logic, preventing the rapid on/off cycling that damages equipment. A buzzer sounds an alert if the temperature goes too high or too low, and MQTT provides remote monitoring and control.
Here is how the system works at a high level:
graph LR
subgraph Input
A1[BME280 Sensor]
A2[Rotary Encoder]
end
subgraph ESP32
B[ESP32 Controller]
end
subgraph Output
C1[OLED Display]
C2[Relay - Heater/Fan]
C3[Buzzer]
end
subgraph Network
D[MQTT Broker]
E[Dashboard / Phone]
end
A1 -->|I2C| B
A2 -->|GPIO| B
B -->|I2C| C1
B -->|GPIO| C2
B -->|GPIO| C3
B -->|WiFi| D
D --> E
E -->|Commands| D
D -->|Remote control| BUnlike a simple on/off thermostat, this design uses hysteresis: the heater turns on when the temperature drops below the setpoint minus a margin, and turns off when it rises above the setpoint plus a margin. This prevents the relay from clicking on and off every few seconds, which extends the life of both the relay and the heater.
🔗How Hysteresis Works
A basic thermostat turns the heater on when the temperature is below the setpoint and off when it is above. But temperature fluctuates constantly near the threshold, causing rapid cycling. Hysteresis adds a dead band around the setpoint:
graph TD
A[Temperature reading] --> B{Heater currently OFF?}
B -->|Yes| C{Temp < Setpoint - Hysteresis?}
C -->|Yes| D[Turn heater ON]
C -->|No| E[Keep heater OFF]
B -->|No: Heater is ON| F{Temp > Setpoint + Hysteresis?}
F -->|Yes| G[Turn heater OFF]
F -->|No| H[Keep heater ON]With a setpoint of $22.0\,°\text{C}$ and hysteresis of $0.5\,°\text{C}$:
- The heater turns ON when the temperature drops below $21.5\,°\text{C}$
- The heater turns OFF when the temperature rises above $22.5\,°\text{C}$
- Between $21.5\,°\text{C}$ and $22.5\,°\text{C}$, the heater stays in whatever state it was already in
This creates a comfortable $1.0\,°\text{C}$ band around the target temperature:
$$T_{on} = T_{setpoint} - T_{hysteresis}$$ $$T_{off} = T_{setpoint} + T_{hysteresis}$$
🔗Parts List
| Component | Qty | Notes |
|---|---|---|
| ESP32 dev board | 1 | Any ESP32-WROOM-32 DevKit works |
| BME280 sensor module | 1 | I2C breakout board (not BMP280 -- that one lacks humidity) |
| SSD1306 OLED display | 1 | 0.96 inch, 128x64 pixels, I2C |
| Rotary encoder with push button | 1 | KY-040 or similar, 5-pin module |
| Single-channel relay module | 1 | 5V relay with optocoupler isolation |
| Active buzzer module | 1 | 3.3V compatible |
| Breadboard | 1 | |
| Jumper wires | ~15 | Male-to-male and male-to-female |
You will also need the following Arduino libraries. Install them via Sketch > Include Library > Manage Libraries:
| Library | Author | Purpose |
|---|---|---|
| Adafruit BME280 Library | Adafruit | BME280 sensor driver |
| Adafruit Unified Sensor | Adafruit | Dependency for the BME280 library |
| Adafruit SSD1306 | Adafruit | OLED display driver |
| Adafruit GFX Library | Adafruit | Graphics primitives (text, shapes) |
| PubSubClient | Nick O'Leary | MQTT client |
| WiFi | Espressif | Built into ESP32 Arduino core |
See our BME280 guide for details on the temperature sensor and our OLED SSD1306 guide for the display.
🔗Wiring
🔗BME280 and SSD1306 OLED (I2C Bus)
Both devices share the same I2C bus. The BME280 defaults to address 0x76 and the SSD1306 defaults to 0x3C.
| Component | Pin | ESP32 Pin |
|---|---|---|
| BME280 | VIN / VCC | 3.3V |
| BME280 | GND | GND |
| BME280 | SDA | GPIO 21 |
| BME280 | SCL | GPIO 22 |
| SSD1306 OLED | VCC | 3.3V |
| SSD1306 OLED | GND | GND |
| SSD1306 OLED | SDA | GPIO 21 |
| SSD1306 OLED | SCL | GPIO 22 |
🔗Rotary Encoder (KY-040)
The rotary encoder has two signal pins (CLK and DT) for rotation direction, plus a switch pin (SW) for the push button. The module typically has its own pull-up resistors.
| Component | Pin | ESP32 Pin |
|---|---|---|
| Encoder | CLK | GPIO 32 |
| Encoder | DT | GPIO 33 |
| Encoder | SW | GPIO 25 |
| Encoder | + | 3.3V |
| Encoder | GND | GND |
GPIO 32 and 33 are good choices for the encoder because they support interrupts, which provide responsive rotation detection. GPIO 25 is used for the push button.
🔗Relay Module
| Component | Pin | ESP32 Pin |
|---|---|---|
| Relay module | VCC | 5V (from external supply or USB 5V) |
| Relay module | GND | GND (shared with ESP32) |
| Relay module | IN | GPIO 26 |
Connect your heater or fan to the relay's normally-open (NO) and common (COM) terminals. When the relay activates, it completes the circuit to the heater's own power supply.
Safety note: If controlling mains-voltage devices (like a space heater), use a relay rated for your voltage and current. Consider a solid-state relay (SSR) for mains switching, and always follow local electrical codes. For beginners, start with a low-voltage device like a 12V heating pad or a USB fan.
🔗Buzzer
| Component | Pin | ESP32 Pin |
|---|---|---|
| Buzzer (+) | -- | GPIO 27 |
| Buzzer (-) | -- | GND |
🔗System Modes
The thermostat operates in three modes, selectable by pressing the rotary encoder button:
| Mode | Display label | Behavior |
|---|---|---|
| Heating | HEAT | Relay activates when temperature drops below setpoint (with hysteresis) |
| Cooling | COOL | Relay activates when temperature rises above setpoint (with hysteresis) |
| Off | OFF | Relay is always off. Sensor readings and display still work |
Pressing the encoder button cycles through: HEAT -> COOL -> OFF -> HEAT.
🔗Day/Night Scheduling
The thermostat supports simple two-period scheduling with separate day and night setpoints. When scheduling is enabled via MQTT, the thermostat automatically switches between two target temperatures:
| Period | Default hours | Default setpoint |
|---|---|---|
| Day | 07:00 -- 22:00 | 22.0 degrees C |
| Night | 22:00 -- 07:00 | 18.0 degrees C |
NTP (Network Time Protocol) provides the current time so the thermostat knows which period is active. The schedule can be overridden by manually adjusting the setpoint with the encoder -- the schedule resumes at the next period change.
🔗Complete Code
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
#include <time.h>
// ==================== CONFIGURATION ====================
// WiFi
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
// MQTT
const char* MQTT_BROKER = "test.mosquitto.org";
const int MQTT_PORT = 1883;
const char* MQTT_CLIENT_ID = "esp32_thermostat";
const char* MQTT_PUB_TOPIC = "thermostat/status";
const char* MQTT_CMD_TOPIC = "thermostat/command";
// NTP
const char* NTP_SERVER = "pool.ntp.org";
const long GMT_OFFSET_SEC = 0; // Adjust for your timezone (e.g., 3600 for GMT+1)
const int DST_OFFSET_SEC = 0; // Daylight saving offset in seconds
// BME280
#define BME280_ADDRESS 0x76 // Change to 0x77 if needed
// OLED
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDRESS 0x3C
// Rotary encoder pins
#define ENC_CLK 32
#define ENC_DT 33
#define ENC_SW 25
// Relay and buzzer
#define RELAY_PIN 26
#define BUZZER_PIN 27
// ---- Thermostat settings ----
float setpoint = 22.0; // Target temperature (Celsius)
float hysteresis = 0.5; // Dead band (+/- around setpoint)
float alertHighC = 35.0; // Buzzer alert: too hot
float alertLowC = 5.0; // Buzzer alert: too cold
// Day/night schedule
bool scheduleEnabled = false;
float daySetpoint = 22.0;
float nightSetpoint = 18.0;
int dayStartHour = 7; // 07:00
int nightStartHour = 22; // 22:00
// ==================== ENUMS ====================
enum Mode { MODE_HEAT, MODE_COOL, MODE_OFF };
Mode currentMode = MODE_HEAT;
// ==================== OBJECTS ====================
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Adafruit_BME280 bme;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// ==================== STATE ====================
float temperature = 0;
float humidity = 0;
float pressure = 0;
bool relayOn = false;
bool buzzerActive = false;
bool scheduleOverridden = false;
// Encoder state
volatile int encoderPos = 0;
volatile bool encoderChanged = false;
int lastCLK = HIGH;
// Button state
unsigned long lastButtonPress = 0;
const unsigned long DEBOUNCE_MS = 300;
// Timing
unsigned long lastSensorRead = 0;
unsigned long lastPublish = 0;
const unsigned long SENSOR_INTERVAL = 2000; // 2 seconds
const unsigned long PUBLISH_INTERVAL = 30000; // 30 seconds
// ==================== INTERRUPT HANDLER ====================
void IRAM_ATTR encoderISR() {
int clkState = digitalRead(ENC_CLK);
int dtState = digitalRead(ENC_DT);
if (clkState != lastCLK && clkState == LOW) {
if (dtState != clkState) {
encoderPos++; // Clockwise
} else {
encoderPos--; // Counter-clockwise
}
encoderChanged = true;
}
lastCLK = clkState;
}
// ==================== WIFI ====================
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
Serial.print("Connecting to WiFi");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println();
Serial.print("Connected! IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println();
Serial.println("WiFi connection failed. Will retry...");
}
}
// ==================== MQTT ====================
void mqttCallback(char* topic, byte* payload, unsigned int length) {
String message;
for (unsigned int i = 0; i < length; i++) {
message += (char)payload[i];
}
Serial.print("MQTT received [");
Serial.print(topic);
Serial.print("]: ");
Serial.println(message);
// Commands:
// "setpoint:XX.X" - set target temperature
// "mode:heat" - switch to heating mode
// "mode:cool" - switch to cooling mode
// "mode:off" - turn off
// "hysteresis:X.X" - set hysteresis value
// "schedule:on" - enable day/night schedule
// "schedule:off" - disable day/night schedule
// "day:XX.X" - set day setpoint
// "night:XX.X" - set night setpoint
// "status" - request current status
if (message.startsWith("setpoint:")) {
float val = message.substring(9).toFloat();
if (val >= 5.0 && val <= 40.0) {
setpoint = val;
scheduleOverridden = true;
Serial.print("Setpoint changed to: ");
Serial.println(setpoint);
}
}
else if (message == "mode:heat") {
currentMode = MODE_HEAT;
}
else if (message == "mode:cool") {
currentMode = MODE_COOL;
}
else if (message == "mode:off") {
currentMode = MODE_OFF;
}
else if (message.startsWith("hysteresis:")) {
float val = message.substring(11).toFloat();
if (val >= 0.1 && val <= 5.0) {
hysteresis = val;
}
}
else if (message == "schedule:on") {
scheduleEnabled = true;
scheduleOverridden = false;
}
else if (message == "schedule:off") {
scheduleEnabled = false;
}
else if (message.startsWith("day:")) {
float val = message.substring(4).toFloat();
if (val >= 5.0 && val <= 40.0) daySetpoint = val;
}
else if (message.startsWith("night:")) {
float val = message.substring(6).toFloat();
if (val >= 5.0 && val <= 40.0) nightSetpoint = val;
}
else if (message == "status") {
publishStatus();
}
}
void connectMQTT() {
if (mqtt.connected()) return;
Serial.print("Connecting to MQTT...");
while (!mqtt.connected()) {
if (mqtt.connect(MQTT_CLIENT_ID)) {
Serial.println(" connected!");
mqtt.subscribe(MQTT_CMD_TOPIC);
} else {
Serial.print(" failed (rc=");
Serial.print(mqtt.state());
Serial.println("). Retrying in 5 seconds...");
delay(5000);
}
}
}
void publishStatus() {
const char* modeStr = (currentMode == MODE_HEAT) ? "heat" :
(currentMode == MODE_COOL) ? "cool" : "off";
String payload = "{";
payload += "\"temperature\":" + String(temperature, 1) + ",";
payload += "\"humidity\":" + String(humidity, 1) + ",";
payload += "\"pressure\":" + String(pressure, 1) + ",";
payload += "\"setpoint\":" + String(setpoint, 1) + ",";
payload += "\"hysteresis\":" + String(hysteresis, 1) + ",";
payload += "\"mode\":\"" + String(modeStr) + "\",";
payload += "\"relay\":" + String(relayOn ? "true" : "false") + ",";
payload += "\"schedule\":" + String(scheduleEnabled ? "true" : "false");
payload += "}";
if (mqtt.publish(MQTT_PUB_TOPIC, payload.c_str())) {
Serial.print("Published: ");
Serial.println(payload);
} else {
Serial.println("ERROR: MQTT publish failed.");
}
}
// ==================== SCHEDULE ====================
void applySchedule() {
if (!scheduleEnabled || scheduleOverridden) return;
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return; // NTP not synced yet
int hour = timeinfo.tm_hour;
// Determine if it is day or night
bool isDaytime;
if (dayStartHour < nightStartHour) {
isDaytime = (hour >= dayStartHour && hour < nightStartHour);
} else {
isDaytime = (hour >= dayStartHour || hour < nightStartHour);
}
float targetSetpoint = isDaytime ? daySetpoint : nightSetpoint;
if (setpoint != targetSetpoint) {
setpoint = targetSetpoint;
Serial.print("Schedule: switched to ");
Serial.print(isDaytime ? "day" : "night");
Serial.print(" setpoint: ");
Serial.println(setpoint, 1);
}
}
// ==================== CONTROL LOGIC ====================
void evaluateControl() {
if (currentMode == MODE_OFF) {
relayOn = false;
digitalWrite(RELAY_PIN, HIGH); // Active-LOW: HIGH = off
return;
}
if (currentMode == MODE_HEAT) {
// Heating: turn on when cold, off when warm
if (!relayOn && temperature < (setpoint - hysteresis)) {
relayOn = true;
} else if (relayOn && temperature > (setpoint + hysteresis)) {
relayOn = false;
}
}
else if (currentMode == MODE_COOL) {
// Cooling: turn on when hot, off when cool
if (!relayOn && temperature > (setpoint + hysteresis)) {
relayOn = true;
} else if (relayOn && temperature < (setpoint - hysteresis)) {
relayOn = false;
}
}
// Active-LOW relay
digitalWrite(RELAY_PIN, relayOn ? LOW : HIGH);
// Buzzer alert for extreme temperatures
if (temperature > alertHighC || temperature < alertLowC) {
if (!buzzerActive) {
buzzerActive = true;
digitalWrite(BUZZER_PIN, HIGH);
}
} else {
if (buzzerActive) {
buzzerActive = false;
digitalWrite(BUZZER_PIN, LOW);
}
}
}
// ==================== DISPLAY ====================
void updateDisplay() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// Row 1: Current temperature (large)
display.setTextSize(2);
display.setCursor(0, 0);
display.print(temperature, 1);
display.setTextSize(1);
display.print(" ");
display.print((char)247);
display.print("C");
// Row 2: Humidity and pressure
display.setTextSize(1);
display.setCursor(0, 20);
display.print("Hum: ");
display.print(humidity, 1);
display.print("% ");
display.print(pressure, 0);
display.print("hPa");
// Row 3: Setpoint
display.setCursor(0, 32);
display.print("Set: ");
display.setTextSize(2);
display.print(setpoint, 1);
display.setTextSize(1);
display.print(" ");
display.print((char)247);
display.print("C");
// Row 4: Mode and relay status
display.setCursor(0, 52);
const char* modeStr = (currentMode == MODE_HEAT) ? "HEAT" :
(currentMode == MODE_COOL) ? "COOL" : "OFF";
display.print("Mode: ");
display.print(modeStr);
display.setCursor(80, 52);
if (currentMode != MODE_OFF) {
display.print(relayOn ? "[ON]" : "[OFF]");
}
// Schedule indicator
if (scheduleEnabled) {
display.setCursor(110, 0);
display.print("S");
}
display.display();
}
// ==================== ENCODER HANDLING ====================
void handleEncoder() {
// Handle rotation (adjust setpoint)
if (encoderChanged) {
encoderChanged = false;
int delta = encoderPos;
encoderPos = 0;
// Each detent changes setpoint by 0.5 degrees
setpoint += delta * 0.5;
// Clamp setpoint to reasonable range
if (setpoint < 5.0) setpoint = 5.0;
if (setpoint > 40.0) setpoint = 40.0;
// Manual adjustment overrides schedule until next period
if (scheduleEnabled) {
scheduleOverridden = true;
}
Serial.print("Setpoint adjusted to: ");
Serial.println(setpoint, 1);
}
// Handle button press (change mode)
if (digitalRead(ENC_SW) == LOW) {
unsigned long now = millis();
if (now - lastButtonPress > DEBOUNCE_MS) {
lastButtonPress = now;
// Cycle through modes: HEAT -> COOL -> OFF -> HEAT
if (currentMode == MODE_HEAT) {
currentMode = MODE_COOL;
} else if (currentMode == MODE_COOL) {
currentMode = MODE_OFF;
} else {
currentMode = MODE_HEAT;
}
// Turn off buzzer when changing modes
buzzerActive = false;
digitalWrite(BUZZER_PIN, LOW);
const char* modeStr = (currentMode == MODE_HEAT) ? "HEAT" :
(currentMode == MODE_COOL) ? "COOL" : "OFF";
Serial.print("Mode changed to: ");
Serial.println(modeStr);
}
}
}
// ==================== SETUP ====================
void setup() {
Serial.begin(115200);
// Relay and buzzer
pinMode(RELAY_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(RELAY_PIN, HIGH); // Active-LOW: start OFF
digitalWrite(BUZZER_PIN, LOW);
// Encoder pins
pinMode(ENC_CLK, INPUT_PULLUP);
pinMode(ENC_DT, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
lastCLK = digitalRead(ENC_CLK);
// Attach interrupt for smooth encoder reading
attachInterrupt(digitalPinToInterrupt(ENC_CLK), encoderISR, CHANGE);
// Initialize I2C
Wire.begin();
// Initialize OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
Serial.println("ERROR: SSD1306 not found.");
while (true) { delay(1000); }
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Smart Thermostat");
display.println("Starting...");
display.display();
// Initialize BME280
if (!bme.begin(BME280_ADDRESS)) {
Serial.println("ERROR: BME280 not found.");
display.clearDisplay();
display.setCursor(0, 0);
display.println("BME280 not found!");
display.println("Check wiring.");
display.display();
while (true) { delay(1000); }
}
Serial.println("BME280 initialized.");
// Connect WiFi
connectWiFi();
// Initialize NTP
configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER);
Serial.println("NTP configured.");
// Initialize MQTT
mqtt.setServer(MQTT_BROKER, MQTT_PORT);
mqtt.setCallback(mqttCallback);
connectMQTT();
Serial.println("Smart thermostat ready.");
}
// ==================== MAIN LOOP ====================
void loop() {
// Maintain connections
connectWiFi();
if (!mqtt.connected()) {
connectMQTT();
}
mqtt.loop();
// Handle encoder input (runs every loop for responsiveness)
handleEncoder();
unsigned long now = millis();
// Read sensors and update control
if (now - lastSensorRead >= SENSOR_INTERVAL) {
lastSensorRead = now;
temperature = bme.readTemperature();
humidity = bme.readHumidity();
pressure = bme.readPressure() / 100.0F;
applySchedule();
evaluateControl();
updateDisplay();
Serial.print("Temp: ");
Serial.print(temperature, 1);
Serial.print("C | Set: ");
Serial.print(setpoint, 1);
Serial.print("C | Relay: ");
Serial.println(relayOn ? "ON" : "OFF");
}
// Publish MQTT data
if (now - lastPublish >= PUBLISH_INTERVAL) {
lastPublish = now;
publishStatus();
}
}🔗Testing and Calibration
🔗Step 1: Verify Sensor and Display
After uploading the code, the OLED should show:
- Current temperature in large text (top row)
- Humidity and pressure on the second row
- Setpoint in large text (third row)
- Mode and relay state on the bottom row
- An S in the top-right corner when scheduling is enabled
Open the Serial Monitor at 115200 baud to verify that readings are reasonable. The BME280 takes about 1-2 minutes to stabilize after power-on due to self-heating.
🔗Step 2: Test the Rotary Encoder
Turn the encoder knob. Each click (detent) should change the setpoint by $0.5\,°\text{C}$. The display should update immediately. The setpoint is clamped between $5.0\,°\text{C}$ and $40.0\,°\text{C}$.
Press the encoder button to cycle through modes: HEAT, COOL, OFF. The mode label on the display should change with each press.
If the encoder direction is reversed (clockwise decreases instead of increases), swap the CLK and DT wires.
🔗Step 3: Test Hysteresis Control
To test heating mode without waiting for natural temperature changes:
- Set the mode to HEAT
- Use the encoder to set the setpoint well above the current room temperature (e.g., $30.0\,°\text{C}$)
- The relay should click ON because the room is colder than $30.0 - 0.5 = 29.5\,°\text{C}$
- Lower the setpoint to well below room temperature (e.g., $15.0\,°\text{C}$)
- The relay should click OFF because the room is warmer than $15.0 + 0.5 = 15.5\,°\text{C}$
Repeat for COOL mode (the relay logic is reversed).
🔗Step 4: Test MQTT Control
Use mosquitto_pub to send commands from any computer:
# Set the target temperature to 23 degrees
mosquitto_pub -h test.mosquitto.org -t "thermostat/command" -m "setpoint:23.0"
# Switch to cooling mode
mosquitto_pub -h test.mosquitto.org -t "thermostat/command" -m "mode:cool"
# Enable the day/night schedule
mosquitto_pub -h test.mosquitto.org -t "thermostat/command" -m "schedule:on"
# Set day and night temperatures
mosquitto_pub -h test.mosquitto.org -t "thermostat/command" -m "day:22.0"
mosquitto_pub -h test.mosquitto.org -t "thermostat/command" -m "night:18.0"
# Request current status
mosquitto_pub -h test.mosquitto.org -t "thermostat/command" -m "status"Subscribe to see the thermostat's status updates:
mosquitto_sub -h test.mosquitto.org -t "thermostat/status"Example output:
{
"temperature": 23.4,
"humidity": 45.2,
"pressure": 1013.5,
"setpoint": 22.0,
"hysteresis": 0.5,
"mode": "heat",
"relay": false,
"schedule": false
}🔗Step 5: Test the Buzzer
The buzzer sounds when the temperature exceeds alertHighC ($35.0\,°\text{C}$) or drops below alertLowC ($5.0\,°\text{C}$). To test it, temporarily change these values in the code to be near room temperature, or gently warm the BME280 sensor with your finger (it will rise a few degrees). Pressing the mode button silences the buzzer.
🔗Step 6: Configure Your Timezone
Adjust the GMT_OFFSET_SEC constant for your timezone. Multiply your UTC offset by 3600:
| Timezone | UTC Offset | GMT_OFFSET_SEC |
|---|---|---|
| GMT / UTC | +0 | 0 |
| CET (Paris, Berlin) | +1 | 3600 |
| EET (Helsinki, Athens) | +2 | 7200 |
| EST (New York) | -5 | -18000 |
| PST (Los Angeles) | -8 | -28800 |
If your region observes daylight saving time, set DST_OFFSET_SEC to 3600 during summer months.
🔗MQTT Topics Reference
| Topic | Direction | Description |
|---|---|---|
thermostat/status | ESP32 publishes | JSON with temperature, setpoint, mode, relay state |
thermostat/command | ESP32 subscribes | Control commands (setpoint, mode, schedule, etc.) |
🔗Available Commands
| Command | Example | Description |
|---|---|---|
setpoint:XX.X | setpoint:22.5 | Set target temperature (5.0 to 40.0) |
mode:heat | mode:heat | Switch to heating mode |
mode:cool | mode:cool | Switch to cooling mode |
mode:off | mode:off | Turn off relay control |
hysteresis:X.X | hysteresis:1.0 | Set hysteresis band (0.1 to 5.0) |
schedule:on | schedule:on | Enable day/night schedule |
schedule:off | schedule:off | Disable schedule |
day:XX.X | day:22.0 | Set daytime setpoint |
night:XX.X | night:18.0 | Set nighttime setpoint |
status | status | Request current status JSON |
🔗Common Issues and Solutions
| Problem | Cause | Fix |
|---|---|---|
| Encoder skips steps or counts wrong | Noisy signal or wrong pins | Use pins that support interrupts (GPIO 32, 33). Add 100nF capacitors between CLK/DT and GND for debouncing |
| Encoder direction is reversed | CLK and DT swapped | Swap the two wires, or swap ENC_CLK and ENC_DT in the code |
| Button press registers multiple times | Mechanical bounce | The code includes a 300ms debounce. Increase DEBOUNCE_MS if needed |
| Temperature reads a few degrees high | BME280 self-heating | Wait 1-2 minutes after power-on. Mount the sensor away from the ESP32 and relay, ideally on a short cable |
| Relay clicks rapidly | Hysteresis too small | Increase hysteresis to 1.0 or more. Rapid cycling damages both the relay and the heater |
| Buzzer stays on | Temperature is genuinely outside alert range, or alertHighC / alertLowC values are too close to room temperature | Adjust the alert thresholds. Press the mode button to silence |
| BME280 not found | Wrong I2C address | Try 0x77 instead of 0x76. Run an I2C scanner sketch |
| OLED shows nothing | Wrong address or wiring | Most SSD1306 modules use 0x3C. Check SDA (GPIO 21) and SCL (GPIO 22) |
| Schedule does not switch | NTP not synced | Check WiFi connection. NTP needs internet access. The first sync may take a few seconds after boot |
| Setpoint resets after reboot | Settings are not saved to flash | Add EEPROM or Preferences library to persist setpoint and mode. See Extensions below |
| MQTT commands are ignored | Topic mismatch or not connected | Topics are case-sensitive. Check the Serial Monitor for MQTT connection status |
🔗Extending the Project
- Persistent settings: Use the ESP32 Preferences library to save the setpoint, mode, and schedule to flash memory so they survive reboots
- PID control: Replace the hysteresis logic with a PID controller for smoother, more precise temperature regulation using PWM-controlled heaters
- Multi-zone: Add more BME280 sensors (using address
0x77for the second) and relays to control different rooms independently - Web interface: Add an ESP32 web server with a settings page, so you can adjust the thermostat from a browser without needing MQTT
- Graphing: Log temperature data to InfluxDB and visualize trends with Grafana to see how well the thermostat maintains the target
- Home Assistant integration: Publish MQTT discovery messages so Home Assistant automatically detects and controls the thermostat