Siddhant Kumar
Project 003 · Smart Home

Learning Smart Thermostat.

A thermostat that stops being a dumb switch — it measures how fast your room actually heats and cools, learns when you are home, and starts the system early enough to hit the setpoint at the right moment rather than after it.

Intermediate 12–18 hours 48 min read SensorsMLHVAC
Jump to source Bill of materials
Learning Smart Thermostat — reference build illustration MCU VCC · GND · SIG · NC
Difficulty
Intermediate
Build time
12–18 hours
Indicative cost
₹3,100 – ₹4,000
Platform
ESP32 DevKit V1 (ESP-WROOM-32)
Category
Smart Home
Last updated
28 July 2026
Contents — 26 sections

Project Overview

A thermostat that stops being a dumb switch — it measures how fast your room actually heats and cools, learns when you are home, and starts the system early enough to hit the setpoint at the right moment rather than after it.

A conventional thermostat implements one rule: if the temperature is below the setpoint, turn the heat on. It works, and it produces a room that is either slightly too cold or slightly too warm nearly all the time, with the system short-cycling at the boundary. Every improvement in this project comes from replacing that rule with something that understands the room has thermal mass and the system has lag.

Three ideas do the heavy lifting. Hysteresis stops short-cycling by using two thresholds instead of one. PI control with a duty cycle replaces bang-bang switching with proportional output, so the room settles at the setpoint rather than oscillating around it. And learned thermal parameters — measured, not guessed — let the controller predict how long a change will take and start early.

The learning part is genuinely simple mathematics, which is why it is worth doing rather than hand-waving about machine learning. A room warming towards a source behaves as a first-order system: the rate of change is proportional to the temperature difference. Two constants describe it — a heating rate and a cooling time constant — and both can be measured by the device itself during ordinary operation. From those two numbers you can answer the only question that matters: "if I want 22 °C at 07:00, when must I start?"

On top of that sits an occupancy schedule the device builds from a PIR sensor. Not a neural network — a seven-by-forty-eight grid of exponentially-weighted occupancy probabilities, one cell per half hour per weekday. After a fortnight it knows that the living room is empty from 09:00 to 18:00 on weekdays and it stops heating an empty room, which is where nearly all of the energy saving comes from.

An ESP32 development board with the ESP-WROOM-32 module and USB connector
An ESP32 development board — the thermostat controller. Photograph sourced from Wikimedia Commons — ESP32 Espressif ESP-WROOM-32 Dev Board.jpg. Reused under the licence stated on that page; please check it before republishing.

What this project does

  • Measures temperature, humidity and pressure with a BME280 and controls a heater or air conditioner through a relay.
  • Applies PI control with a slow duty cycle, so the room settles at the setpoint instead of oscillating.
  • Enforces a minimum off-time so the compressor or boiler can never short-cycle.
  • Measures the room's heating rate and cooling time constant automatically during normal operation.
  • Predicts a start time so the setpoint is reached at the scheduled moment, not thirty minutes later.
  • Learns an occupancy schedule from a PIR sensor and drops to a setback temperature when the room is reliably empty.
  • Exposes everything over MQTT with Home Assistant auto-discovery, so it appears as a proper climate entity.

Real-World Applications

SettingHow it is used
Domestic heatingReplaces a mechanical or simple digital thermostat and typically cuts runtime 10–25 % through setback alone.
Split-AC controlThe same logic with the sign inverted, driving an IR blaster or a relay on the compressor contactor.
Server and network cupboardsKeeps a small enclosed space inside a band and alerts when the cooling cannot keep up.
Greenhouse and grow tentsNight setback plus predictive ramp matters more here than in a house, because plants respond to the temperature curve.
Fermentation and proofing chambersTight band control with anti-short-cycling protection is exactly what a beer fridge or a bread proofer needs.
Holiday-home frost protectionRemote setpoint plus freeze protection lets an unoccupied property sit at 8 °C and be brought up before arrival.

Deployment contexts where a build of this kind earns its keep.

Features & Capabilities

  • Two-threshold hysteresis with configurable dead band, eliminating relay chatter at the setpoint.
  • PI controller with anti-windup driving a 10-minute duty cycle rather than a raw on/off decision.
  • Minimum run and minimum off timers — a hard protection for compressor-based systems.
  • Online thermal identification: heating rate in °C/min and cooling time constant in minutes, estimated by recursive least squares.
  • Predictive pre-heat that solves the first-order model for the required start time.
  • Learned weekly occupancy grid (7 × 48 cells, exponentially weighted) driving automatic setback.
  • Freeze and overheat protection that overrides every other rule.
  • Home Assistant MQTT discovery, so it appears as a native climate card with no YAML.

Difficulty, Time & Required Skills

AttributeValue
Difficulty levelIntermediate
Estimated completion time12–18 hours
Indicative build cost₹3,100 – ₹4,000
Primary disciplineSmart Home
Reference platformESP32 DevKit V1 (ESP-WROOM-32)

Skills you should have (or will pick up)

  • Arduino C++ with non-blocking timing using millis()
  • I²C bus basics and how to find a device address
  • Enough control theory to understand proportional and integral terms
  • Reading a first-order exponential response and extracting a time constant
  • Relay wiring for a heating or cooling load, including load ratings
  • MQTT topics and JSON payloads

Bill of Materials

Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.

ComponentKey specificationQtyApprox. cost
ESP32 DevKit V1 (ESP-WROOM-32)
Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC1₹450
BME280 pressure/humidity/temperature sensor
Self-heating skews temperature by ~1 °C — read in forced mode, not continuous.
300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz1₹420
HC-SR501 PIR motion sensor
Give it 60 s to settle after power-up or it fires false triggers.
3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle1₹80
5 V single-channel opto-isolated relay module
Cut the JD-VCC jumper and feed the coil separately for true isolation.
SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input1₹90
0.96″ SSD1306 OLED display
Static images burn in — invert or scroll the screen periodically.
128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C1₹250
DS3231 precision RTC + CR2032 backup
Vastly better than DS1307; some boards trickle-charge a non-rechargeable cell — cut that resistor.
±2 ppm (±1 min/year), temperature-compensated crystal, alarms, 32 kHz out1₹180
LM2596 adjustable buck converter module
Set the output voltage with no load connected before wiring the board.
4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency1₹90
5 V 3 A regulated SMPS adapter
Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.
100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection1₹350
Double-sided perfboard 7 × 9 cm + headers
Solder female headers so the MCU can be swapped without desoldering.
FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid1₹60
IP65 ABS junction enclosure 158 × 90 × 60 mm
Fit cable glands, not drilled holes, or the IP rating means nothing.
IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs1₹260
Rotary encoder with push button
Local setpoint adjustment — a thermostat that needs a phone is a worse thermostat.
20 detents/rev, integral switch1₹90
DS18B20 remote probe (optional)
Lets you sense at a better location than the wall box.
Waterproof, 2 m lead1₹160

Estimated total: ₹2,480, excluding tools, shipping and consumables.

Tools and consumables

  • Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
  • Digital multimeter — continuity, DC volts and current ranges
  • Wire strippers, flush cutters and a small set of precision screwdrivers
  • Heat-shrink tubing and a heat gun (or a lighter, carefully)
  • A laptop with a USB port and the toolchain listed above

Hardware Specifications

PartSpecificationSupplyInterfaceReference
ESP32 DevKit V1 (ESP-WROOM-32)Dual-core Xtensa LX6 @ 240 MHz, 520 KB SRAM, 4 MB flash, Wi-Fi 802.11 b/g/n + BLE 4.2, 34 GPIO, 18× 12-bit ADC, 2× 8-bit DAC3.3 V logic / 5 V USBUART, SPI, I²C, I²S, CAN, PWMDatasheet
BME280 pressure/humidity/temperature sensor300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz1.7–3.6 V (module has 3.3 V LDO)I²C (0x76/0x77) or SPIDatasheet
HC-SR501 PIR motion sensor3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle4.5–20 VDigital high on motionDatasheet
5 V single-channel opto-isolated relay moduleSPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input5 V coilDigital (active-low)Datasheet
0.96″ SSD1306 OLED display128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C3.3–5 VI²C (0x3C)Datasheet
DS3231 precision RTC + CR2032 backup±2 ppm (±1 min/year), temperature-compensated crystal, alarms, 32 kHz out2.3–5.5 VI²C (0x68)Datasheet
LM2596 adjustable buck converter module4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency4.5–40 VScrew terminals + trimmerDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet
IP65 ABS junction enclosure 158 × 90 × 60 mmIP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugsDatasheet

Consolidated electrical and interface specifications for every active part in the build.

Power Budget & Supply Sizing

Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.

LoadSupply railTypical current (mA)Notes
ESP32 DevKit V1 (ESP-WROOM-32)3.3 V logic / 5 V USB160Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly.
BME280 pressure/humidity/temperature sensor1.7–3.6 V (module has 3.3 V LDO)0.4Self-heating skews temperature by ~1 °C — read in forced mode, not continuous.
HC-SR501 PIR motion sensor4.5–20 V0.05Give it 60 s to settle after power-up or it fires false triggers.
5 V single-channel opto-isolated relay module5 V coil70Cut the JD-VCC jumper and feed the coil separately for true isolation.
0.96″ SSD1306 OLED display3.3–5 V20Static images burn in — invert or scroll the screen periodically.
DS3231 precision RTC + CR2032 backup2.3–5.5 V0.2Vastly better than DS1307; some boards trickle-charge a non-rechargeable cell — cut that resistor.
LM2596 adjustable buck converter module4.5–40 V8Set the output voltage with no load connected before wiring the board.
5 V 3 A regulated SMPS adapter5 V3000Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A.

Summed typical draw is 3258.65 mA. With a 1.5× design margin the supply should deliver at least 4900 mA continuously at the stated rail voltage.

Software Requirements & Development Environment

Reference toolchain: Arduino IDE 2.3.x with the ESP32 board package 3.x (or PlatformIO on VS Code). Anything newer normally works; anything older may lack the board definitions used here.

  • Install the Arduino IDE 2.3.x (or PlatformIO if you prefer a real editor and dependency locking).
  • Add https://espressif.github.io/arduino-esp32/package_esp32_index.json under File → Preferences → Additional Board Manager URLs, then install esp32 from the Boards Manager.
  • Set the correct port under Tools → Port. On Linux add yourself to the dialout group: sudo usermod -aG dialout $USER and log out and back in.
  • Open the Serial Monitor at 115200 baud — every sketch here logs its state there.
  • Keep File → Preferences → Show verbose output during: compilation switched on while you are debugging build errors.

Required libraries

LibraryWhy it is neededInstall
WiFi (ESP32 core) bundledStation/AP connection management for the ESP32.Bundled with the ESP32 Arduino core
PubSubClient 2.8Lightweight MQTT 3.1.1 client for constrained devices.Library Manager → "PubSubClient" by Nick O'Leary
ArduinoJson 7.xZero-allocation JSON serialisation and parsing.Library Manager → "ArduinoJson" by Benoit Blanchon
Adafruit BME280 2.2.xCompensation maths for the Bosch pressure/humidity/temperature sensor.Library Manager → "Adafruit BME280 Library"
Adafruit Unified Sensor 1.1.xCommon sensor event abstraction; a dependency of most Adafruit drivers.Library Manager → "Adafruit Unified Sensor"
Adafruit SSD1306 + GFX 2.5.xFramebuffer and text/graphics primitives for the OLED.Library Manager → "Adafruit SSD1306"
Preferences (NVS) bundledWear-levelled key/value storage in ESP32 flash for settings.Bundled with the ESP32 core
NTPClient / configTime bundledWall-clock time from an NTP server for timestamping.Bundled (`configTime()` on ESP32)
PID_v1 1.2.1Proportional-integral-derivative controller with anti-windup.Library Manager → "PID" by Brett Beauregard

Block Diagram

The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.

Learning Smart Thermostat — system block diagramFunctional block diagram of the Learning Smart Thermostat system. SenseBME280T / RH / PPIR sensoroccupancyDS3231time of dayModelThermal identifierrate + τOccupancy grid7 × 48 cellsControlPI + hysteresisduty cycleCycle protectionmin on / offOutputRelayboiler / ACOLED + MQTTstate and historysamplessetpoint + modelcall for heat
Learning Smart Thermostat — system block diagram

Circuit Diagram & Wiring

Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.

Learning Smart Thermostat — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsESP32 DevKit V1(ESP-WROOM-32)3.3 V logic / 5 V USBBME280 sensorGPIO 21 / 22I²C at 0x76SSD1306 OLEDGPIO 21 / 22Same bus, 0x3CDS3231 RTCGPIO 21 / 22Same bus, 0x68HC-SR501 PIRGPIO 27High while motiondetectedRotary encoderGPIO 32 / 33 / 25Quadrature + buttonRelay → boiler / contactorGPIO 26Active-low,opto-isolatedStatus LEDGPIO 2Through 330 Ω
Learning Smart Thermostat — wiring schematic
PeripheralPeripheral pinController pinSignal
BME280 sensorSDA / SCLGPIO 21 / 22I²C at 0x76
SSD1306 OLEDSDA / SCLGPIO 21 / 22Same bus, 0x3C
DS3231 RTCSDA / SCLGPIO 21 / 22Same bus, 0x68
HC-SR501 PIROUTGPIO 27High while motion detected
Rotary encoderA / B / SWGPIO 32 / 33 / 25Quadrature + button
Relay → boiler / contactorINGPIO 26Active-low, opto-isolated
Status LEDAnodeGPIO 2Through 330 Ω

Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.

Wiring explanation

  • Three I²C devices share one bus at three different addresses (0x76, 0x3C, 0x68). Run an I²C scanner first and confirm you see all three before writing any application code.
  • Mount the BME280 away from the ESP32 and the OLED. Both are heat sources; a sensor 10 mm from the regulator will read one to two degrees high and no amount of software will fix that. A short ribbon cable moving it 50 mm away is the correct answer.
  • Do not enclose the BME280 in a sealed box. It needs airflow to track the room, and the humidity channel needs it more than the temperature channel.
  • The PIR needs a stable 5 V and about 60 seconds of settling time after power-up. Ignore its output for the first minute after boot or you will record phantom occupancy.
  • The relay switches the boiler's low-voltage call-for-heat loop in most domestic systems, not mains. Check yours: some older systems switch 230 V through the thermostat, in which case every mains precaution applies.
A single-channel relay module with screw terminals
An opto-isolated relay module of the kind used to switch the boiler call-for-heat loop. Photograph sourced from Wikimedia Commons — Relay module.jpg. Reused under the licence stated on that page; please check it before republishing.

System Architecture

Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.

Learning Smart Thermostat — architecture stackLayered architecture from hardware to user interface. Hardware layerESP32 DevKit V1 (ESP-WROOM-32) · BME280 pressure/humidity/temperaturesensor · HC-SR501 PIR motion sensorDriver layerwifi · pubsub · arduinojson · bmeApplication logicsampling loop · filtering · thresholds · state machineTransport layerWi-Fi + MQTT · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Learning Smart Thermostat — architecture stack

Working Principle

A room is a first-order thermal system to a very good approximation. Write T for room temperature, T∞ for outside (or the temperature the room drifts to with no input), and P for heater power. Newton's law of cooling gives dT/dt = −(T − T∞)/τ + k·P, where τ is the thermal time constant — the room's heat capacity divided by its loss coefficient — and k converts heater power into a heating rate.

That single equation explains everything a naive thermostat gets wrong. Because τ for a typical room is 60–240 minutes, temperature responds to a change in heating with a long lag. A controller that reacts only to the present error is always chasing a signal it caused twenty minutes ago, which is precisely why bang-bang control overshoots.

The device measures both constants rather than assuming them. When the heater runs and the room is well below T∞, the observed slope is dominated by k·P, giving the heating rate directly in °C per minute. When the heater is off, the room decays exponentially towards T∞, and fitting ln(T − T∞) against time yields τ as the negative reciprocal of the slope. Both estimates are updated continuously with an exponentially weighted recursive fit so the model tracks the seasons.

Control uses a PI controller driving a duty cycle. The proportional term responds to the current error; the integral term accumulates persistent error and removes the steady-state offset that pure proportional control always leaves. The output is not an on/off decision but a fraction of a ten-minute window: an output of 0.3 means the relay is on for three minutes and off for seven. That converts a binary actuator into an effectively analogue one, which is what lets the room settle rather than oscillate. The integral term is clamped — anti-windup — so that a morning where the heater simply cannot keep up does not accumulate an enormous integral that then overshoots massively once it can.

The derivative term is deliberately omitted. On a slow thermal process with a noisy sensor, the D term amplifies measurement noise far more than it improves response, and every practical thermostat implementation ends up with it either zeroed or so heavily filtered as to be irrelevant.

Predictive pre-heat is then a matter of inverting the model. Given the current temperature T₀, the target T_set, and the measured heating rate, the required lead time is approximately (T_set − T₀) / rate, corrected for the loss term. The device solves this every minute and starts heating exactly when the answer says the remaining time equals the time to the scheduled setpoint change.

The maths behind it

First-order thermal model

plainFirst-order thermal model
dT/dt = −(T − T∞)/τ + k·P

Heater off, solved:
  T(t) = T∞ + (T₀ − T∞)·e^(−t/τ)

Measuring τ from a cooling curve:
  ln(T(t) − T∞) = ln(T₀ − T∞) − t/τ
  → τ = −1 / slope of the log-linear fit

Typical measured values (3 m × 4 m insulated room):
  τ    ≈ 145 min
  rate ≈ 0.048 °C/min with a 2 kW heater

PI controller with anti-windup

plainPI controller with anti-windup
e(t)      = T_set − T
I         = clamp(I + e·Δt, −I_max, +I_max)
u         = Kp·e + Ki·I
duty      = clamp(u, 0, 1)

Starting values that work on a domestic room:
  Kp    = 0.45  per °C
  Ki    = 0.0012 per °C·s
  I_max = 600   °C·s     (≈ 0.72 duty of authority)
  cycle = 600   s

Ki looks tiny because the integral accumulates in °C-seconds; over a ten-minute persistent 1 °C error it contributes 0.72 of duty, which is the intended authority.

Predictive start time

plainPredictive start time
time_to_target = (T_set − T₀) / (rate − (T₀ − T∞)/τ)

Example: T₀ = 16 °C, T_set = 21 °C, T∞ = 8 °C
  loss term = (16 − 8)/145 = 0.055 °C/min
  net rate  = 0.048 − 0.055 → negative!

When the net rate is negative the heater cannot reach the setpoint
at this outside temperature — the controller must report that rather
than run forever.

This example is deliberately chosen to show the failure case. A real system should detect an unattainable setpoint and raise an alert rather than running the heater continuously all night.

Program Flowchart

The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.

Learning Smart Thermostat — firmware flowchartControl flow through the main program loop. Boot: read stored thermalmodel and scheduleSample BME280 and PIR every 5sUpdate occupancy grid cell forthis half hourOccupied, or pre-heatwindow open?comfort setpointsetback setpointCompute PI output over the10-minute cycleMinimum off-time elapsed?yeshold relay offDrive relay for the computedon-fractionUpdate thermal estimate fromobserved slopePublish state over MQTT
Learning Smart Thermostat — firmware flowchart

Assembly Instructions

Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.

Step-by-Step Implementation Guide

Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.

  1. Read the sensor without self-heating error

    The BME280 heats itself when read continuously. Forced mode — take one measurement then return to sleep — keeps the die close to ambient and is what the datasheet recommends for any application where the temperature reading matters.

    cpp01-bme-forced.ino
    #include <Wire.h>
    #include <Adafruit_BME280.h>
    
    Adafruit_BME280 bme;
    
    void sensorBegin() {
      Wire.begin(21, 22);
      if (!bme.begin(0x76)) { Serial.println("BME280 not found at 0x76"); while (1) delay(100); }
    
      // Forced mode: the chip sleeps between measurements, so it does not
      // warm itself. x1 oversampling on T and P, x1 on humidity, filter off.
      bme.setSampling(Adafruit_BME280::MODE_FORCED,
                      Adafruit_BME280::SAMPLING_X1,   // temperature
                      Adafruit_BME280::SAMPLING_X1,   // pressure
                      Adafruit_BME280::SAMPLING_X1,   // humidity
                      Adafruit_BME280::FILTER_OFF);
    }
    
    struct Reading { float tC, rh, hPa; };
    
    Reading sensorRead() {
      bme.takeForcedMeasurement();      // wakes, converts, sleeps again
      return { bme.readTemperature(), bme.readHumidity(), bme.readPressure() / 100.0f };
    }
    MODE_FORCEDIn normal mode the BME280 converts continuously and the die sits 1–2 °C above ambient. Forced mode is the difference between a thermostat that is accurate and one that is confidently wrong.
    FILTER_OFFThe IIR filter helps with pressure noise but adds lag to temperature. On a system with a 145-minute time constant, sensor lag is the last thing you want to add.
  2. Implement PI control with a duty cycle

    The controller runs once per second but its output only changes the relay at cycle boundaries. Keeping those two rates separate is what stops the relay from following the noise on the sensor.

    cpp02-pi-duty.ino
    const float  KP        = 0.45f;      // per °C
    const float  KI        = 0.0012f;   // per °C·second
    const float  I_MAX     = 600.0f;    // °C·s
    const uint32_t CYCLE_MS   = 600000UL;  // 10 minutes
    const uint32_t MIN_ON_MS  = 180000UL;  // 3 min — protects a compressor
    const uint32_t MIN_OFF_MS = 300000UL;  // 5 min
    
    static float    integral   = 0;
    static uint32_t cycleStart = 0;
    static float    dutyThisCycle = 0;
    static uint32_t lastSwitch = 0;
    static bool     heating    = false;
    
    float piUpdate(float setpoint, float measured, float dtSeconds) {
      float e = setpoint - measured;
    
      integral += e * dtSeconds;
      if (integral >  I_MAX) integral =  I_MAX;      // anti-windup clamp
      if (integral < -I_MAX) integral = -I_MAX;
    
      float u = KP * e + KI * integral;
      return u < 0 ? 0 : (u > 1 ? 1 : u);            // duty in [0, 1]
    }
    
    void relayService(float duty) {
      uint32_t now = millis();
    
      if (now - cycleStart >= CYCLE_MS) {            // new cycle: latch the duty
        cycleStart = now;
        dutyThisCycle = duty;
      }
    
      uint32_t elapsed = now - cycleStart;
      bool want = elapsed < (uint32_t)(dutyThisCycle * CYCLE_MS);
    
      // Cycle protection overrides the controller, always.
      if (want != heating) {
        uint32_t held = now - lastSwitch;
        if ( heating && held < MIN_ON_MS)  want = true;
        if (!heating && held < MIN_OFF_MS) want = false;
      }
    
      if (want != heating) {
        heating = want;
        lastSwitch = now;
        digitalWrite(PIN_RELAY, heating ? LOW : HIGH);   // active-low module
      }
    }
    dutyThisCycle latched at cycle startThe duty is sampled once per cycle rather than recomputed continuously. Otherwise a duty that drifts upward mid-cycle can retrigger the relay, defeating the whole point of a duty-cycle scheme.
    integral clampWithout the clamp, an hour where the heater cannot reach the setpoint accumulates an enormous integral. The room then overshoots by several degrees once conditions improve — the classic windup failure.
    MIN_ON_MS / MIN_OFF_MS overrideThese are protection, not control, so they sit outside the controller and win unconditionally. A compressor started within five minutes of stopping can be damaged by the pressure differential.
  3. Identify the thermal constants online

    Both constants come out of ordinary operation — there is no calibration mode to run. The estimator only updates when conditions are clean enough for the estimate to mean something.

    cpp03-thermal-id.ino
    struct Thermal {
      float heatRate;   // °C per minute with the heater on
      float tau;        // minutes, cooling time constant
      float tInf;       // °C the room drifts towards
    };
    Thermal model = { 0.05f, 120.0f, 12.0f };   // sane starting guesses
    
    // Called once a minute with the temperature one minute ago and now.
    void thermalUpdate(float tPrev, float tNow, bool heaterWasOn, float outsideC) {
      float slope = tNow - tPrev;                       // °C per minute
      const float ALPHA = 0.02f;                        // slow EWMA
    
      if (heaterWasOn && tNow < tPrev + 1.0f) {
        // Heating: slope ≈ heatRate − (T − T∞)/τ, so add back the loss term.
        float loss = (tNow - model.tInf) / model.tau;
        float est  = slope + loss;
        if (est > 0.005f && est < 0.5f)                 // reject nonsense
          model.heatRate = (1 - ALPHA) * model.heatRate + ALPHA * est;
      }
    
      if (!heaterWasOn && fabsf(tNow - outsideC) > 3.0f && slope < -0.001f) {
        // Cooling: τ = −(T − T∞) / slope
        float est = -(tNow - outsideC) / slope;
        if (est > 15.0f && est < 600.0f)
          model.tau = (1 - ALPHA) * model.tau + ALPHA * est;
      }
    
      model.tInf = (1 - 0.005f) * model.tInf + 0.005f * outsideC;
    }
    
    // Minutes needed to move from tNow to tTarget, or -1 if unreachable.
    float minutesToTarget(float tNow, float tTarget) {
      float loss = (tNow - model.tInf) / model.tau;
      float net  = model.heatRate - loss;
      if (net <= 0.002f) return -1;                     // heater cannot win
      return (tTarget - tNow) / net;
    }
    if (est > 0.005f && est < 0.5f)Sanity gates. A window opening, a door slamming or someone standing next to the sensor produces slopes that are real measurements of the wrong thing. Rejecting implausible estimates is what keeps the model from being destroyed by one bad hour.
    fabsf(tNow − outsideC) > 3.0fThe cooling time constant is only observable when there is a meaningful temperature difference. Close to equilibrium the slope is dominated by noise and the estimate diverges.
    ALPHA = 0.02An exponentially weighted average with an effective memory of about 50 samples — roughly an hour of updates. Slow enough to reject transients, fast enough to follow a change of season over a fortnight.
    return −1 when net ≤ 0Reporting "unreachable" is a feature. A thermostat that silently runs a heater all night against an unattainable setpoint is worse than one that says it cannot get there.

Complete Source Code

The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.

cpplearning-thermostat.ino
/* ═══════════════════════════════════════════════════════════════
   Learning Smart Thermostat — ESP32 + BME280 + PIR + relay

   PI control on a 10-minute duty cycle with compressor protection,
   online identification of the room's heating rate and cooling time
   constant, a learned weekly occupancy grid, and Home Assistant MQTT
   auto-discovery.
   ══════════════════════════════════════════════════════════════════ */

#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <time.h>
#include <math.h>

#define WIFI_SSID  "YOUR_WIFI"
#define WIFI_PASS  "YOUR_PASSWORD"
#define MQTT_HOST  "192.168.1.50"
#define DEVICE_ID  "thermostat-living"

#define PIN_RELAY  26
#define PIN_PIR    27
#define PIN_ENC_A  32
#define PIN_ENC_B  33
#define PIN_ENC_SW 25
#define PIN_LED     2

#define COMFORT_C   21.5f
#define SETBACK_C   17.0f
#define FREEZE_C     7.0f
#define DEAD_BAND    0.3f

const float    KP = 0.45f, KI = 0.0012f, I_MAX = 600.0f;
const uint32_t CYCLE_MS = 600000UL, MIN_ON_MS = 180000UL, MIN_OFF_MS = 300000UL;

Adafruit_BME280  bme;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
WiFiClient       net;
PubSubClient     mqtt(net);
Preferences      prefs;

struct Thermal { float heatRate, tau, tInf; } model = { 0.05f, 120.0f, 12.0f };

float    setpoint = COMFORT_C, roomC = 20, roomRh = 50, outsideC = 12;
float    integral = 0, duty = 0, dutyThisCycle = 0;
bool     heating = false, occupied = false, manualHold = false;
uint32_t cycleStart = 0, lastSwitch = 0, lastSample = 0, lastMinute = 0;
float    tPrevMinute = 20;
bool     heaterWasOnLastMinute = false;

/* Occupancy: 7 days x 48 half-hour slots, probability 0..1 */
float occGrid[7][48];

/* ── occupancy ──────────────────────────────────────────────── */
int slotNow(struct tm &t) { return t.tm_hour * 2 + (t.tm_min >= 30 ? 1 : 0); }

void occUpdate(bool motion) {
  time_t now = time(nullptr);
  struct tm t; localtime_r(&now, &t);
  float &cell = occGrid[t.tm_wday][slotNow(t)];
  cell = 0.94f * cell + 0.06f * (motion ? 1.0f : 0.0f);
}

bool occLikely(int minutesAhead) {
  time_t now = time(nullptr) + minutesAhead * 60;
  struct tm t; localtime_r(&now, &t);
  return occGrid[t.tm_wday][slotNow(t)] > 0.35f;
}

void occSave() {
  prefs.putBytes("occ", occGrid, sizeof(occGrid));
  prefs.putBytes("model", &model, sizeof(model));
}

/* ── thermal identification ─────────────────────────────────── */
void thermalUpdate(float tPrev, float tNow, bool heaterOn) {
  float slope = tNow - tPrev;
  const float A = 0.02f;

  if (heaterOn && slope < 1.0f) {
    float est = slope + (tNow - model.tInf) / model.tau;
    if (est > 0.005f && est < 0.5f) model.heatRate = (1 - A) * model.heatRate + A * est;
  }
  if (!heaterOn && fabsf(tNow - outsideC) > 3.0f && slope < -0.001f) {
    float est = -(tNow - outsideC) / slope;
    if (est > 15.0f && est < 600.0f) model.tau = (1 - A) * model.tau + A * est;
  }
  model.tInf = 0.995f * model.tInf + 0.005f * outsideC;
}

float minutesToTarget(float tNow, float tTarget) {
  float net = model.heatRate - (tNow - model.tInf) / model.tau;
  return net <= 0.002f ? -1 : (tTarget - tNow) / net;
}

/* ── control ────────────────────────────────────────────────── */
float piUpdate(float sp, float pv, float dt) {
  float e = sp - pv;
  if (fabsf(e) < DEAD_BAND) e = 0;              // dead band kills chatter
  integral += e * dt;
  integral = integral >  I_MAX ?  I_MAX : (integral < -I_MAX ? -I_MAX : integral);
  float u = KP * e + KI * integral;
  return u < 0 ? 0 : (u > 1 ? 1 : u);
}

void relayService(float d) {
  uint32_t now = millis();
  if (now - cycleStart >= CYCLE_MS) { cycleStart = now; dutyThisCycle = d; }

  bool want = (now - cycleStart) < (uint32_t)(dutyThisCycle * CYCLE_MS);
  if (roomC < FREEZE_C) want = true;             // freeze protection wins

  if (want != heating) {
    uint32_t held = now - lastSwitch;
    if ( heating && held < MIN_ON_MS)  want = true;
    if (!heating && held < MIN_OFF_MS) want = false;
  }
  if (want != heating) {
    heating = want;
    lastSwitch = now;
    digitalWrite(PIN_RELAY, heating ? LOW : HIGH);
    digitalWrite(PIN_LED, heating);
  }
}

/* ── target selection, including pre-heat ───────────────────── */
float chooseSetpoint() {
  if (manualHold) return setpoint;
  if (occLikely(0)) return COMFORT_C;

  // Look up to two hours ahead; start early if the model says we must.
  for (int ahead = 10; ahead <= 120; ahead += 10) {
    if (!occLikely(ahead)) continue;
    float need = minutesToTarget(roomC, COMFORT_C);
    if (need > 0 && need >= ahead) return COMFORT_C;   // start now
    break;
  }
  return SETBACK_C;
}

/* ── MQTT ───────────────────────────────────────────────────── */
void publishDiscovery() {
  JsonDocument d;
  d["name"] = "Living Room Thermostat";
  d["unique_id"] = DEVICE_ID;
  d["modes"][0] = "off"; d["modes"][1] = "heat";
  d["current_temperature_topic"] = "home/climate/" DEVICE_ID "/state";
  d["current_temperature_template"] = "{{ value_json.room_c }}";
  d["temperature_state_topic"] = "home/climate/" DEVICE_ID "/state";
  d["temperature_state_template"] = "{{ value_json.setpoint }}";
  d["temperature_command_topic"] = "home/climate/" DEVICE_ID "/set";
  d["min_temp"] = 7; d["max_temp"] = 28; d["temp_step"] = 0.5;
  char buf[640];
  size_t n = serializeJson(d, buf, sizeof(buf));
  mqtt.publish("homeassistant/climate/" DEVICE_ID "/config", (uint8_t *)buf, n, true);
}

void publishState() {
  JsonDocument d;
  d["room_c"]    = roundf(roomC * 10) / 10.0f;
  d["rh"]        = roundf(roomRh);
  d["setpoint"]  = setpoint;
  d["duty"]      = roundf(duty * 100) / 100.0f;
  d["heating"]   = heating;
  d["occupied"]  = occupied;
  d["heat_rate"] = roundf(model.heatRate * 1000) / 1000.0f;
  d["tau_min"]   = roundf(model.tau);
  d["t_inf"]     = roundf(model.tInf * 10) / 10.0f;
  char buf[256];
  size_t n = serializeJson(d, buf, sizeof(buf));
  mqtt.publish("home/climate/" DEVICE_ID "/state", (uint8_t *)buf, n, true);
}

void onMessage(char *topic, byte *payload, unsigned int len) {
  char v[16] = {0};
  memcpy(v, payload, len < 15 ? len : 15);
  float sp = atof(v);
  if (sp >= 7 && sp <= 28) { setpoint = sp; manualHold = true; integral = 0; }
}

/* ── display ────────────────────────────────────────────────── */
void draw() {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(3);
  oled.setCursor(0, 4);  oled.printf("%.1f", roomC);
  oled.setTextSize(1);
  oled.setCursor(96, 6); oled.print("degC");
  oled.setCursor(0, 34); oled.printf("set %.1f  rh %.0f%%", setpoint, roomRh);
  oled.setCursor(0, 46); oled.printf("duty %3.0f%%  %s", duty * 100, heating ? "HEAT" : "idle");
  oled.setCursor(0, 56); oled.printf("tau %.0fm  %.3f C/min", model.tau, model.heatRate);
  oled.display();
}

/* ── setup / loop ───────────────────────────────────────────── */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_RELAY, OUTPUT); digitalWrite(PIN_RELAY, HIGH);
  pinMode(PIN_LED, OUTPUT);
  pinMode(PIN_PIR, INPUT);

  Wire.begin(21, 22);
  if (!bme.begin(0x76)) Serial.println("BME280 missing");
  bme.setSampling(Adafruit_BME280::MODE_FORCED, Adafruit_BME280::SAMPLING_X1,
                  Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1,
                  Adafruit_BME280::FILTER_OFF);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  prefs.begin("thermo", false);
  if (prefs.getBytesLength("occ") == sizeof(occGrid)) prefs.getBytes("occ", occGrid, sizeof(occGrid));
  if (prefs.getBytesLength("model") == sizeof(model)) prefs.getBytes("model", &model, sizeof(model));

  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
  configTime(19800, 0, "pool.ntp.org");
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.setCallback(onMessage);
  mqtt.setBufferSize(768);

  delay(60000);                        // let the PIR settle before trusting it
  Serial.println("Thermostat running");
}

void loop() {
  if (!mqtt.connected() && WiFi.status() == WL_CONNECTED) {
    if (mqtt.connect(DEVICE_ID)) {
      mqtt.subscribe("home/climate/" DEVICE_ID "/set");
      publishDiscovery();
    }
  }
  mqtt.loop();

  uint32_t now = millis();

  if (now - lastSample >= 5000) {
    float dt = (now - lastSample) / 1000.0f;
    lastSample = now;

    bme.takeForcedMeasurement();
    roomC  = bme.readTemperature();
    roomRh = bme.readHumidity();
    occupied = digitalRead(PIN_PIR);
    occUpdate(occupied);

    setpoint = manualHold ? setpoint : chooseSetpoint();
    duty = piUpdate(setpoint, roomC, dt);
    draw();
  }

  relayService(duty);

  if (now - lastMinute >= 60000) {
    lastMinute = now;
    thermalUpdate(tPrevMinute, roomC, heaterWasOnLastMinute);
    tPrevMinute = roomC;
    heaterWasOnLastMinute = heating;
    publishState();
    static uint8_t saveCounter = 0;
    if (++saveCounter >= 30) { saveCounter = 0; occSave(); }   // every 30 min
  }
}
occGrid[7][48]336 floats — 1.3 KB — is the entire occupancy model. It is deliberately not machine learning: an exponentially weighted per-slot average is interpretable, debuggable, and in practice indistinguishable in accuracy from anything more elaborate for this task.
cell = 0.94f * cell + 0.06f * motionAn EWMA with an effective memory of about 16 observations of that slot — roughly four months of weekdays. Slow enough that one late night does not rewrite the schedule, fast enough to follow a genuine change in routine.
chooseSetpoint() look-ahead loopWalks forward in ten-minute steps to find the next likely-occupied slot, then asks the thermal model whether it needs to start now to arrive on time. This is the whole point of measuring the heating rate.
if (roomC < FREEZE_C) want = trueFreeze protection sits inside relayService, below the controller, so no combination of setpoint, schedule or manual hold can bypass it. Burst pipes cost more than a night of heating.
saveCounter >= 30NVS is flash with a finite erase-cycle budget. Saving every 30 minutes rather than every minute extends the flash life from months to decades.
delay(60000) in setupThe HC-SR501 outputs garbage for roughly a minute after power-up. Blocking here is acceptable because it happens once, and it prevents the occupancy grid being polluted at every boot.

Configuration & Calibration

Configuration steps

  • Set COMFORT_C, SETBACK_C and FREEZE_C. A setback 4–5 °C below comfort captures most of the saving; deeper setbacks cost more to recover than they save on a well-insulated room.
  • Set DEAD_BAND to at least your sensor noise — 0.3 °C suits a BME280. Too small and the relay chatters; too large and the room drifts visibly.
  • Set MIN_OFF_MS to your equipment manufacturer's figure. Five minutes is a safe default for a compressor; a resistive heater or a modulating boiler can use much less.
  • Feed a real outside temperature into outsideC — either a second DS18B20 outdoors, or a value published from a weather API through MQTT. Without it, the τ estimate degrades.
  • Leave the thermostat running for at least a week before judging it. The occupancy grid needs that long to mean anything, and the thermal model needs several heat-and-cool cycles.

Calibration procedure

An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.

  1. Check the sensor against a reference

    Place a known-good thermometer next to the BME280 for an hour and note the offset. A consistent offset of up to about 1 °C is normal and should be corrected in software with a constant; a drifting difference means the sensor is being warmed by something nearby and needs to be moved.

  2. Verify the thermal model after 48 hours

    Compare the reported tau_min against a manual measurement: turn the heat off with the room 8 °C above outside and time how long it takes to fall by 63 % of that difference. That time is τ. The two should agree within about 20 %.

  3. Tune Kp and Ki if it overshoots

    A room that overshoots the setpoint by more than 0.5 °C has too much integral authority — halve KI. A room that takes hours to close the last half degree has too little — double it. Change one term at a time and give each change a full day.

Network Architecture & Connectivity

Learning Smart Thermostat — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsThermostatESP32 + BME280Outdoor probeDS18B20Wi-Fi 2.4 GHzHome routerIoT VLANMQTT 1883Mosquitto + HAlocal, no cloudHome Assistantclimate cardGrafanaruntime history
Learning Smart Thermostat — network topology

Communication protocol

State is published retained, which is correct here because temperature and setpoint are state rather than events — a dashboard connecting at any moment should immediately learn the current value rather than wait up to a minute for the next update.

Home Assistant MQTT discovery means the device describes itself: publishing a config document to homeassistant/climate/<id>/config creates a fully functional climate entity with no configuration file editing at all.

Topic / endpointDirectionPayload
home/climate/thermostat-living/statedevice → broker (retained)JSON: room_c, rh, setpoint, duty, heating, occupied, heat_rate, tau_min, t_inf
home/climate/thermostat-living/setbroker → devicePlain float setpoint in °C
homeassistant/climate/thermostat-living/configdevice → broker (retained)Home Assistant discovery document

Message contract between the device and the broker.

Dashboard setup

The panel worth building is not current temperature — the thermostat already shows that. Plot duty cycle against outside temperature over a season: the slope of that line is your building's heat loss coefficient, and it is the number that tells you whether insulation work paid for itself.

Security considerations

  • A thermostat with an open MQTT endpoint can be set to 28 °C by anyone on the network. Use broker authentication and a per-device ACL.
  • Clamp the accepted setpoint range in firmware (7–28 °C here) so a malformed or malicious message cannot command something dangerous.
  • Keep freeze protection outside the network path entirely — it must work with the broker unreachable.

Testing Procedure & Expected Output

Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.

TestWhat you should see
Run an I²C scannerAddresses 0x76 (BME280), 0x3C (OLED) and 0x68 (RTC) all reported.
Compare the reading against a reference thermometerWithin about 1 °C after ten minutes of settling, with no upward drift.
Set the setpoint 3 °C above the roomDuty rises towards 1.0, the relay closes, and the LED lights. The room warms without the relay chattering.
Set the setpoint 3 °C below the roomDuty falls to 0 and the relay opens — but not before MIN_ON_MS has elapsed.
Toggle the setpoint rapidly around the room temperatureThe relay does not follow. The dead band and minimum timers absorb it entirely.
Let it run 48 hours, then read the state topictau_min settles somewhere between 60 and 250 minutes and heat_rate between 0.02 and 0.1 °C/min for a typical room.
Cover the PIR for a full working dayThat day's daytime occupancy cells decay and the setpoint drops to setback the following week at the same time.
Check Home AssistantA climate card appears automatically showing current temperature and an adjustable setpoint.

Bench-test checklist. If a row fails, stop and fix it before moving on.

Expected output

jsonmqtt-state.json
{
  "room_c": 20.4,
  "rh": 47,
  "setpoint": 21.5,
  "duty": 0.62,
  "heating": true,
  "occupied": true,
  "heat_rate": 0.047,
  "tau_min": 148,
  "t_inf": 11.8
}
A Grafana time-series dashboard
A time-series dashboard. Plotting duty cycle against outside temperature over a season quantifies a building's heat loss. Photograph sourced from Wikimedia Commons — Grafana dashboard.png. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

The reported temperature is one to two degrees higher than the room

Likely cause. Self-heating. Either the BME280 is in continuous mode, or it is physically too close to the ESP32 regulator, the OLED or the relay coil.

Fix. Confirm MODE_FORCED is set. Then move the sensor at least 50 mm away from every other component on a short ribbon cable, with airflow around it. Only after both of those should you apply a software offset — an offset applied to a self-heating sensor is wrong at every other ambient temperature.

The relay clicks on and off every few seconds

Likely cause. No dead band, or the duty is being recomputed and applied continuously rather than latched per cycle.

Fix. Set DEAD_BAND to at least 0.3 °C, verify dutyThisCycle is only assigned at a cycle boundary, and confirm the minimum on and off timers are actually being applied — they are the last line of defence and their absence will destroy a compressor.

The room overshoots the setpoint by two degrees every morning

Likely cause. Integral windup during the overnight setback, when the error is large for hours.

Fix. Reduce I_MAX, and reset the integral to zero whenever the setpoint changes — this sketch does that in the MQTT handler but not on an automatic schedule change, which is worth adding.

tau_min drifts to an absurd value like 5 or 5000

Likely cause. Either outsideC is stale or wrong, or the estimator is updating during a disturbance such as an open window.

Fix. Feed a real outdoor temperature. Tighten the acceptance gates — the sketch rejects estimates outside 15–600 minutes, which you can narrow once you know your building. Also skip the update for ten minutes after any relay transition, since the transient is not a clean exponential.

Occupancy never triggers comfort mode

Likely cause. PIR wiring, the 60-second settling period, or a sensitivity trimmer turned down.

Fix. Print digitalRead(PIN_PIR) and wave at it — it should read high for the hold period. The HC-SR501 has two trimmers (sensitivity and hold time) and one jumper (retrigger mode); set retrigger to H so continuous motion holds the output high.

Home Assistant shows the device but the setpoint does nothing

Likely cause. The command topic in the discovery document does not match the topic the device subscribes to.

Fix. Subscribe to homeassistant/climate/# with mosquitto_sub and read the published config document. Every topic in it must match the firmware exactly, including case.

The sketch will not upload — "Failed to connect" or "avrdude: stk500_recv()"

Likely cause. The bootloader is not being reached: wrong port, wrong board, a serial monitor holding the port open, or a USB cable that only carries power.

Fix. Close every serial monitor, confirm Tools → Board and Port, and swap to a known data-capable USB cable. On an ESP32 hold BOOT while the IDE prints "Connecting…", then release. If a peripheral is wired to the UART pins (GPIO 1/3 on ESP32, D0/D1 on Uno) unplug it — it fights the programmer.

The board resets in a loop, or the serial monitor prints "Brownout detector was triggered"

Likely cause. The supply cannot deliver peak current. Wi-Fi transmit bursts, relay coils and servos all pull far more than their average draw.

Fix. Power peripherals from a separate regulated supply with a common ground rather than from the board 5 V pin. Add a 470–1000 µF electrolytic capacitor across the supply near the load, and use a real power adapter rather than a laptop USB port.

Serial monitor shows garbage characters

Likely cause. Baud rate mismatch between Serial.begin() and the monitor, or a floating/shared UART line.

Fix. Set the monitor to 115200 to match the sketch. If it still garbles, the crystal or the USB bridge is being confused by noise — shorten the cable and keep motor wiring away from the USB lead.

An I²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an I²C scanner sketch first — it should print the device address. Most breakout boards include 4.7 kΩ pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

Performance Optimisation

  • Sample every five seconds, not every loop. Thermal processes have minute-scale dynamics; oversampling adds nothing and costs power.
  • Write the occupancy grid and thermal model to NVS every half hour, not every update — flash has a finite erase-cycle budget.
  • Keep the OLED refresh at the sample rate rather than the loop rate. An I²C framebuffer push costs about 10 ms.
  • Replace every delay() with a millis() comparison — blocking delays are the single most common cause of dropped readings.
  • Sample sensors on a fixed cadence and publish on a slower one; you almost never need to transmit at the sampling rate.
  • Move networking into its own FreeRTOS task so a slow DNS lookup cannot stall the control loop.
  • Use uint8_t / uint16_t where the range allows; on an 8-bit AVR a 32-bit add costs four times as much.
  • Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
  • Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
  • For battery builds use deep sleep between samples: an ESP32 drops from ~160 mA awake to about 10 µA asleep, which is the difference between days and months of runtime.
  • Profile before optimising — print micros() deltas around each stage and fix the slowest one first.

Safety Precautions

  • Confirm what your thermostat terminals actually switch. Most modern boilers use a 24 V or dry-contact call-for-heat loop, but plenty of older systems switch 230 V directly. Measure before you touch anything.
  • Never remove the equipment's own limit thermostat or overheat cut-out. This device is a controller, not a safety device, and it must never be the only thing standing between a heater and a fire.
  • Freeze protection must be independent of the network and the schedule. Verify it works with Wi-Fi disabled.
  • Mains voltage kills. Anything on the load side of the relay is at 230 V. Do not work on a powered circuit, and never leave exposed mains wiring on a bench where someone could touch it.
  • Keep at least 6 mm of creepage between the mains and low-voltage sides of any board you make, and never route mains tracks under the microcontroller.
  • Have a qualified electrician do the final installation into a consumer unit or wall fitting. In most jurisdictions this is a legal requirement, not a suggestion.
  • Fit an RCD/RCBO upstream and fuse the load appropriately for its rating.
  • Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
  • Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
  • Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.

Maintenance

  • Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
  • Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
  • Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
  • Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.

Future Improvements & Upgrades

A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.

  • Add weather forecast integration — knowing that the outside temperature will fall 6 °C overnight lets the pre-heat calculation start earlier and more accurately than reacting to the drop.
  • Add multi-room sensing with cheap ESP-NOW satellite nodes, and control on a weighted average or on the coldest occupied room.
  • Add window-open detection — a sudden negative slope with the heater running is unambiguous, and pausing the call for heat saves more than any tuning.
  • Log duty against outside temperature and fit the line: the intercept is the building's baseline heat loss and the slope quantifies insulation quality.
  • Replace the PIR with an mmWave presence sensor. PIR misses a stationary person reading a book; 24 GHz radar does not.
  • Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
  • Add over-the-air firmware updates so you never have to physically reach a deployed node again.
  • Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
  • Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
  • Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
  • Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.

Frequently Asked Questions

Is the setback actually worth it? I have heard it costs more to reheat.

That claim is a persistent myth. Heat loss is proportional to the temperature difference between inside and outside, so a cooler house loses less heat, full stop. The energy needed to reheat is exactly the energy that was not lost while cool, minus what escaped — it can never exceed the saving. The real caveat is comfort and recovery time, which is precisely what the predictive pre-heat in this project addresses.

Why PI and not PID?

The derivative term differentiates a noisy sensor signal on a process whose time constant is measured in hours. In practice D contributes almost nothing useful here and amplifies noise into relay chatter. Nearly every commercial thermostat that claims PID runs with D heavily filtered or effectively zero.

Can this control an air conditioner?

Yes — invert the sign of the error and keep everything else. The minimum off-time becomes critical rather than merely advisable, because restarting a compressor against head pressure is genuinely damaging. For a split unit with no accessible contactor, drive an IR blaster instead of a relay and send the manufacturer's codes.

How long before the learning is useful?

The thermal model is usable after about six hours and good after two days. The occupancy grid needs two to three weeks to be trustworthy, because each half-hour slot only gets one observation per week per weekday. Until then it falls back to comfort temperature, which is the safe default.

What happens if Wi-Fi goes down?

Everything except the remote setpoint keeps working. Control, learning, scheduling and freeze protection all run locally on the ESP32 — the network is for reporting and convenience, never for control. That is a deliberate architectural choice and it is worth preserving in any modification you make.

Should the sensor be in the thermostat or elsewhere in the room?

Elsewhere, almost always. A wall box near a door is a poor proxy for where people sit. Adding a DS18B20 on a two-metre lead placed at seated height in the middle of the room measurably improves perceived comfort, and it costs ₹160.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. BME280 combined humidity, pressure and temperature sensor — datasheetBosch Sensortec
  2. Åström & Hägglund, "PID Controllers: Theory, Design, and Tuning" — anti-windup chapterISA
  3. Beauregard, "Improving the Beginner's PID" — the reference explanation of windup and derivative kickBrett Beauregard
  4. Newton's law of cooling and first-order thermal modelsWikipedia
  5. Home Assistant MQTT Climate integration and discovery schemaHome Assistant
  6. ASHRAE Standard 55 — thermal environmental conditions for human occupancyASHRAE