Projects ESP32

FanCam

An ESP32-CAM that streams MJPEG to a browser while driving a 4-wire fan at the 25 kHz PWM spec, reading its tachometer on an interrupt, and catching a stalled rotor before it cooks.

Shipped since May 2026 #esp32-cam#ov2640#mjpeg#pwm
25 kHz
Fan PWM frequency
8 -bit
PWM resolution
5
Fan curve points
1,584
Lines of C++

What it is

An AI-Thinker ESP32-CAM doing two jobs at once: streaming live video to a browser, and driving a PC case fan properly — real 25 kHz PWM, real tachometer feedback, and a stall check that notices when the rotor stops.

Why I built it

I wanted to see the thing I was cooling. A fan controller that reports RPM tells you a number; a fan controller with a camera pointed at the fan tells you whether the number is true. The camera started as a debugging aid and turned out to be the more interesting half.

Driving the fan correctly

A 4-pin fan’s PWM input is not a general-purpose speed knob. The specification asks for 25 kHz, and the reason is audible: drive it at a few hundred hertz and the fan whines at the switching frequency, because the coils are being pulsed inside the range human hearing is most sensitive to. Push the carrier up to 25 kHz and it moves above that band.

#define LEDC_FAN_FREQ 25000   // 25 kHz — per the 4-wire fan spec
#define LEDC_LED_FREQ  5000
#define LEDC_RES       8      // 8-bit: 0-255

The fan gets its own LEDC channel, and the flash LED gets another at a much lower frequency, because an LED does not care and a lower carrier gives smoother dimming at the bottom of the range.

The constraint that shapes the whole file: the OV2640 needs a clock, and on this board that clock is generated by LEDC too. The channel assignments are therefore not arbitrary — they are chosen so the fan and LED channels cannot collide with the camera’s XCLK. Get that wrong and the symptom is not a broken fan, it is a camera that fails to initialise.

Reading the tachometer

A 4-wire fan’s tach line pulses twice per revolution, open-collector. It is read on GPIO2 as INPUT_PULLUP with a falling-edge interrupt, counting pulses in the ISR and converting to RPM outside it.

GPIO2 is 3.3 V tolerant only. The tach line must not be pulled to 5 V — this is written in the header as a warning because it is the kind of mistake that costs a board and is invisible until it isn’t.

Stall detection

The useful failure to catch is a fan that is commanded to spin and isn’t: something jammed the blades, or a bearing seized. The check compares measured RPM against commanded PWM — if the ratio falls below a threshold and stays there past a delay, it is a stall rather than a transient.

30.0f,   // stallRatio — RPM must track PWM by at least this much
3.0f,    // stallDelay — sustained for this many seconds before it counts

The delay is the part that matters. Fans take time to spin up, and a threshold without hysteresis fires every time the curve steps. Three seconds is long enough to ride through spin-up and short enough to be worth having.

Concurrency

Streaming video and servicing a control loop on one dual-core chip means the work has to be split explicitly. Four FreeRTOS tasks, with the fan and tach loops at a higher priority than the web server and the stream:

#define PRI_WEB    5
#define PRI_STREAM 5
#define PRI_FAN    6
#define PRI_TACH   6

That ordering is deliberate. If the network gets busy, the stream should stutter — the fan control loop should not. Dropping frames is a cosmetic failure; missing a stall is a thermal one.

Limitations

  • The stream is MJPEG, which is bandwidth-hungry and has no audio. It is the right choice for this board and the wrong choice for anything with a budget.
  • One client at a time is the realistic limit for the stream.