What you will learn
- Set a target from 20 to 30 °C with a potentiometer.
- Explain hysteresis and its ON and OFF thresholds.
- Distinguish controller demand, actual relay state, and HOLD.
- Handle an invalid reading by forcing the output off.
- Interface the relay module through an additional transistor and check the contact-driven LED model.
Before you start
Complete courses 10, 11, and 16: DHT11 readings, OLED addressing, and the relay module with a transistor driver.
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
Build a temperature-controller model with a potentiometer target, DHT11 readings, OLED, and relay-switched LED. The LED represents the actual cooling output. No fan, pump, heater, or mains load is connected; switching does not lower room temperature.
Learning objectives
- Set a temperature target from an analog input.
- Combine hysteresis with a minimum relay switching interval.
- Distinguish the controller's demand from the actual output.
- Give sensor failure priority over the timing restriction.
- Coordinate measurement, decisions, and display updates.
Prerequisites
Complete courses 10, 11, and 16. You need DHT11 readings, OLED addressing, and the relay transistor interface. The reference board is a classic ESP32/ESP32-WROOM-32. GPIO labels are not header positions; other ESP32 families need adaptation.
Equipment and module 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. |
| 5 V two-channel relay module — active LOW | 1 | The reference module has input pull-ups, onboard coil drivers and coil diodes. Verify these properties; only the 5 V/two-channel category is established by the image. One channel is used. |
| Additional PN2222A NPN transistor | 1Required extra — not in kit photo | ADDITIONAL, not listed in the kit. Identify B/C/E for the exact manufacturer; PN2222A and P2N2222A may have different lead orders. ADDITIONAL: the transistor is not listed in the kit photograph. |
| 1 kΩ resistor | 1 | Base resistor; course 14 also uses one across the piezo. The kit quantity at this resistance is unconfirmed. |
| 10 kΩ resistor | 2 | R2 pulls Q1 base down to GND; R4 pulls DHT DATA up to 3V3. |
| Red LED | 1 | Anode A and cathode K; identify polarity on the actual part. |
| 220 Ω resistor | 1 | One current-limiting resistor per LED branch; use the kit’s 220 Ω resistors. |
| 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. |
| 0.96-inch OLED — reference I2C SSD1306 128×64 | 1 | VCC/GND/SDA/SCL, 3.3 V supply, onboard pull-ups to 3V3. The image does not confirm the controller, interface or resolution. |
| 10 kΩ potentiometer | 1 | Two end terminals and a wiper W; identify their physical positions on the actual component. |
| Jumper wires | 22 | Use male-to-male or female-to-male leads to suit the board headers. Approximate quantity; depends on the physical layout. |
The PN2222A transistor is a required additional component, absent from the photographed kit. Verify B, C, and E against the exact manufacturer and package datasheet. Similar transistor names do not guarantee identical lead order. onsemi — PN2222A
Use a verified 5 V, active-LOW relay module with an input that returns HIGH when released, onboard coil drivers, and coil protection. Its schematic is not confirmed by the photograph. The DHT11 module and 128 × 64 SSD1306 OLED must support 3.3 V power and logic. An SH1106 display requires a different driver.
Why there are two thresholds
With a target of 24 °C, cooling demand becomes ON at 25 °C or above, and OFF at 23 °C or below. Between these thresholds, demand retains its previous state. This hysteresis avoids repeated switching around one temperature boundary.
MIN_SWITCH_MS = 5000 also requires five seconds between normal relay changes. Demand can change while the output waits. Five seconds is a teaching setting, not a protection specification for a compressor or another real appliance.
Circuit and assembly order
Wiring diagram
Use the same relay-switched LED circuit as course 16; no actual cooling appliance is connected. DHT11 and OLED use 3.3 V, while the relay module uses the verified USB5V rail. R2 pulls the base down; R4 pulls DATA up. Q1 is additional.
| From | To | Connection |
|---|---|---|
ESP32.GPIO25 | R1.1 | R1, 1 kΩ, limits base current. |
R1.2 | Q1.B | Base of the additional PN2222A transistor. |
Q1.B | R2.1 | R2, 10 kΩ, pulls the base low while the GPIO is not driving. |
R2.2 | ESP32.GND | Other end of the pull-down resistor. |
Q1.E | ESP32.GND | Emitter to common ground; verify actual B/C/E pin order. |
Q1.C | K1.IN1 | Collector pulls IN1 LOW when GPIO25 is HIGH. |
ESP32.USB5V | K1.VCC | Reference module 5 V supply; retain the documented JD-VCC jumper if present. |
ESP32.GND | K1.GND | Common ground for module and input transistor. |
ESP32.USB5V | K1.IN2 | Hold the unused active-LOW channel input HIGH, meaning OFF. |
ESP32.3V3 | K1.COM1 | Common contact feeds only the 3.3 V LED circuit. |
K1.NO1 | R3.1 | Normally open contact to R3, 220 Ω. |
R3.2 | D1.A | Series resistor to the LED anode. |
D1.K | ESP32.GND | Indicator LED cathode to ground; leave NC1 unconnected. |
ESP32.3V3 | U1.VCC | DHT11 module verified for 3.3 V operation. |
ESP32.GND | U1.GND | Common ground. |
U1.DATA | ESP32.GPIO26 | DHT11 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 | OLED1.VCC | Reference 128×64 I2C SSD1306 powered at 3.3 V. |
ESP32.GND | OLED1.GND | Common ground. |
ESP32.GPIO21 | OLED1.SDA | I2C data. |
ESP32.GPIO22 | OLED1.SCL | I2C clock. |
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 to the ADC1 input. |
- Disconnect USB. Join the ESP32, sensor, display, and relay-module grounds.
- Connect GPIO25 through R1, 1 kΩ, to Q1.B; R2, 10 kΩ, from B to GND; Q1.E to GND; Q1.C to module IN1.
- Connect module VCC and unused IN2 to the board's verified USB-fed 5 V header. If JD-VCC exists, retain only the documented common-supply jumper arrangement.
- Wire the contact branch: 3V3 to COM1, NO1 through R3, 220 Ω, to the LED anode, and its cathode to GND. Leave NC1 and channel-two contacts unused.
- Connect DHT11 VCC to 3V3, GND to GND, and DATA to GPIO26. Add R4, 10 kΩ, between DATA and 3V3, including when the module has its own pull-up.
- Connect OLED VCC to 3V3, GND to GND, SDA to GPIO21, and SCL to GPIO22. Its pull-ups must connect to 3.3 V.
- Connect the potentiometer ends to 3V3/GND and its wiper to GPIO34. Check every branch before applying USB power.
GPIO HIGH activates Q1, pulls IN1 LOW, and closes COM1–NO1. Q1 controls the module input, not a bare coil. Do not place a flyback diode across IN1. Check the USB load, including coil current; do not connect a second 5 V source in parallel with USB. Espressif — board power
Arduino preparation
Use Arduino IDE 2.x, esp32 by Espressif Systems 3.3.12, ESP32 Dev Module, and 115200 baud. Install these Library Manager versions:
| Library | Version |
|---|---|
| DHT sensor library by Adafruit | 1.4.7 |
| Adafruit Unified Sensor | 1.1.15 |
| Adafruit SSD1306 | 2.5.17 |
| Adafruit GFX Library | 1.12.6 |
| Adafruit BusIO | 1.17.4 |
Wire is included with the ESP32 core. Extract the Arduino ZIP, open cooling_controller.ino from its matching folder, select the port, then run Verify and Upload.
| Library and release | Version | Dependencies |
|---|---|---|
| DHT sensor library | 1.4.7 | Adafruit Unified Sensor 1.1.15 |
| Adafruit Unified Sensor | 1.1.15 | None |
| Adafruit SSD1306 | 2.5.17 | Adafruit GFX Library 1.12.6 |
| Adafruit GFX Library | 1.12.6 | Adafruit BusIO 1.17.4 |
| Adafruit BusIO | 1.17.4 | None |
Wire is included in the ESP32 core; do not install it separately through Library Manager.
Complete program
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <DHT.h>
#include <math.h>
constexpr uint8_t RELAY_DRIVER_PIN = 25;
constexpr uint8_t DHT_PIN = 26;
constexpr uint8_t POT_PIN = 34;
constexpr uint8_t SDA_PIN = 21;
constexpr uint8_t SCL_PIN = 22;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint32_t SAMPLE_MS = 2500;
constexpr uint32_t POT_SAMPLE_MS = 100;
constexpr uint32_t DISPLAY_MS = 250;
constexpr uint32_t MIN_SWITCH_MS = 5000;
Adafruit_SSD1306 display(128, 64, &Wire, -1, 100000UL, 100000UL);
DHT dht(DHT_PIN, DHT11);
bool ready = false;
bool sampleAttempted = false;
bool currentValid = false;
bool coolingDemand = false;
bool relayOn = false;
float currentTemperature = NAN;
float currentHumidity = NAN;
uint16_t rawValue = 0;
uint8_t targetC = 20;
uint32_t lastSampleAt = 0;
uint32_t lastPotAt = 0;
uint32_t lastDisplayAt = 0;
uint32_t lastRelayChangeAt = 0;
void scanI2CBus() {
Serial.println("I2C scan: ACK identifies an address, not the display controller.");
uint8_t found = 0;
for (uint8_t address = 1; address < 127; ++address) {
Wire.beginTransmission(address);
if (Wire.endTransmission() == 0) {
Serial.print("ACK at 0x"); Serial.println(address, HEX);
++found;
}
}
if (found == 0) Serial.println("No I2C device acknowledged.");
}
bool initializeDisplay() {
if (!Wire.begin(SDA_PIN, SCL_PIN)) {
Serial.println("OLED ERROR: I2C initialization failed; reset to retry.");
return false;
}
Wire.setClock(100000);
Wire.setTimeOut(50);
scanI2CBus();
Wire.beginTransmission(OLED_ADDRESS);
if (Wire.endTransmission() != 0) {
Serial.println("OLED ERROR: configured address did not ACK; check wiring/address and reset.");
return false;
}
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS, false, false)) {
Serial.println("OLED ERROR: buffer initialization failed; reset to retry.");
return false;
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setTextWrap(false);
return true;
}
uint8_t targetFromRaw(uint16_t raw) {
return static_cast<uint8_t>(20UL + static_cast<uint32_t>(raw) * 10UL / 4095UL);
}
void evaluateDemand() {
if (!currentValid) coolingDemand = false;
else if (currentTemperature >= static_cast<float>(targetC) + 1.0f) coolingDemand = true;
else if (currentTemperature <= static_cast<float>(targetC) - 1.0f) coolingDemand = false;
}
void setRelay(bool on, uint32_t now) {
if (on == relayOn) return;
relayOn = on;
digitalWrite(RELAY_DRIVER_PIN, relayOn ? HIGH : LOW);
lastRelayChangeAt = now;
}
void applyRelay(uint32_t now) {
if (!ready || !currentValid) {
coolingDemand = false;
setRelay(false, now); // A fault overrides the normal minimum interval.
return;
}
if (coolingDemand != relayOn && static_cast<uint32_t>(now - lastRelayChangeAt) >= MIN_SWITCH_MS) {
setRelay(coolingDemand, now);
}
}
void sampleEnvironment() {
sampleAttempted = true;
const float humidity = dht.readHumidity();
const float temperature = dht.readTemperature();
if (!isfinite(humidity) || !isfinite(temperature)) {
currentValid = false;
currentTemperature = currentHumidity = NAN;
} else {
currentValid = true;
currentTemperature = temperature;
currentHumidity = humidity;
}
}
void reportSample() {
if (!currentValid) Serial.print("SENSOR ERROR: no current reading");
else {
Serial.print("T_C="); Serial.print(currentTemperature, 1);
Serial.print(" RH_pct="); Serial.print(currentHumidity, 1);
}
Serial.print(" target_C="); Serial.print(targetC);
Serial.print(" demand="); Serial.print(coolingDemand ? "ON" : "OFF");
Serial.print(" relay="); Serial.print(relayOn ? "ON" : "OFF");
Serial.println(currentValid && coolingDemand != relayOn ? " HOLD" : "");
}
void drawController() {
display.clearDisplay();
display.setCursor(0, 0); display.print("Cooling LED model");
display.setCursor(0, 12);
if (!sampleAttempted) display.print("Waiting for DHT11");
else if (!currentValid) display.print("SENSOR ERROR");
else {
display.print("T: "); display.print(currentTemperature, 1); display.print(" C");
display.setCursor(0, 22); display.print("RH: "); display.print(currentHumidity, 1); display.print(" %");
}
display.setCursor(0, 34); display.print("Target: "); display.print(targetC); display.print(" C");
display.setCursor(0, 44); display.print("Relay: "); display.print(relayOn ? "ON" : "OFF");
display.setCursor(0, 54);
if (currentValid && coolingDemand != relayOn) display.print("HOLD: minimum 5 s");
else if (!currentValid) display.print("Relay forced OFF");
else display.print("Demand: ");
if (currentValid && coolingDemand == relayOn) display.print(coolingDemand ? "ON" : "OFF");
display.display();
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_DRIVER_PIN, OUTPUT);
digitalWrite(RELAY_DRIVER_PIN, LOW);
analogReadResolution(12);
analogSetPinAttenuation(POT_PIN, ADC_11db);
rawValue = analogRead(POT_PIN);
targetC = targetFromRaw(rawValue);
dht.begin();
ready = initializeDisplay();
if (!ready) return;
drawController();
Serial.println("Cooling LED model: first DHT sample after 2.5 s; initial relay OFF for at least 5 s.");
const uint32_t now = millis();
lastSampleAt = lastPotAt = lastDisplayAt = lastRelayChangeAt = now;
}
void loop() {
if (!ready) return;
uint32_t now = millis();
if (static_cast<uint32_t>(now - lastPotAt) >= POT_SAMPLE_MS) {
lastPotAt = now;
rawValue = analogRead(POT_PIN);
targetC = targetFromRaw(rawValue);
}
bool newSample = false;
if (static_cast<uint32_t>(now - lastSampleAt) >= SAMPLE_MS) {
lastSampleAt = now;
sampleEnvironment();
newSample = true;
}
now = millis();
evaluateDemand();
applyRelay(now);
if (newSample) reportSample();
if (newSample || static_cast<uint32_t>(now - lastDisplayAt) >= DISPLAY_MS) {
lastDisplayAt = now;
drawController();
}
}
initializeDisplay() scans I2C at 100 kHz and checks OLED_ADDRESS, default 0x3C. Use 0x3D only when confirmed. An address response does not identify the controller. If display initialization fails, normal operation does not begin and the relay remains OFF.
From a knob position to a target
GPIO34 is sampled every 100 ms using 12-bit ADC readings and ADC_11db. targetFromRaw() calculates 20 + raw * 10 / 4095: raw 0 gives 20 °C, 2048 gives 25 °C, and 4095 gives 30 °C. This is a user setting, not a voltage or temperature measurement.
Target changes use the latest valid temperature before another DHT attempt. ADC noise near a step boundary can move the target between adjacent values; hysteresis and the switching interval address different parts of the resulting behavior.
Measurement, demand, and output
sampleEnvironment() reads DHT every 2500 ms, first after that interval following display setup. drawController() refreshes the OLED approximately every 250 ms. A refreshed screen does not imply a fresh measurement. Differences between millis() values schedule the tasks without long delay() pauses.
evaluateDemand() sets coolingDemand from valid T/RH; applyRelay() controls actual relayOn. Within the hysteresis band, demand retains its state even when the actual relay is still waiting. HOLD indicates that valid demand differs from the actual output while the minimum interval blocks a change. The initial OFF period also lasts at least five seconds.
Sensor-failure behavior
If either measurement is nonfinite, such as NaN, currentValid becomes false, demand becomes OFF, and the relay switches off as that failed attempt is processed. The five-second restriction does not delay this action. Fault discovery occurs at a reading attempt. Adafruit — reading implementation
An actual ON→OFF transition starts a new minimum OFF interval. Repeated failures while already OFF do not restart that interval. Recovery restores readings; restarting needs the appropriate temperature threshold and elapsed time. A failed measurement is never replaced by a fabricated zero.
First run and experiment
Observe startup waiting, temperature, and target. Turn the target below and then above room temperature, waiting more than five seconds each time. Watch the contact-driven LED rather than only the module indicator. Record your own observations; the table lists predictions.
| Condition with a valid reading | Expected behavior | Your observation |
|---|---|---|
| T ≥ target + 1; OFF for at least 5 s | Demand ON, relay ON | Complete |
| T ≤ target − 1; ON for less than 5 s | Demand OFF, relay ON, HOLD | Complete |
| Same demand after the minimum interval | Relay OFF | Complete |
| T between thresholds | Demand retains its previous state | Complete |
| No valid reading | Waiting/error; relay OFF | Complete |
To check a startup reading failure, disconnect USB, remove DHT DATA, then reconnect USB. Restore the wire with power off. This resets the timers and does not demonstrate timed recovery within one uninterrupted run.
Troubleshooting
| Problem | Check |
|---|---|
| Relay behavior is reversed | Verify active-LOW triggering and transistor inversion. |
| Relay clicks but LED stays dark | Check COM1, NO1, R3, and LED polarity. |
| Temperature is missing | Check DHT11 type, GPIO26, R4, and the 3.3 V module variant. |
| OLED startup fails | Check 21/22, address, controller, and power. |
| Target changes without switching | Check both thresholds, reading validity, and HOLD. |
| Board resets when switching | Check the USB budget, common ground, and module wiring. |
Challenge and worked answer
Set a target of 24 °C. Assume valid readings and initial OFF from t = 0: temperature is 26 °C at t = 2.5 s and 5 s, then 22 °C at 7.5 s and 10 s. Determine demand and relay state.
Answer: 2.5 s — demand ON, relay OFF/HOLD; 5 s — ON/ON; 7.5 s — OFF/ON/HOLD; 10 s — OFF/OFF. If the 7.5 s attempt instead fails, the relay turns off then. The earliest next ON is 12.5 s with a valid reading and ON demand. These are constructed examples, not measured results.
Downloads
Keep your observation log. Computer-based logic checks, actual compilation, and physical experiments are separate results.
