Project Overview & Difficulty Rating
Transitioning from blinking LEDs to closed-loop environmental control is a major milestone in embedded systems. This guide walks you through one of the most practical do it yourself projects electronics builders can tackle: a precision temperature controller using an NTC thermistor and a relay. Unlike basic digital sensors, analog thermistors require you to understand voltage dividers, ADC non-linearity, and the Steinhart-Hart equation.
Difficulty Rating: Intermediate (Requires basic calculus concepts for theory, standard solderless breadboard wiring).
Estimated Build Time: 90 minutes.
Core Framework: Arduino IDE (ESP32 Core v2.0.x or v3.x).
Bill of Materials & Component Specifications
Component selection dictates the noise floor and accuracy of your analog readings. The table below details the exact variants required to replicate this build's performance, with 2026 market pricing.
| Component | Exact Variant / Model | Key Specification | Est. Cost |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32 DevKit v1 (38-pin) | Dual-core 240MHz, 12-bit SAR ADC | $6.50 |
| Temperature Sensor | 10k NTC Thermistor (Glass Encapsulated) | B-Value: 3950, 1% tolerance | $1.20 |
| Pull-up Resistor | 10kΩ Metal Film Resistor | 1% tolerance, 1/4W (Critical for math accuracy) | $0.10 |
| Switching Module | 5V Optocoupler Relay Module (SRD-05VDC) | Active LOW trigger, 10A/250VAC contacts | $2.80 |
Circuit Theory: Voltage Dividers & ESP32 ADC Non-Linearity
Microcontrollers cannot read resistance directly; they read voltage. We convert the thermistor's variable resistance into a measurable voltage using a voltage divider. The formula is:
V_out = V_in × (R_therm / (R_therm + R_fixed))
Assuming a 3.3V reference (V_in) and a 10kΩ fixed pull-up resistor (R_fixed), we must account for a notorious hardware quirk: ESP32 ADC non-linearity. According to the Espressif ESP32 Technical Reference Manual, the internal 12-bit SAR ADC becomes highly non-linear and compresses near the 3.3V rail (typically above 2.5V).
Once we have the voltage, we calculate resistance, then apply the B-parameter equation (a simplified Steinhart-Hart equation for hobbyist NTCs):
1/T = 1/T_0 + (1/B) × ln(R / R_0)
Where T_0 is 298.15K (25°C), B is 3950, and R_0 is 10,000Ω.
Pin Mapping & Wiring Steps
Ensure your ESP32 is powered via a high-quality USB cable capable of delivering 2A. The relay coil draws a sudden current spike upon switching, which can cause voltage sags on cheap cables.
| Component Pin | ESP32 DevKit v1 Pin | Wire Color (Recommended) | Notes |
|---|---|---|---|
| Thermistor Leg 1 | GPIO 34 (ADC1_CH6) | Orange | Input only, no internal pull-up |
| Thermistor Leg 2 | GND | Black | Shared ground with 10k resistor |
| Relay IN (Signal) | GPIO 26 | Blue | Active LOW trigger |
| Relay VCC | VIN (5V) | Red | Do NOT use 3V3 pin for relay coil |
Complete ESP32 Firmware (Arduino IDE)
This code targets the Arduino-ESP32 Core (v2.0.x or newer). It utilizes analogReadMilliVolts() to bypass raw ADC non-linearities via the chip's internal eFuse calibration data, and implements an exponential moving average (EMA) filter to smooth out inherent SAR ADC noise.
#include <Arduino.h>
// --- PIN DEFINITIONS ---
#define THERMISTOR_PIN 34 // ADC1 channel, safe to use with WiFi
#define RELAY_PIN 26 // Output pin for relay control
// --- THERMISTOR CONSTANTS ---
#define SERIES_RESISTOR 10000.0 // 10k Ohm pull-up
#define NOMINAL_RESISTANCE 10000.0 // 10k Ohm at 25C
#define NOMINAL_TEMPERATURE 298.15 // 25C in Kelvin
#define B_COEFFICIENT 3950.0
// --- CONTROL PARAMETERS ---
#define TARGET_TEMP_C 22.0
#define HYSTERESIS 1.0 // Prevents relay chatter
#define EMA_ALPHA 0.15 // Smoothing factor (0.0 to 1.0)
float filteredTempC = TARGET_TEMP_C;
bool relayState = false;
void setup() {
Serial.begin(115200);
delay(500);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, HIGH); // Active LOW: HIGH = Relay OFF
analogReadResolution(12); // Ensure 12-bit resolution
Serial.println("ESP32 Thermistor Controller Initialized.");
}
void loop() {
// 1. Read ADC in millivolts (uses factory calibration)
uint32_t vOut_mV = analogReadMilliVolts(THERMISTOR_PIN);
// Error handling: Check for disconnected or shorted sensor
if (vOut_mV < 50 || vOut_mV > 3250) {
Serial.println("ERROR: Thermistor out of range. Check wiring.");
digitalWrite(RELAY_PIN, HIGH); // Fail-safe: turn off relay
delay(1000);
return;
}
// 2. Calculate Resistance
float voltage = vOut_mV / 1000.0;
float resistance = SERIES_RESISTOR * (voltage / (3.3 - voltage));
// 3. Steinhart-Hart (B-Parameter) Math
float steinhart;
steinhart = resistance / NOMINAL_RESISTANCE; // (R/Ro)
steinhart = log(steinhart); // ln(R/Ro)
steinhart /= B_COEFFICIENT; // 1/B * ln(R/Ro)
steinhart += 1.0 / NOMINAL_TEMPERATURE; // + (1/To)
steinhart = 1.0 / steinhart; // Invert
float currentTempC = steinhart - 273.15; // Convert to Celsius
// 4. Exponential Moving Average Filter
filteredTempC = (EMA_ALPHA * currentTempC) + ((1.0 - EMA_ALPHA) * filteredTempC);
// 5. Hysteresis Control Logic
if (filteredTempC > (TARGET_TEMP_C + HYSTERESIS) && relayState == false) {
digitalWrite(RELAY_PIN, LOW); // Turn ON (Active LOW)
relayState = true;
Serial.println("RELAY ON: Cooling required.");
}
else if (filteredTempC < (TARGET_TEMP_C - HYSTERESIS) && relayState == true) {
digitalWrite(RELAY_PIN, HIGH); // Turn OFF
relayState = false;
Serial.println("RELAY OFF: Target reached.");
}
// Serial output for plotting
Serial.printf("Raw: %.2f C | Filtered: %.2f C | Relay: %s\n",
currentTempC, filteredTempC, relayState ? "ON" : "OFF");
delay(250); // 4Hz sampling rate
}
Debugging: First Three Things to Check When It Fails
When moving from simulation to physical hardware, embedded projects often fail at the power and ground boundaries. If your build fails, follow this diagnostic sequence.
1. The Brownout Reset Loop
Exact Error String: Brownout detector was triggered (Printed repeatedly in the serial monitor, accompanied by the ESP32 resetting every few seconds).
Cause: When the relay coil energizes, it draws 70-100mA instantaneously. If your USB cable has high resistance or your PC's USB port current-limits at 500mA, the ESP32's 3.3V LDO starves, triggering the internal brownout detector.
Fix: Swap to a premium, low-AWG USB cable. If the issue persists, power the relay module's VCC from a dedicated 5V/2A wall adapter, tying only the GND and Signal pins to the ESP32.
2. ADC Saturation and Ghost Readings
Symptom: Temperature reads a flat -12°C or 145°C and never changes, even when you pinch the thermistor.
Cause: GPIO 34 is an input-only pin with no internal pull-up resistors. If the thermistor or pull-up resistor is wired incorrectly (e.g., floating), the ADC pin acts as an antenna, picking up 50/60Hz mains noise or saturating at the rail.
Fix: Measure the voltage at GPIO 34 with a multimeter. It should read between 1.0V and 2.5V at room temperature. If it reads 0.0V or 3.3V, verify your voltage divider wiring.
3. Compilation Errors on Older Cores
Exact Error String: 'analogReadMilliVolts' was not declared in this scope
Cause: You are using an outdated ESP32 Arduino Core board package (v1.0.x) which lacks the factory-calibrated mV reading function.
Fix: Open the Arduino Boards Manager, search for 'esp32', and update to version 2.0.14 or any 3.x release.
Extending and Simplifying the Build
Depending on your end goal, you may want to alter the complexity of this circuit.
How to Simplify (The Digital Route)
If you want to bypass analog math, ADC noise, and voltage dividers entirely, swap the NTC thermistor for a DS18B20 digital temperature sensor. It communicates via the OneWire protocol, requiring only a single 4.7kΩ pull-up resistor on the data line. You will lose the fast thermal response time of a glass bead NTC, but you eliminate 30 lines of calculus and filtering code.
How to Extend (PID Control & IoT)
Bang-bang control (hysteresis) is fine for a basic heater, but it causes temperature oscillation. To achieve laboratory-grade stability:
- Add PID: Integrate the
Arduino-PID-Library. Feed the filtered thermistor reading as the Input, your target temp as the Setpoint, and output a PWM signal to a solid-state relay (SSR) instead of a mechanical relay. - Add MQTT: Utilize the ESP32's native WiFi. Publish the
filteredTempCvariable to an MQTT broker (like Mosquitto or Home Assistant) every 5 seconds, and subscribe to a topic that allows you to update theTARGET_TEMP_Cvariable over the air.






