Click here to Skip to main content
15,669,905 members
Articles / Internet of Things
Posted 1 Jan 2023

Tagged as


17 bookmarked

A PWM Based Fan Controller for Arduino

Rate me:
Please Sign up or sign in to vote.
5.00/5 (16 votes)
6 Jan 2023MIT6 min read
Control 3 or 4 pin PWM fans using this library
For 3 pin fans, controlling them is easy, but primitive. For 4 pin fans, you can use an adaptive algorithm to match the fan's reported RPM to your target RPM without assuming a linear response curve.

fan controller


I needed some code to control a 12 volt fan with a 5 volt PWM input and a 5 volt tachometer output. The code needed to control for environmental conditions and hardware variance that can influence the response curve of the fan with respect to the PWM duty cycle versus the RPM response. This is to ensure a best effort attempt at achieving the target RPM.

Understanding This Mess

Mainly, we'll be covering 4 pin fans that have a tachometer. You can purchase widely available PC chassis fans that have this feature, and that's what I tested this code with - specifically a Noctua NF-A14.

Hardware doesn't necessarily behave the same device to device, or sometimes over time the device characteristics can change, in addition to the variance caused by environmental factors. If you obstruct the airflow you'll notice that the fan's RPM changes even if the signal stays the same.

So, to that end, we need something adaptive in order to hit a target RPM. It also needs to be fairly graceful, avoiding jitter which is cycling between the nearest lower value and nearest higher value when it can't land right on the target.

The basic operating theory of the code is to continuously sample the RPM, and then adjust the duty cycle upward or downward until the target RPM is hit, or is close enough that trying to get closer would cause jitter.

Note that this code can still encounter situations where it will result in jitter, especially when setting the RPM significantly below the minimum operating RPM. It would be more accurate to say it dramatically reduces jitter, eliminating it in most situations.

It also requires some tuning for different fans. I've provided some defaults for the algorithm that should basically work. We use something called PID to handle the zeroing in on a target RPM.

I don't really have the math background to grok it, but I'll provide some material I found here for those of you that might.

The fan's tachometer reports a number of ticks - usually two - per revolution. That means twice per revolution, it will drive the tach line high.

There are two ways in theory to take an RPM reading from something like this. The preferable way would be to count the duration between ticks, and extrapolate from there. 

The other way to do it is to count the actual number of ticks that have elapsed in a given period.

Update: Originally I used the latter method of computing RPM because I had problems with the former method. Having solved that, the RPM readings are now basically instant, and the PID no longer waits to recompute as a consequence.

Wiring This Mess

You need a 12 volt power supply capable of delivering about 0.2 amps to be safe.

To run this project, you need an ESP32, but you can use a Teensy or something if you modify the code.

You need a level shifter because the fan's PWM and tach operate at 5 volts, while the ESP32 operates at 3.3 volts.

Here's some information on the wiring of 4-pin fans. Be very careful to orient the plug properly with respect to the pinouts.

Do all this wiring and triple check it before applying power. Failure to do so can damage your equipment.

What you do is wire the PWM and the tach pins into the high side of the level shifter.

Wire the 5v VIN on the shifter to the 5v on the ESP32 dev board.

Wire the 3.3v VIN on the shifter to the ESP32's 3.3v output.

Wire ALL the grounds together. That means the ESP32's ground, the fan's ground, and the level shifter's ground.

Wire the ESP32's GPIO 22 to the fan's tach low level shift side.

Wire the ESP32's GPIO 23 to the fan's PWM low level shift side.

Wire the fan's power to your 12 volt power supply

Plug the ESP32 into your PC.

Coding This Mess

Using the code is usually simpler than it was to create it. First, you add an entry to your Platform IO INI file, or download the code and include it in your project's libraries (for Arduino IDE and such.):

lib_deps = codewitch-honey-crisis/htcw_fan_controller ; PIO ini entry for lib

Next you #include the file in your project and optionally import the arduino namespace:

#include <fan_controller.hpp>
using namespace arduino;

Now you should declare the fan. This varies depending on platform:

For the ESP32

There are two constructors. One of them is for fans with a tach, and one is for fans without.

// four pin fan:
fan_controller fan(pwm_set,nullptr,TACH_PIN,MAX_RPM);
// three pin fan:
fan_controller fan(pwm_set,nullptr, MAX_RPM);

The above assumes void pwm_set(uint8_t duty, void*) is declared and will set the duty cycle for you.

Using the built in PWM channels at 8 bit resolution, it could look like this:

static void pwm_set(uint16_t duty, void* state) {
  // input is 16-bit
  // write a 8-bit duty 

For Other Platforms

fan_controller<> is actually a template, and you pass the tach pin or -1 and the ticks per revolution, if applicable, as template arguments. It requires the use of templates for complicated reasons involving handling interrupts without being able to pass some sort of argument under the core arduino framework.

// four or three pin fan:
using fan_ctrl = fan_controller<TACH_PIN>;
static fan_ctrl fan(pwm_set,nullptr,MAX_RPM);

The above assumes void pwm_set(uint8_t duty, void*) is declared and will set the duty cycle for you. Some MCUs support native PWM signal generation, while other MCUs will require you to use a 3rd party device to generate such signals. In pwm_set from above, you'd do whatever you need to in order to get your hardware to generate the signal for you.

The duty argument is a value from 0-65535 that indicates the duty of the PWM in 16-bit value space, such that 0 is no duty, and 65535 is 100% duty.

The state argument is optional, and is a user defined value that can be passed with the call if specified in the constructor.

In setup(), you must call initialize() before you use the fan controller. In loop(), you'll want to call update().

You simply use the rpm() get/set accessors to retrieve and set the target RPM. If there's no reading available yet - or ever - it will return NAN.

You can set or retrieve the PWM duty cycle in 16-bit value space using the pwm_duty() accessors. Doing so abandons any adaptive RPM targeting and simply sets the device to the specified duty.

Before initialization it is possible to detect the minimum and maximum RPMs using find_min_rpm() and find_max_rpm(). If you pass NAN in as the max_rpm parameter to the constructor, the maximum RPM will be detected on initialize(). Of course, this only works with the 4-pin constructor. Note that the minimum RPM is the minimum effective RPM for adaptive targeting. It is often possible to go lower by setting the pwm_duty() manually.

Happy coding!


  • 1st January, 2023 - Initial submission
  • 3rd January, 2023 - Updated to make RPM floating point, and to eliminate period based updating in favor of something more real-time.
  • 4th January, 2023 - Added methods for detecting min/max fan RPMs, updated demo.
  • 6th January, 2023 - Increased RPM and PWM precision
  • 6th January, 2023 - Made more cross platform


This article, along with any associated source code and files, is licensed under The MIT License

Written By
United States United States
Just a shiny lil monster. Casts spells in C++. Mostly harmless.

Comments and Discussions

GeneralMy vote of 5 Pin
Member 137287228-Jan-23 17:58
Member 137287228-Jan-23 17:58 
QuestionSmall Nit Pin
TBC_DEV6-Jan-23 6:19
TBC_DEV6-Jan-23 6:19 
Thankyou for sharing such a great software education topic that is both affordable and useful. I wanted to point out that your use of the term hysteresis is off a bit. I wouldn't want hobbyist to pick up bad vocabulary. Hysteresis is not an error condition to be avoided, it is what you add to correct the condition you describe. Unfortunately, I am not aware of a correct term for this problem. Perhaps time or numerical "jitter" would be appropriate. You are adding hysteresis to filter-out jitter.
AnswerRe: Small Nit Pin
honey the codewitch6-Jan-23 6:23
mvahoney the codewitch6-Jan-23 6:23 
GeneralRe: Small Nit Pin
Nelek6-Feb-23 11:37
protectorNelek6-Feb-23 11:37 
SuggestionESP32 and ESP8266 I/O pins are 5V DC tolerant (chip's power supply is 3.3v DC) Pin
mikestrat4-Jan-23 7:55
mikestrat4-Jan-23 7:55 
GeneralRe: ESP32 and ESP8266 I/O pins are 5V DC tolerant (chip's power supply is 3.3v DC) Pin
honey the codewitch4-Jan-23 8:14
mvahoney the codewitch4-Jan-23 8:14 
QuestionPWM fan controller Pin
JohnDG524-Jan-23 7:22
JohnDG524-Jan-23 7:22 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.