Community project

Dual-Zone Fan Controller

ESP32
Photo of Dual-Zone Fan Controller
Generated with AI

ziti

Published October 25, 2026

This project builds a dual-zone temperature monitor and fan controller using an ESP32 microcontroller to manage cooling in enclosed spaces. Two DS18B20 temperature sensors independently monitor separate zones, and the controller adjusts fan speed via PWM based on thermal readings, with RPM feedback for stall detection. The guide provides a complete wiring diagram, parts list, and firmware that implements smooth ramping, hysteresis control, and optional MQTT telemetry.

Builders will learn how to interface multiple one-wire temperature sensors with an ESP32, design a MOSFET-based PWM fan driver, and safely integrate mains-powered cooling equipment using a Mean Well dual-output supply. The assembly outline covers power distribution, sensor wiring, PWM stage construction, and tachometer feedback integration, with inspection checkpoints before energizing the system.

Wiring diagram

Wiring diagram for Dual-Zone Fan Controller

Gather all the parts

QtyComponent
1

DS18B20

DS18B20 waterproof probe

Digital temperature sensor using OneWire protocol

1

DS18B20

DS18B20 waterproof probe

Digital temperature sensor using OneWire protocol

1

Pc Fan

ARCTIC F12 PWM PST CO, 120 mm 12 V

Standard 4-wire PC/case fan (Intel 4-wire PWM spec): always-on +12V power, ground, a ~25 kHz PWM control input (TTL, pulled to 5V or 3.3V), and an open-collector tachometer output (2 pulses/rev, needs an external pull-up to read RPM). Unlike a brushed DC motor it is brushless with built-in commutation, so it is driven directly from an MCU PWM pin rather than through an H-bridge; do not wire it like a bidirectional dc-motor. With no PWM signal it runs at full RPM. 5V 4-wire variants exist but 12V is standard.

1

Mean Well RD-125A dual-output enclosed power supply

The existing enclosed supply that provides isolated regulated 5 volt and 12 volt power to this controller and fan.

1

2N7000 N-channel MOSFET, TO-92

2N7000

A small through-hole transistor that safely pulls the fan PWM control wire low without placing 12 volts on the ESP32.

1

4.7 kilohm resistor, 1/4 W

4.7 kΩ

A through-hole resistor that holds the shared temperature-probe data wire at safe 3.3 volt logic level when nobody is talking.

1

100 ohm resistor, 1/4 W

100 Ω

A through-hole resistor that softens fast PWM edges at the MOSFET gate to reduce electrical noise.

1

100 kilohm resistor, 1/4 W

100 kΩ

A through-hole resistor that holds the MOSFET off when the ESP32 starts or loses power.

1

10 kilohm resistor, 1/4 W

10 kΩ

A through-hole resistor that lifts the fan tachometer signal only to safe 3.3 volt ESP32 logic level.

1

1 kilohm resistor, 1/4 W

1 kΩ

A through-hole resistor that limits noise and transient current into the ESP32 tachometer input.

Assemble it in 6 steps

1. Keep mains wiring out of the carrier board

Turn off and unplug the RD-125A before opening the enclosure. Mount the small carrier board and ESP32-S3-Zero well away from the power supply’s mains terminals and the SDR antenna path. Bring only the already-regulated +5V, +12V, and COM terminals to the carrier board.

  • Use short twisted pairs for +12V/COM to the fan and +5V/COM to the carrier board.
  • Keep the ESP32 ceramic antenna clear of metal and at least several centimetres from the SDR RF section.
  • The RD-125A mains terminals can retain dangerous voltage; do not put mains wiring, mains terminals, or exposed metal on this low-voltage carrier board.

2. Power the controller and fan

Connect RD-125A +5V to the ESP32-S3-Zero 5V pin (controller power), and connect RD-125A COM to an ESP32 GND pin (ground). Connect RD-125A +12V to the fan’s yellow 12V wire (fan power) and COM to the fan’s black wire (ground).

  • A standard 4-wire fan usually uses black = ground, yellow = 12V, green = tachometer, and blue = PWM; confirm the colours in the fan’s own label or datasheet.
  • Use a removable 4-pin fan header so the fan can be replaced.
  • Never connect the 12V fan wire to an ESP32 pin or the ESP32 5V pin — 12 volts can permanently damage the board.

3. Wire both temperature probes

Connect each DS18B20 red wire to 3V3 (power), each black wire to GND (ground), and join both white or yellow data wires to GPIO4 (signal). Place the 4.7 kΩ resistor between 3V3 and that same GPIO4/data junction (keeps the shared data wire at a valid idle level). Put one probe near the Orange Pi/NVMe/SDR heat path and the other where it senses the RD-125A lower-compartment air, not directly against a live mains terminal.

  • Twist each probe’s data wire loosely with a ground wire if the cable is long.
  • Label the upper and lower probe cables before closing the enclosure; the firmware cools from the hotter reading regardless of discovery order.
  • Make sure 3V3 and GND are not swapped on either probe — swapped power can damage a probe.

4. Build the PWM control stage

With the flat face of a common 2N7000 facing you, verify the actual lead order from its datasheet before soldering. Connect its source to GND (ground), its drain to the fan blue PWM wire (fan control), and its gate through the 100 Ω resistor to GPIO5 (signal). Connect the 100 kΩ resistor from that gate junction to GND (holds the transistor off if the ESP32 is unpowered).

  • Keep the blue PWM wire and this transistor stage close together, and keep it away from the DS18B20 data wire.
  • The fan provides its own pull-up on the blue control wire; do not connect it to 5V or 12V yourself.
  • Reversing drain and source or connecting the blue fan wire to 12V can prevent speed control and can damage the fan or controller.

5. Wire the RPM feedback safely

Connect the fan green tachometer wire to one end of the 1 kΩ resistor. Connect the other end of that resistor to GPIO6 (RPM signal). Connect the 10 kΩ resistor from 3V3 to the GPIO6 side of the 1 kΩ resistor (makes a safe 3.3V logic signal).

  • Keep the tach wire away from the 12V fan power pair where practical.
  • The tach wire is an output from the fan; do not drive it from an ESP32 output pin.
  • Do not pull the tach wire up to 5V or 12V — GPIO6 accepts 3.3V logic only.

6. Inspect before applying power

With power still off, check that +12V reaches only the fan yellow wire, 5V reaches only the ESP32 5V pin, and every low-voltage part shares COM/GND. Confirm the MOSFET gate has the 100 kΩ path to ground. Then power the RD-125A: the fan should start at full speed for about five seconds before settling.

  • If the fan does not start at full speed, remove power and check the fan connector order first.
  • Mount the fan so it produces a defined enclosure airflow path rather than recirculating air inside one compartment.
  • Do not handle loose wires near the RD-125A mains terminals while it is powered.

Review all connections

1. Connections between "rd125a" and "ESP32"

Functionrd125aESP32
power+5V → 5 V supply to the ESP32-S3-Zero 5V pin5V
power+12V → Pc Fan 12VEXT
groundCOM → shared negative returnGND

2. Connections between "upper_probe" and "ESP32"

Functionupper_probeESP32
powerVCC → 3.3 V probe power3V3
groundGND → probe groundGND
dataDATA → shared temperature dataGPIO 4

3. Connections between "lower_probe" and "ESP32"

Functionlower_probeESP32
powerVCC → 3.3 V probe power3V3
groundGND → probe groundGND
dataDATA → DS18B20 DATAEXT

4. Connections between "r_ds_pullup" and "ESP32"

Functionr_ds_pullupESP32
powerA → 3.3 V pull-up source3V3
digitalB → DS18B20 DATAEXT

5. Connections between "fan_120mm" and "ESP32"

Functionfan_120mmESP32
groundGND → fan ground returnGND
dataTACH → 1 kilohm resistor, 1/4 W AEXT

6. Connections between "q1_pwm" and "ESP32"

Functionq1_pwmESP32
digitalD → Pc Fan PWMEXT
groundS → MOSFET source groundGND

7. Connections between "r_gate" and "ESP32"

Functionr_gateESP32
digitalA → 25 kHz ESP32 PWM outputGPIO 5
digitalB → 2N7000 N-channel MOSFET, TO-92 GEXT

8. Connections between "r_gate_pd" and "ESP32"

Functionr_gate_pdESP32
digitalA → 2N7000 N-channel MOSFET, TO-92 GEXT
groundB → gate pull-down groundGND

9. Connections between "r_tach_series" and "ESP32"

Functionr_tach_seriesESP32
digitalB → protected tachometer inputGPIO 6

10. Connections between "r_tach_pullup" and "ESP32"

Functionr_tach_pullupESP32
powerA → safe 3.3 V tach pull-up3V3
digitalB → 1 kilohm resistor, 1/4 W BEXT

Deploy the firmware

#include <Arduino.h>
#include <OneWire.h>
#include <DallasTemperature.h>

// Set to 1 only after entering real network and MQTT details below.
// Cooling is entirely local and does not depend on this feature.
#define ENABLE_MQTT 0
#if ENABLE_MQTT
#include <WiFi.h>
#include <PubSubClient.h>
const char *WIFI_SSID = "YOUR_WIFI_NAME";
const char *WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char *MQTT_HOST = "192.168.1.10";
const uint16_t MQTT_PORT = 1883;
const char *MQTT_TOPIC = "adsb-enclosure/cooling/state";
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
#endif

#define ONEWIRE_PIN 4
#define FAN_PWM_PIN 5
#define FAN_TACH_PIN 6

constexpr uint32_t PWM_FREQUENCY_HZ = 25000;
constexpr uint8_t PWM_RESOLUTION_BITS = 10;
constexpr uint16_t PWM_MAX = (1U << PWM_RESOLUTION_BITS) - 1U;
constexpr float MIN_FAN_PERCENT = 35.0f;
constexpr float FULL_FAN_PERCENT = 100.0f;
constexpr float TEMP_START_C = 30.0f;
constexpr float TEMP_FULL_C = 55.0f;
constexpr float TEMP_HYSTERESIS_C = 1.5f;
constexpr float SLEW_PERCENT_PER_STEP = 1.0f;
constexpr uint32_t CONTROL_INTERVAL_MS = 250;
constexpr uint32_t TEMP_INTERVAL_MS = 1000;
constexpr uint32_t TACH_WINDOW_MS = 5000;
constexpr uint32_t STARTUP_FULL_SPEED_MS = 5000;
constexpr uint16_t STALL_RPM_LIMIT = 200;
constexpr uint8_t FAN_PULSES_PER_REV = 2;

OneWire oneWire(ONEWIRE_PIN);
DallasTemperature sensors(&oneWire);
DeviceAddress sensorAddress[2];
bool sensorPresent[2] = {false, false};
float compartmentC[2] = {NAN, NAN};
volatile uint32_t tachPulses = 0;
float fanPercent = FULL_FAN_PERCENT;
float requestedPercent = FULL_FAN_PERCENT;
uint16_t fanRpm = 0;
bool sensorFault = true;
bool stallFault = false;
uint32_t startupUntil = 0;
uint32_t lastControlMs = 0;
uint32_t lastTempMs = 0;
uint32_t lastTachMs = 0;
uint32_t lastMqttMs = 0;

void IRAM_ATTR onTachPulse() {
  tachPulses++;
}

void applyFanPercent(float percent) {
  percent = constrain(percent, 0.0f, 100.0f);
  // GPIO high turns Q1 on, pulling the fan's PWM wire low. This is the
  // active-low open-drain control expected by a compliant 4-wire PWM fan.
  const uint16_t duty = static_cast<uint16_t>((percent * PWM_MAX) / 100.0f);
  ledcWrite(FAN_PWM_PIN, duty);
  fanPercent = percent;
}

float demandForTemperature(float temperatureC) {
  if (temperatureC <= TEMP_START_C - TEMP_HYSTERESIS_C) return MIN_FAN_PERCENT;
  if (temperatureC >= TEMP_FULL_C) return FULL_FAN_PERCENT;
  const float span = TEMP_FULL_C - (TEMP_START_C - TEMP_HYSTERESIS_C);
  return MIN_FAN_PERCENT + (temperatureC - (TEMP_START_C - TEMP_HYSTERESIS_C)) *
         (FULL_FAN_PERCENT - MIN_FAN_PERCENT) / span;
}

void discoverSensors() {
  sensors.begin();
  const uint8_t found = sensors.getDeviceCount();
  for (uint8_t i = 0; i < 2; i++) {
    sensorPresent[i] = (i < found) && sensors.getAddress(sensorAddress[i], i);
    if (sensorPresent[i]) sensors.setResolution(sensorAddress[i], 12);
  }
}

void readTemperatures() {
  sensorFault = false;
  sensors.requestTemperatures();
  for (uint8_t i = 0; i < 2; i++) {
    if (!sensorPresent[i]) {
      sensorFault = true;
      compartmentC[i] = NAN;
      continue;
    }
    const float value = sensors.getTempC(sensorAddress[i]);
    // DallasTemperature returns DEVICE_DISCONNECTED_C (-127) when absent.
    if (value == DEVICE_DISCONNECTED_C || value < -55.0f || value > 125.0f) {
      sensorFault = true;
      compartmentC[i] = NAN;
    } else {
      compartmentC[i] = value;
    }
  }
}

void updateTachometer(uint32_t now) {
  if (now - lastTachMs < TACH_WINDOW_MS) return;
  noInterrupts();
  const uint32_t pulses = tachPulses;
  tachPulses = 0;
  interrupts();
  const uint32_t elapsed = now - lastTachMs;
  lastTachMs = now;
  fanRpm = static_cast<uint16_t>((pulses * 60000UL) / (FAN_PULSES_PER_REV * elapsed));
  // Do not report a stall while deliberately asking for a low fan speed.
  stallFault = (fanPercent >= 45.0f && fanRpm < STALL_RPM_LIMIT);
}

#if ENABLE_MQTT
void publishTelemetry(uint32_t now) {
  if (now - lastMqttMs < 10000) return;
  lastMqttMs = now;
  if (WiFi.status() != WL_CONNECTED) {
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    return;
  }
  if (!mqttClient.connected()) {
    mqttClient.connect("adsb-cooling-controller");
    return;
  }
  char payload[220];
  snprintf(payload, sizeof(payload),
           "{\"upper_c\":%.2f,\"lower_c\":%.2f,\"fan_rpm\":%u,\"pwm_percent\":%.1f,\"sensor_fault\":%s,\"stall_fault\":%s}",
           compartmentC[0], compartmentC[1], fanRpm, fanPercent,
           sensorFault ? "true" : "false", stallFault ? "true" : "false");
  mqttClient.publish(MQTT_TOPIC, payload, true);
}
#endif

void setup() {
  // Default to full speed before temperature discovery or any network activity.
  ledcAttach(FAN_PWM_PIN, PWM_FREQUENCY_HZ, PWM_RESOLUTION_BITS);
  applyFanPercent(FULL_FAN_PERCENT);
  pinMode(FAN_TACH_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(FAN_TACH_PIN), onTachPulse, FALLING);
  Serial.begin(115200);
  discoverSensors();
  startupUntil = millis() + STARTUP_FULL_SPEED_MS;
  lastTachMs = millis();
#if ENABLE_MQTT
  WiFi.mode(WIFI_STA);
  mqttClient.setServer(MQTT_HOST, MQTT_PORT);
#endif
}

void loop() {
  const uint32_t now = millis();
  if (now - lastTempMs >= TEMP_INTERVAL_MS) {
    lastTempMs = now;
    readTemperatures();
  }
  updateTachometer(now);

  if (now - lastControlMs >= CONTROL_INTERVAL_MS) {
    lastControlMs = now;
    if (now < startupUntil || sensorFault || stallFault) {
      requestedPercent = FULL_FAN_PERCENT;
    } else {
      const float hotterC = max(compartmentC[0], compartmentC[1]);
      requestedPercent = demandForTemperature(hotterC);
    }
    if (fanPercent < requestedPercent) fanPercent = min(fanPercent + SLEW_PERCENT_PER_STEP, requestedPercent);
    if (fanPercent > requestedPercent) fanPercent = max(fanPercent - SLEW_PERCENT_PER_STEP, requestedPercent);
    applyFanPercent(fanPercent);
  }

#if ENABLE_MQTT
  mqttClient.loop();
  publishTelemetry(now);
#endif
  delay(10);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik