A small KiCad board to keep my home server cabinet cool. 4 PWM fans, 4 temperature probes, one ESP32.
Loading README and project media…
A small KiCad board to keep my home server cabinet cool. 4 PWM fans, 4 temperature probes, one ESP32.
My home servers live in a small cabinet, hidden inside a piece of furniture I built around them. It is a credenza with a stone top, a perforated metal grille set into that top so the warm air can escape upward, and cane mesh side panels so fresh air can come in from below.
The room itself has air conditioning, but the cabinet does not. Without something actively pushing the warm air out, it just stacks up under the stone. So I needed a way to manage the airflow.
I started with two breadboard prototypes (see How I got here). The first one was just about understanding the temperatures: where to put the probes, where it actually gets hot. The second one was for the fans, to see how they really move the air through that perforated stone grille.
Once both questions were answered, I designed this PCB. It is a small two layer board, all through-hole so I can solder it by hand on a Sunday afternoon, around €14 in parts. It runs ESPHome, which means it shows up directly in Home Assistant without writing any firmware.
The board is now soldered, mounted at the back of the cabinet, and the firmware in firmware/fancontroller.yaml keeps the two pairs of fans (one per side of the cabinet) under thermostatic control. A short press on the on-board button toggles between auto and manual; the yellow LED reflects that state.
| Block | Component |
|---|---|
| MCU | ESP32 DevKitC V4 (AZDelivery, 100 % Espressif layout) |
| Power | External 12 V / 2 A PSU, then on-board MP1584EN buck to 5 V |
| Fans | 4 Noctua NF-A14 PWM, plugged into 4-pin headers |
| Sensors | 4 DS18B20 waterproof probes on screw terminals |
| Protection | 1.5KE15A TVS on the 12 V input, 4 MF-R050 PTC fuses on the fans |
| Enclosure | No enclosure for v1, just M3 mounting holes on the bare board |
PCB is two-layer, 155 × 85 mm, 1.6 mm thick.
| Connector | Side | PWM (out) | Tach (in) |
|---|---|---|---|
| J3 | Right | GPIO 32 | GPIO 35 |
| J4 | Right | GPIO 33 | GPIO 34 |
| J5 | Left | GPIO 25 | GPIO 39 |
| J6 | Left | GPIO 26 | GPIO 36 |
| Pin | Use |
|---|---|
| 23 | 1-Wire bus (DS18B20 probes) |
| 21 / 22 | I²C SDA / SCL |
| 2 | Status LED (D3 yellow, R6 470 Ω) |
| 0 | Boot / user button (SW1) |
The probe on J8 covers the left fans (J5 + J6), the probe on J9 covers the right ones (J3 + J4). All four DS18B20 sockets (J8–J11) sit on the same 1-Wire bus, so the assignment is by ROM address in the YAML.
The shipped firmware is firmware/fancontroller.yaml. It exposes, in Home Assistant:
Left Fans, Right Fans), each driving that side's two fans in lockstep, plus an individual RPM sensor per physical fan (four in total, so a single seized fan is still visible)Left Temp, Right Temp) with their DS18B20 ROM addresses pinnedAuto Cooling switch and three Number entities (Cooling Setpoint, Fans Off Below, Fans Full Above)Restart button and a User Button binary sensor for the on-board SW1When Auto Cooling is on, each side runs the same linear ramp on its own probe:
T ≤ Off Below → 0 % (silent)
Off Below < T < Setpoint → 0 → 60 % (gentle correction)
Setpoint ≤ T < Full Above → 60 → 100 % (aggressive)
T ≥ Full Above → 100 % (max effort)
The setpoint sits inside the band on purpose, so a stable cabinet parks the fans at around 60 % instead of oscillating between full speed and off. All three thresholds are editable live from HA; defaults are 24 / 27 / 30 °C.
The on-board button toggles Auto Cooling on a short press, and reboots the ESP32 on a 3 s+ hold. The yellow LED follows Auto Cooling: on in auto, off in manual.
The control loop runs entirely on the board, not in Home Assistant. That is deliberate. Each server already manages its own temperature through its own fans; this controller's job is to keep the enclosure itself in range, and it has to keep doing that even when nothing is around to tell it to.
Home Assistant here runs on one of my Kubernetes nodes, which lives in the very rack this board is cooling. Relying on it to cool the rack it runs in is a chicken-and-egg problem: if that node is the one overheating, HA could be the first thing to go down. So the thermostatic logic lives on the ESP32 itself. If HA is unreachable, the board keeps reading its probes and driving the fans on its own, the servers keep handling their own thermal management, and everything stays under control independently.
The result is layered, autonomous cooling instead of a central brain that could take itself down and put the other nodes at thermal risk. Home Assistant is for visibility and live tuning, not a dependency for the rack to stay cool.
The build / flash / dashboard commands are wrapped in a Taskfile.yml. With Task installed (brew install go-task):
task # list available tasks
task config # validate the YAML
task upload # compile and OTA-flash fancontroller.local
task flash-usb PORT=/dev/cu.wchusbserial8330 # first-time USB flash
task logs # stream logs over the network
task dashboard # local ESPHome dashboard on :6052
Before the first flash, copy firmware/secrets.example.yaml to firmware/secrets.yaml and fill it in (Wi-Fi, API key, OTA password). secrets.yaml is git-ignored.
Before committing to a PCB, I validated the design with two breadboard stages.
I started with an ESP32 LoLin board and two DS18B20 probes, flashed with ESPHome and added to Home Assistant. The question for this stage was simple: what does the temperature actually do in the server cabinet? It gave me the baseline graphs that justified active cooling, and it told me where the probes should sit (intake, exhaust, ambient).
The ESPHome YAML I used is kept here for reference: docs/stage1-lolin32-esphome.yaml. Shared 1-Wire bus on GPIO 4, two dallas_temp entries by 64-bit address.
For the second stage, I drove a fan directly from a separate breadboard, to actually measure how the airflow influences the temperature. Fan position, speed versus Δtemperature, how fast the cabinet responds. This is where I confirmed that the Noctua NF-A14 was the right fan, validated the PWM strategy, and figured out the per-fan power budget.
The PCB in this repository is the consolidation of both stages: 4 probes (intake, exhaust, two ambient) and 4 PWM-controlled fans on a single board instead of breadboards.
kicad/esp32-fan-controller.kicad_pro directly, or export gerbers first if your fab needs them.bom/ORDER.md is verified against live Farnell France stock. Paste it into Farnell's Quick Paste / Multi-Add tool. The full BOM with notes is in BOM.md.Flash ESPHome. First time over USB, after that over the air:
cp firmware/secrets.example.yaml firmware/secrets.yaml # fill in Wi-Fi etc.
task flash-usb PORT=/dev/cu.wchusbserial... # initial flash
task upload # subsequent OTAs
Details in Firmware (ESPHome).
.
├── kicad/ # KiCad 10 project (schematic and PCB)
├── firmware/ # ESPHome YAML for the v1 board
│ ├── fancontroller.yaml # Shipped firmware
│ └── secrets.example.yaml # Template for secrets.yaml (git-ignored)
├── bom/ # BOM CSV and ordering sheets
│ └── orders/ # Generated distributor cart CSVs
├── docs/
│ ├── images/ # Photos, schematic renders, screenshots
│ └── stage1-lolin32-esphome.yaml # ESPHome config used on the temperature prototype
├── BOM.md # Human-readable BOM with notes
├── Taskfile.yml # `task config / upload / logs / dashboard`
├── LICENSE # MIT
└── README.md
Next
Later
Done
Shared and discussed here:
MIT, see LICENSE. Hardware design files are released under the same terms, so feel free to fork, modify, fabricate and share.