Updated on 2023-01-06
Control 3 or 4 pin PWM fans using this library
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.
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.
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.
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:
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
ledcWrite(0,duty>>8);
}
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!