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.

Target Board Variant: ESP32-WROOM-32 DevKit v1 (38-pin layout).
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).

Design Rule: To maintain ±0.5°C accuracy, design your voltage divider so the midpoint voltage stays between 0.5V and 2.4V across your target temperature range. At 25°C, a 10k NTC and 10k pull-up yield exactly 1.65V (dead center). If your application drops below 0°C (where NTC resistance spikes to ~27kΩ), the voltage approaches 2.42V, still safely within the linear zone.

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:

  1. 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.
  2. Add MQTT: Utilize the ESP32's native WiFi. Publish the filteredTempC variable to an MQTT broker (like Mosquitto or Home Assistant) every 5 seconds, and subscribe to a topic that allows you to update the TARGET_TEMP_C variable over the air.