What you will learn
- Convert a 12-bit ADC reading into an 18–30 °C target.
- Explain HEAT, IN_RANGE, and COOL with ±1 °C entry thresholds and a return at target.
- Indicate exactly one valid temperature decision with LEDs.
- Discard an invalid temperature/humidity sample and the earlier decision on sensor failure.
- Check target changes, recovery, and hysteresis using a transition table.
Before you start
Complete courses 5, 10, and 17: a potentiometer and ADC, DHT11 readings, and temperature-controller hysteresis.
Reference setup
- Board in Arduino IDE
- ESP32 Dev Module
- Arduino-ESP32
- 3.3.12
- Serial Monitor
- 115200 baud
This is a reference profile, not an identification of the pictured board. Adapt the GPIO mapping and verify the circuit before using ESP32-C3/S2/S3 or a differently labelled board.
What we will build
We will build an LED thermostat model with three possible decisions: red means cold and requests heating, green indicates the range around the target, and yellow means warm and requests cooling. A DHT11 supplies temperature and humidity, while a potentiometer selects a target between 18 and 30 °C. The Serial Monitor reports the reading, target, and state.
This circuit does not heat or cool the room. Its colours represent software decisions, allowing us to explore thresholds, previous state, and sensor failure without a heater or fan. Unlike a comparison against one boundary, the decision in part of the range also depends on the direction from which we arrived.
What you will learn
- Convert a raw ADC reading into a temperature setting.
- Distinguish a state's entry threshold from its return threshold.
- Implement three mutually exclusive temperature states.
- Reconsider the last valid reading when the target changes.
- Reject an invalid sample and clear the previous decision.
- Document expected behaviour using a transition table.
Prerequisites
Complete courses 5, 10, and 17: a potentiometer on an analogue input, DHT11 readings, and hysteresis. You should understand the Serial Monitor, an LED's series resistor, and intervals measured with millis(). The reference board is a classic ESP32 with an ESP32-WROOM-32 module. GPIO34 names a signal, not the board's thirty-fourth physical terminal. These assignments do not transfer automatically to an ESP32-C3 or ESP32-S3.
Equipment and variant checks
| Component | Quantity | Specification and notes |
|---|---|---|
| Development board with a classic ESP32-WROOM-32 module | 1 | Match the reference GPIO labels; check physical header positions on the actual board. |
| USB data and power cable | 1 | Use the connector fitted to your board; the pictured kit lists Micro-USB. |
| Solderless breadboard | 1 | The kit lists 830 tie points. Check whether the power rails are split. |
| DHT11 temperature and relative humidity module | 1 | Three-terminal VCC/GND/DATA module verified for 3.3 V; DATA pull-up only to 3V3. Some variants are unreliable at 3.3 V. |
| 10 kΩ potentiometer | 1 | Two end terminals and a wiper W; identify their physical positions on the actual component. |
| Red LED | 1 | Anode A and cathode K; identify polarity on the actual part. |
| Green LED | 1 | Anode A and cathode K. |
| Yellow LED | 1 | Anode A and cathode K. |
| 220 Ω resistor | 3 | One current-limiting resistor per LED branch; use the kit’s 220 Ω resistors. |
| 10 kΩ resistor | 1 | R4 pulls DHT DATA up to 3V3; also check any existing module pull-up. |
| Jumper wires | 17 | Use male-to-male or female-to-male leads to suit the board headers. Approximate quantity; depends on layout and connector types. |
Prepare three separate LEDs: red D1, green D2, and yellow D3. Each has its own 220 Ω resistor; a shared resistor is not the specified circuit. The reference potentiometer is 10 kΩ. Verify that the DHT11 module supports a 3.3 V supply and 3.3 V DATA logic. The kit photograph does not establish its internal wiring.
R4, 10 kΩ, pulls DATA up to 3V3. If the module already includes a pull-up, confirm that it also leads to 3.3 V; the external R4 remains across the same nodes in the reference circuit. This lesson uses no relay, transistor, or protection diode. Check the available resistor quantities by value before assembling the circuit.
Three states and two return paths
With a 24 °C target, the program enters HEAT at 23 °C or below and COOL at 25 °C or above. From HEAT it returns to IN_RANGE when temperature reaches the 24 °C target; from COOL it returns when temperature falls to the target. Within the intervening region, the previous state can therefore persist. At 23.5 °C, a previous HEAT stays HEAT, while a previous IN_RANGE stays IN_RANGE.
The comparison order is deliberate: first the lower entry threshold, then the upper threshold, then retaining HEAT below the target or COOL above it. If a sample jumps from 22 to 26 °C, the state changes directly from HEAT to COOL. Exactly one LED lights in a valid temperature mode. This hysteresis reduces decision changes near boundaries; it does not improve sensor accuracy. The DHT11 is a basic sensor with limited accuracy. Adafruit — DHT overview
Wiring diagram and assembly order
Wiring diagram
All components use 3.3 V. R4 is the DHT DATA pull-up to 3V3. Red represents HEAT, green IN_RANGE and yellow COOL; all blink in FAULT as a separate error indication. Only LED indicators are connected, without a heater, cooler or relay.
| From | To | Connection |
|---|---|---|
ESP32.3V3 | U1.VCC | DHT11 module verified for 3.3 V operation. |
ESP32.GND | U1.GND | Common ground. |
U1.DATA | ESP32.GPIO26 | DHT DATA to GPIO26. |
ESP32.3V3 | R4.1 | R4, 10 kΩ, pulls DATA up to 3V3. |
R4.2 | U1.DATA | Other end of R4 to the DATA/GPIO26 node. |
ESP32.3V3 | RV1.END_A | One end of the 10 kΩ potentiometer. |
ESP32.GND | RV1.END_B | The other end terminal. |
RV1.W | ESP32.GPIO34 | Wiper W to GPIO34, an ADC1 input. |
ESP32.GPIO25 | R1.1 | LED output (red) to its 220 Ω resistor. |
R1.2 | D1.A | Series resistor to anode A. |
D1.K | ESP32.GND | Cathode K to ground. |
ESP32.GPIO27 | R2.1 | LED output (green) to its 220 Ω resistor. |
R2.2 | D2.A | Series resistor to anode A. |
D2.K | ESP32.GND | Cathode K to ground. |
ESP32.GPIO33 | R3.1 | LED output (yellow) to its 220 Ω resistor. |
R3.2 | D3.A | Series resistor to anode A. |
D3.K | ESP32.GND | Cathode K to ground. |
- Disconnect USB and connect the board's GND to the breadboard's common ground.
- Connect DHT11 VCC to 3V3, GND to GND, and DATA to GPIO26. Place R4, 10 kΩ, between DATA and 3V3.
- Connect the potentiometer's end terminals to 3V3 and GND; connect its wiper to GPIO34.
- Wire the red branch: GPIO25 → R1, 220 Ω → D1 anode; D1 cathode to GND.
- Wire the green branch: GPIO27 → R2, 220 Ω → D2 anode; D2 cathode to GND.
- Wire the yellow branch: GPIO33 → R3, 220 Ω → D3 anode; D3 cathode to GND.
- Check LED polarity, the wiper connection, absence of a 3V3-to-GND short, and common ground. Then connect USB.
GPIO34 is input-only and serves the potentiometer here. The external circuit uses 3.3 V; no signal connects to 5 V. A four-pin DHT package and a three-connection module may have different pin orders, so follow the actual component's documentation. Adafruit — connecting a DHT sensor
Preparing the Arduino environment
Use Arduino IDE 2.x, esp32 by Espressif Systems 3.3.12, board ESP32 Dev Module, and the Serial Monitor at 115200 baud. Install DHT sensor library 1.4.7 and Adafruit Unified Sensor 1.1.15 through Library Manager.
Extract the example and open led_thermostat.ino from its matching folder. Select the serial port, run Verify, and then Upload. Confirm that the sketch selects DHT11 rather than DHT22. These versions define the package's reference environment; a successful host logic check does not demonstrate an actual ESP32 build or execution of the real library.
| Library and release | Version | Dependencies |
|---|---|---|
| DHT sensor library | 1.4.7 | Adafruit Unified Sensor 1.1.15 |
| Adafruit Unified Sensor | 1.1.15 | None |
Complete program
#include <Arduino.h>
#include <DHT.h>
#include <math.h>
constexpr uint8_t DHT_PIN = 26;
constexpr uint8_t POT_PIN = 34;
constexpr uint8_t HEAT_LED_PIN = 25;
constexpr uint8_t RANGE_LED_PIN = 27;
constexpr uint8_t COOL_LED_PIN = 33;
constexpr uint32_t SAMPLE_MS = 2500;
constexpr uint32_t POT_SAMPLE_MS = 100;
constexpr float BAND_C = 1.0f;
DHT dht(DHT_PIN, DHT11);
enum ThermostatState { WAITING, HEAT, IN_RANGE, COOL, FAULT };
ThermostatState state = WAITING;
bool currentValid = false;
float currentTemperature = NAN, currentHumidity = NAN;
uint16_t rawValue = 0;
uint8_t targetC = 18;
uint32_t lastSampleAt = 0, lastPotAt = 0, faultSince = 0;
void setState(ThermostatState next, uint32_t now);
uint8_t targetFromRaw(uint16_t raw) {
return static_cast<uint8_t>(18UL + static_cast<uint32_t>(raw) * 12UL / 4095UL);
}
void setState(ThermostatState next, uint32_t now) {
if (next == state) return;
state = next;
if (state == FAULT) faultSince = now;
Serial.print("State=");
switch (state) {
case WAITING: Serial.println("WAITING"); break;
case HEAT: Serial.println("HEAT (red)"); break;
case IN_RANGE: Serial.println("IN_RANGE (green)"); break;
case COOL: Serial.println("COOL (yellow)"); break;
case FAULT: Serial.println("FAULT"); break;
}
}
void evaluateThermostat(uint32_t now) {
if (!currentValid) return;
const float target = static_cast<float>(targetC);
if (currentTemperature <= target - BAND_C) setState(HEAT, now);
else if (currentTemperature >= target + BAND_C) setState(COOL, now);
else if (state == HEAT && currentTemperature < target) return;
else if (state == COOL && currentTemperature > target) return;
else setState(IN_RANGE, now);
}
void sampleEnvironment(uint32_t now) {
const float humidity = dht.readHumidity();
const float temperature = dht.readTemperature();
const bool wasValid = currentValid;
currentValid = isfinite(temperature) && isfinite(humidity);
if (!currentValid) {
currentTemperature = currentHumidity = NAN;
setState(FAULT, now); // Discard the previous hysteresis state.
} else {
currentTemperature = temperature;
currentHumidity = humidity;
if (!wasValid) setState(IN_RANGE, now);
evaluateThermostat(now);
}
if (currentValid) {
Serial.print("T_C="); Serial.print(currentTemperature, 1);
Serial.print(" RH_pct="); Serial.print(currentHumidity, 1);
} else Serial.print("SENSOR ERROR: no current reading");
Serial.print(" target_C="); Serial.println(targetC);
}
void updateLeds(uint32_t now) {
const bool blink = state == FAULT && static_cast<uint32_t>(now - faultSince) % 500UL < 250UL;
digitalWrite(HEAT_LED_PIN, state == HEAT || blink ? HIGH : LOW);
digitalWrite(RANGE_LED_PIN, state == IN_RANGE || blink ? HIGH : LOW);
digitalWrite(COOL_LED_PIN, state == COOL || blink ? HIGH : LOW);
}
void setup() {
Serial.begin(115200);
pinMode(HEAT_LED_PIN, OUTPUT); digitalWrite(HEAT_LED_PIN, LOW);
pinMode(RANGE_LED_PIN, OUTPUT); digitalWrite(RANGE_LED_PIN, LOW);
pinMode(COOL_LED_PIN, OUTPUT); digitalWrite(COOL_LED_PIN, LOW);
analogReadResolution(12);
analogSetPinAttenuation(POT_PIN, ADC_11db);
rawValue = analogRead(POT_PIN);
targetC = targetFromRaw(rawValue);
dht.begin();
lastSampleAt = lastPotAt = millis();
Serial.println("LED thermostat: first DHT11 sample after 2.5 s.");
}
void loop() {
uint32_t now = millis();
if (static_cast<uint32_t>(now - lastPotAt) >= POT_SAMPLE_MS) {
lastPotAt = now;
rawValue = analogRead(POT_PIN);
const uint8_t newTarget = targetFromRaw(rawValue);
if (newTarget != targetC) {
targetC = newTarget;
Serial.print("Target_C="); Serial.println(targetC);
evaluateThermostat(now); // Reuse only a current valid sample.
}
}
if (static_cast<uint32_t>(now - lastSampleAt) >= SAMPLE_MS) {
lastSampleAt = now;
sampleEnvironment(now);
}
now = millis();
updateLeds(now);
}
After power-up, WAITING keeps every LED off until the first reading attempt. That attempt occurs after approximately 2500 ms, with subsequent attempts following the same schedule. If either temperature or humidity is invalid, FAULT clears the current data and temperature-state memory. All three LEDs then blink together: 250 ms on and 250 ms off. This is a separate visual fault pattern, not simultaneous heating and cooling demands.
How the setting and decision work
The ADC uses 12-bit resolution and ADC_11db. The potentiometer is sampled every 100 ms, and targetFromRaw() calculates the integer target as 18 + raw * 12UL / 4095. Raw values 0, 2048, and 4095 produce 18, 24, and 30 °C. This maps a count to a user setting. It is not calibrated voltage measurement and does not promise equal degrees per shaft angle; the ADC may reach its maximum before the potentiometer reaches its mechanical end. Espressif — ADC API
A target change is considered immediately by evaluateThermostat() against the last valid sample, including between DHT readings. Unchanged room conditions can therefore produce a different colour when the target moves. sampleEnvironment() requires finite temperature and humidity values, although humidity does not enter the thresholds. The pair ordinarily comes from the same DHT frame, rather than two independently refreshed measurements. Adafruit — DHT implementation
After a fault, the first valid sample starts its decision from IN_RANGE and is then compared with both thresholds. Recovery at 23.5 °C with a 24 °C target therefore produces green, even if red was active before the fault. Failure is detected during a reading attempt, not at the exact instant a physical connection breaks. The relay's minimum switching interval from course 17 is not needed for this LED model.
Practical experiment and expectations
Start with the potentiometer near its midpoint. Wait for the first sample and compare the target with room temperature. Slowly move the target above and below that temperature. Record target, temperature, previous state, and the resulting LED; the final colour alone does not explain hysteresis.
The following sequence is a worked example with a 24 °C target, not a claim about DHT11 resolution or a set of recorded measurements. The initial state is IN_RANGE.
| Temperature in sequence | Resulting state | Explanation |
|---|---|---|
| 22.9 °C | HEAT, red | Lower entry threshold crossed |
| 23.5 °C | HEAT, red | The return to target has not occurred |
| 24.0 °C | IN_RANGE, green | Target reached |
| 25.0 °C | COOL, yellow | Upper entry threshold reached |
| 24.5 °C | COOL, yellow | Temperature remains above target |
| 24.0 °C | IN_RANGE, green | Target reached from above |
To demonstrate a startup fault, disconnect USB, remove DATA, and power the board again. Expect a short wait followed by all three LEDs blinking. Restore the connection with power off. This procedure resets the program, so it does not prove recovery within the same run; examine that case through the logic or controlled sample injection in a test environment.
Common problems
| Problem | What to check |
|---|---|
| All three LEDs blink | DHT11 selection, DATA26, R4, supply, and ground |
| One colour never lights | Its GPIO, resistor, polarity, and selected target |
| Two colours remain on | Swapped branches or a short; valid decisions select one |
| The target stays at an endpoint | GPIO34 wiper connection and both potentiometer ends |
| The target sometimes moves by one degree | Noise near an ADC step boundary; set the knob steadily |
| A colour does not change near the middle of the band | Examine the previous state and return-to-target rule |
Independent challenges
- Widen the entry thresholds from ±1 °C to ±2 °C while keeping the return at the target. For a 24 °C target, process 21.9, 23.0, 24.0, 26.0, 25.0, and 24.0 °C.
- Determine the state after a fault followed by recovery at 23.5 °C using the original ±1 °C thresholds. Explain why the old HEAT state should not be restored merely because it was active before failure.
Worked answers
For the first challenge, change BAND_C from 1.0f to 2.0f. Both entry comparisons then become target - 2 and target + 2; keep the retention comparisons against the target unchanged. The sequence becomes HEAT, HEAT, IN_RANGE, COOL, COOL, IN_RANGE. Changing the return comparisons as well would create a different controller.
For the second challenge, the fault clears the previous state. Recovery begins at IN_RANGE; 23.5 °C is neither ≤23 nor ≥25, so IN_RANGE remains. The decision therefore follows verified data and an explicit restart rule. Do not substitute zero for an invalid reading: zero would incorrectly request HEAT.
Questions and answers
Why can 23.5 °C produce two different colours? Because of the previous state: HEAT persists until the target, while IN_RANGE has no reason to request heating before the lower threshold.
Does hysteresis improve DHT11 accuracy? No. It changes the decision rule, not the measurement characteristics.
Why check humidity too? This lesson defines a valid sample as a confirmed temperature/humidity pair; an invalid part rejects the whole sample.
Why may all three LEDs blink together? Only the dedicated FAULT pattern uses all three. It is not a valid temperature state or a command to real heating and cooling equipment.
Primary sources
- Adafruit — DHT overview and limitations
- Adafruit — connecting a DHT sensor
- Adafruit — DHT library implementation
- Espressif — ADC API
- Espressif — GPIO API
Downloads
Save the expectation table and your observations with the sketch. Host logic checks, a real ESP32 build, and physical testing are separate verification results.
