Skip to content
HYHanie Yazdi
CV
← All projects

Project · October 2026 · Solo

Smart desk fan

A 12 V PC fan that sets its own speed from the room temperature. An STM32F411 runs the control on FreeRTOS. An ESP32 next to it provides a web dashboard, OTA updates and MQTT. Hardware, firmware, tests and CI were all designed and built in this project and tested on the real hardware.

RoleSolo: hardware, firmware, tests, CI
MCUsSTM32F411, ESP32
StackC, FreeRTOS, CubeMX/HAL, CMake · C++, PlatformIO
RepoHani256/smart-fan
6FreeRTOS tasks, no shared globals
25 kHzPWM fan drive on TIM1
47unit tests, gcc + clang, ASan + UBSan
4CI jobs on every push
2real bugs found by the tests
Smart desk fan with its OLED and the live dashboard on a phone
The fan in its printed enclosure. OLED: temperature, humidity, speed and duty. Phone: the ESP32 dashboard.

What it does

A 12 V PC fan on a desk that adjusts its own speed to the room temperature. The STM32 reads an AHT20 sensor, decides a target speed, drives the fan with PWM and measures the real speed from the tachometer. An ESP32 next to it handles the network side: dashboard, OTA updates and MQTT.

FIELDSTM32F411 · FreeRTOS · controlESP32 · connectivity AHT20temperature SSD1306 OLED Transistor stage12 V 12 V PC fanPWM in · tach out 6 tasks · no shared globals Sensor Display Control: comfort curve Fan drive + tach UART link mailbox queues · command queue stream buffer · ISR notifications Web dashboardserved from flashGET /status.json · POST /mode OTA updatespassword protected MQTT clientretained state · Last Willvalidated commands Mosquitto brokerauthenticated I2C I2C PWM 25 kHz TIM1 tach · TIM2 capture UART DMA XOR sum

Firmware on FreeRTOS

Six tasks with deliberate priorities. Data moves between them only through FreeRTOS objects: mailbox queues (xQueueOverwrite / xQueuePeek) for the latest sensor and speed values, a command queue for mode changes, a stream buffer filled from the UART interrupt, and task notifications from interrupts. Periodic tasks run on fixed periods with vTaskDelayUntil.

Why no shared globals

A real torn read. Writing a value and its timestamp is two stores, and a higher-priority task can run between them and read a new value with an old timestamp. volatile gives visibility, not atomicity. A one-slot mailbox hands over the value and its timestamp as one copy.

/* before: two stores, a reader can run in between */
g_temp     = reading;
g_temp_ts  = xTaskGetTickCount();

/* after: one sample, copied in and out of a one-slot queue */
xQueueOverwrite(tempBox, &sample);   /* writer */
xQueuePeek(tempBox, &sample, 0);     /* readers */

Simplified for this page.

Control: a comfort curve, after a PID that was the wrong model

The first version used PID. A desk fan does not cool the room, so the temperature does not respond to the fan and there is no loop to close. The fan only changes how warm it feels. The control was replaced with a comfort curve that maps temperature to speed, with three additions:

  • Hysteresis so the speed does not hunt when the temperature sits on a step.
  • Slew-rate limit so speed changes are gradual and quiet.
  • Kick-start so the fan reliably starts from standstill at low duty.

Fan drive and speed measurement

TIM1 generates the PWM at 25 kHz, driving the fan through a transistor stage. The real fan speed comes from the tachometer, measured with TIM2 input capture and filtered twice: in hardware with the timer's input filter and in software. The tach input needed both, see the debug log below.

The AHT20 and the SSD1306 OLED share I2C. When the sensor is unplugged, the firmware recovers the bus instead of hanging, and when readings go stale it falls back to a safe fixed speed.

ESP32: dashboard, OTA and MQTT

  • Web dashboard served from flash: live values, 5-minute graphs, mode buttons, a speed slider and offline detection.
  • REST endpoints: GET /status.json and POST /mode.
  • Password-protected OTA updates and automatic Wi-Fi reconnect.
  • MQTT with PubSubClient to a Mosquitto broker with authentication: a retained state topic, online/offline status through Last Will, and a command topic with strict input validation.
  • Secrets stay out of git.

C++, Arduino framework, PlatformIO.

Tests and CI

47 unit tests with Unity and CTest cover the control logic, both ends of the protocol and the ESP32 parser. They run with gcc and clang, and again under AddressSanitizer and UndefinedBehaviorSanitizer.

The tests found two real bugs:

  • Fan speed oscillated at low manual speeds.
  • The checksum parser accepted a space as a hex digit.

GitHub Actions runs four jobs on every push:

TRIGGERgit push
✓ JOB 1Unit testsgcc and clang
✓ JOB 2Sanitizer buildASan + UBSan
✓ JOB 3STM32 firmwareARM GCC, CMake
✓ JOB 4ESP32 firmwarePlatformIO
GitHub repository Hani256/smart-fan with all four CI checks passed
All four checks green on the public repository: ESP32 firmware, STM32 firmware, unit tests, sanitized unit tests.

Hardware

The circuit board is single-sided and 3D printed in PLA, with copper tape laid on raised tracks. It is a fast and cheap alternative to ordering or etching a PCB, and it needs no jumper wires. The second revision fixed a flipped module footprint found on the first. The enclosure is 3D printed too. The repository has a BOM and a board build guide with the pin map, polarity, wiring and first power-up.

3D printed circuit board with copper tape tracks
The circuit board: printed in PLA with raised tracks, copper tape laid on top, through-hole pads soldered.

Debug log

Problems found and fixed on the bench:

  • TACHAn open tachometer path.
  • TACHNoise on the tach input. The speed measurement is now filtered in hardware and in software.
  • UARTGarbage on the UART link, traced to a loose ground.
  • WIFIWi-Fi association failures.
  • MQTTBroker authentication issues.

github.com/Hani256/smart-fan