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.
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
| Setting | How it is used |
|---|---|
| Domestic heating | Replaces a mechanical or simple digital thermostat and typically cuts runtime 10–25 % through setback alone. |
| Split-AC control | The same logic with the sign inverted, driving an IR blaster or a relay on the compressor contactor. |
| Server and network cupboards | Keeps a small enclosed space inside a band and alerts when the cooling cannot keep up. |
| Greenhouse and grow tents | Night setback plus predictive ramp matters more here than in a house, because plants respond to the temperature curve. |
| Fermentation and proofing chambers | Tight band control with anti-short-cycling protection is exactly what a beer fridge or a bread proofer needs. |
| Holiday-home frost protection | Remote 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
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 12–18 hours |
| Indicative build cost | ₹3,100 – ₹4,000 |
| Primary discipline | Smart Home |
| Reference platform | ESP32 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.
| Component | Key specification | Qty | Approx. 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 DAC | 1 | ₹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 Hz | 1 | ₹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 idle | 1 | ₹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 input | 1 | ₹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²C | 1 | ₹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 out | 1 | ₹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 % efficiency | 1 | ₹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 protection | 1 | ₹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 grid | 1 | ₹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 lugs | 1 | ₹260 |
| Rotary encoder with push button Local setpoint adjustment — a thermostat that needs a phone is a worse thermostat. | 20 detents/rev, integral switch | 1 | ₹90 |
| DS18B20 remote probe (optional) Lets you sense at a better location than the wall box. | Waterproof, 2 m lead | 1 | ₹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
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| 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 DAC | 3.3 V logic / 5 V USB | UART, SPI, I²C, I²S, CAN, PWM | Datasheet |
| BME280 pressure/humidity/temperature sensor | 300–1100 hPa ±1 hPa, 0–100 %RH ±3 %, −40 to +85 °C ±1 °C, 3.4 µA at 1 Hz | 1.7–3.6 V (module has 3.3 V LDO) | I²C (0x76/0x77) or SPI | Datasheet |
| HC-SR501 PIR motion sensor | 3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle | 4.5–20 V | Digital high on motion | Datasheet |
| 5 V single-channel opto-isolated relay module | SPDT contacts rated 10 A @ 250 VAC / 10 A @ 30 VDC, opto-isolated input | 5 V coil | Digital (active-low) | Datasheet |
| 0.96″ SSD1306 OLED display | 128 × 64 monochrome, 1.3–3.3 V logic, 100 kHz–400 kHz I²C | 3.3–5 V | I²C (0x3C) | Datasheet |
| DS3231 precision RTC + CR2032 backup | ±2 ppm (±1 min/year), temperature-compensated crystal, alarms, 32 kHz out | 2.3–5.5 V | I²C (0x68) | Datasheet |
| LM2596 adjustable buck converter module | 4.5–40 V in, 1.25–37 V out, 2 A (3 A peak), ~92 % efficiency | 4.5–40 V | Screw terminals + trimmer | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
| IP65 ABS junction enclosure 158 × 90 × 60 mm | IP65, ABS, −20 to +80 °C, transparent lid, wall-mount lugs | — | — | Datasheet |
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.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| ESP32 DevKit V1 (ESP-WROOM-32) | 3.3 V logic / 5 V USB | 160 | Wi-Fi transmit bursts peak near 500 mA — size the regulator accordingly. |
| BME280 pressure/humidity/temperature sensor | 1.7–3.6 V (module has 3.3 V LDO) | 0.4 | Self-heating skews temperature by ~1 °C — read in forced mode, not continuous. |
| HC-SR501 PIR motion sensor | 4.5–20 V | 0.05 | Give it 60 s to settle after power-up or it fires false triggers. |
| 5 V single-channel opto-isolated relay module | 5 V coil | 70 | Cut the JD-VCC jumper and feed the coil separately for true isolation. |
| 0.96″ SSD1306 OLED display | 3.3–5 V | 20 | Static images burn in — invert or scroll the screen periodically. |
| DS3231 precision RTC + CR2032 backup | 2.3–5.5 V | 0.2 | Vastly better than DS1307; some boards trickle-charge a non-rechargeable cell — cut that resistor. |
| LM2596 adjustable buck converter module | 4.5–40 V | 8 | Set the output voltage with no load connected before wiring the board. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure 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.jsonunder 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
dialoutgroup:sudo usermod -aG dialout $USERand 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
| Library | Why it is needed | Install |
|---|---|---|
| WiFi (ESP32 core) bundled | Station/AP connection management for the ESP32. | Bundled with the ESP32 Arduino core |
| PubSubClient 2.8 | Lightweight MQTT 3.1.1 client for constrained devices. | Library Manager → "PubSubClient" by Nick O'Leary |
| ArduinoJson 7.x | Zero-allocation JSON serialisation and parsing. | Library Manager → "ArduinoJson" by Benoit Blanchon |
| Adafruit BME280 2.2.x | Compensation maths for the Bosch pressure/humidity/temperature sensor. | Library Manager → "Adafruit BME280 Library" |
| Adafruit Unified Sensor 1.1.x | Common sensor event abstraction; a dependency of most Adafruit drivers. | Library Manager → "Adafruit Unified Sensor" |
| Adafruit SSD1306 + GFX 2.5.x | Framebuffer and text/graphics primitives for the OLED. | Library Manager → "Adafruit SSD1306" |
| Preferences (NVS) bundled | Wear-levelled key/value storage in ESP32 flash for settings. | Bundled with the ESP32 core |
| NTPClient / configTime bundled | Wall-clock time from an NTP server for timestamping. | Bundled (`configTime()` on ESP32) |
| PID_v1 1.2.1 | Proportional-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.
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.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| BME280 sensor | SDA / SCL | GPIO 21 / 22 | I²C at 0x76 |
| SSD1306 OLED | SDA / SCL | GPIO 21 / 22 | Same bus, 0x3C |
| DS3231 RTC | SDA / SCL | GPIO 21 / 22 | Same bus, 0x68 |
| HC-SR501 PIR | OUT | GPIO 27 | High while motion detected |
| Rotary encoder | A / B / SW | GPIO 32 / 33 / 25 | Quadrature + button |
| Relay → boiler / contactor | IN | GPIO 26 | Active-low, opto-isolated |
| Status LED | Anode | GPIO 2 | Through 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.
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.
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
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
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
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.
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.
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.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.inoconst 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.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.inostruct 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.
/* ═══════════════════════════════════════════════════════════════
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
}
}
Configuration & Calibration
Configuration steps
- Set
COMFORT_C,SETBACK_CandFREEZE_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_BANDto 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_MSto 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.
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.
Verify the thermal model after 48 hours
Compare the reported
tau_minagainst 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 %.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
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 / endpoint | Direction | Payload |
|---|---|---|
home/climate/thermostat-living/state | device → broker (retained) | JSON: room_c, rh, setpoint, duty, heating, occupied, heat_rate, tau_min, t_inf |
home/climate/thermostat-living/set | broker → device | Plain float setpoint in °C |
homeassistant/climate/thermostat-living/config | device → 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.
| Test | What you should see |
|---|---|
| Run an I²C scanner | Addresses 0x76 (BME280), 0x3C (OLED) and 0x68 (RTC) all reported. |
| Compare the reading against a reference thermometer | Within about 1 °C after ten minutes of settling, with no upward drift. |
| Set the setpoint 3 °C above the room | Duty 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 room | Duty falls to 0 and the relay opens — but not before MIN_ON_MS has elapsed. |
| Toggle the setpoint rapidly around the room temperature | The relay does not follow. The dead band and minimum timers absorb it entirely. |
| Let it run 48 hours, then read the state topic | tau_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 day | That day's daytime occupancy cells decay and the setpoint drops to setback the following week at the same time. |
| Check Home Assistant | A 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
{
"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
}
Troubleshooting: Common Errors & Fixes
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 amillis()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_twhere 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
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- BME280 combined humidity, pressure and temperature sensor — datasheetBosch Sensortec
- Åström & Hägglund, "PID Controllers: Theory, Design, and Tuning" — anti-windup chapterISA
- Beauregard, "Improving the Beginner's PID" — the reference explanation of windup and derivative kickBrett Beauregard
- Newton's law of cooling and first-order thermal modelsWikipedia
- Home Assistant MQTT Climate integration and discovery schemaHome Assistant
- ASHRAE Standard 55 — thermal environmental conditions for human occupancyASHRAE