Smart Thermostat Advanced

Build a WiFi-connected thermostat with display, rotary encoder, and MQTT remote control

🔗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| B

Unlike 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

ComponentQtyNotes
ESP32 dev board1Any ESP32-WROOM-32 DevKit works
BME280 sensor module1I2C breakout board (not BMP280 -- that one lacks humidity)
SSD1306 OLED display10.96 inch, 128x64 pixels, I2C
Rotary encoder with push button1KY-040 or similar, 5-pin module
Single-channel relay module15V relay with optocoupler isolation
Active buzzer module13.3V compatible
Breadboard1
Jumper wires~15Male-to-male and male-to-female

You will also need the following Arduino libraries. Install them via Sketch > Include Library > Manage Libraries:

LibraryAuthorPurpose
Adafruit BME280 LibraryAdafruitBME280 sensor driver
Adafruit Unified SensorAdafruitDependency for the BME280 library
Adafruit SSD1306AdafruitOLED display driver
Adafruit GFX LibraryAdafruitGraphics primitives (text, shapes)
PubSubClientNick O'LearyMQTT client
WiFiEspressifBuilt 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.

ComponentPinESP32 Pin
BME280VIN / VCC3.3V
BME280GNDGND
BME280SDAGPIO 21
BME280SCLGPIO 22
SSD1306 OLEDVCC3.3V
SSD1306 OLEDGNDGND
SSD1306 OLEDSDAGPIO 21
SSD1306 OLEDSCLGPIO 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.

ComponentPinESP32 Pin
EncoderCLKGPIO 32
EncoderDTGPIO 33
EncoderSWGPIO 25
Encoder+3.3V
EncoderGNDGND

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

ComponentPinESP32 Pin
Relay moduleVCC5V (from external supply or USB 5V)
Relay moduleGNDGND (shared with ESP32)
Relay moduleINGPIO 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

ComponentPinESP32 Pin
Buzzer (+)--GPIO 27
Buzzer (-)--GND

🔗System Modes

The thermostat operates in three modes, selectable by pressing the rotary encoder button:

ModeDisplay labelBehavior
HeatingHEATRelay activates when temperature drops below setpoint (with hysteresis)
CoolingCOOLRelay activates when temperature rises above setpoint (with hysteresis)
OffOFFRelay 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:

PeriodDefault hoursDefault setpoint
Day07:00 -- 22:0022.0 degrees C
Night22:00 -- 07:0018.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:

  1. Set the mode to HEAT
  2. Use the encoder to set the setpoint well above the current room temperature (e.g., $30.0\,°\text{C}$)
  3. The relay should click ON because the room is colder than $30.0 - 0.5 = 29.5\,°\text{C}$
  4. Lower the setpoint to well below room temperature (e.g., $15.0\,°\text{C}$)
  5. 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:

TimezoneUTC OffsetGMT_OFFSET_SEC
GMT / UTC+00
CET (Paris, Berlin)+13600
EET (Helsinki, Athens)+27200
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

TopicDirectionDescription
thermostat/statusESP32 publishesJSON with temperature, setpoint, mode, relay state
thermostat/commandESP32 subscribesControl commands (setpoint, mode, schedule, etc.)

🔗Available Commands

CommandExampleDescription
setpoint:XX.Xsetpoint:22.5Set target temperature (5.0 to 40.0)
mode:heatmode:heatSwitch to heating mode
mode:coolmode:coolSwitch to cooling mode
mode:offmode:offTurn off relay control
hysteresis:X.Xhysteresis:1.0Set hysteresis band (0.1 to 5.0)
schedule:onschedule:onEnable day/night schedule
schedule:offschedule:offDisable schedule
day:XX.Xday:22.0Set daytime setpoint
night:XX.Xnight:18.0Set nighttime setpoint
statusstatusRequest current status JSON

🔗Common Issues and Solutions

ProblemCauseFix
Encoder skips steps or counts wrongNoisy signal or wrong pinsUse pins that support interrupts (GPIO 32, 33). Add 100nF capacitors between CLK/DT and GND for debouncing
Encoder direction is reversedCLK and DT swappedSwap the two wires, or swap ENC_CLK and ENC_DT in the code
Button press registers multiple timesMechanical bounceThe code includes a 300ms debounce. Increase DEBOUNCE_MS if needed
Temperature reads a few degrees highBME280 self-heatingWait 1-2 minutes after power-on. Mount the sensor away from the ESP32 and relay, ideally on a short cable
Relay clicks rapidlyHysteresis too smallIncrease hysteresis to 1.0 or more. Rapid cycling damages both the relay and the heater
Buzzer stays onTemperature is genuinely outside alert range, or alertHighC / alertLowC values are too close to room temperatureAdjust the alert thresholds. Press the mode button to silence
BME280 not foundWrong I2C addressTry 0x77 instead of 0x76. Run an I2C scanner sketch
OLED shows nothingWrong address or wiringMost SSD1306 modules use 0x3C. Check SDA (GPIO 21) and SCL (GPIO 22)
Schedule does not switchNTP not syncedCheck WiFi connection. NTP needs internet access. The first sync may take a few seconds after boot
Setpoint resets after rebootSettings are not saved to flashAdd EEPROM or Preferences library to persist setpoint and mode. See Extensions below
MQTT commands are ignoredTopic mismatch or not connectedTopics 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 0x77 for 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