Problem
A 2-bay NAS, a network switch and a GMKtec EVO-X2 sitting around my office with a spaghetti of cables. There has to be a better way.
The real problem was heat. I bought the EVO-X2 for local inference, an AMD Ryzen AI Max+ 395 "Strix Halo" with 128GB of unified LPDDR5X, running llama-swap in front of llama.cpp so I can hot-swap models. Under sustained inference it sat at 98C, right at the throttle point.

The rack: Lab Rax
I printed Lab Rax, a modular 10" rack system by Michael Klements. There is an original version using brass heat-set inserts and a bolted version needing only M6 button heads and nuts - I went bolted, so it comes apart cleanly when I re-shelf something. The full collection has shelves, blanking plates and fan holders, and The DIY Life has a good write-up.
Check your filament before you start. PLA is all I had initially, but PLA creeps and softens well below the temperatures this rack sees with a hot mini PC bolted into it. That sent me on a shopping trip for PETG and a hardened steel nozzle.
Printed on a Bambu P1S. (256mm bed limit)
Custom parts, designed by an LLM
The frame is a download, but my components are not standard rack gear. Three custom parts:
- Top fan panel. The bundled Lab Rax panel is drilled for a 120mm fan; I wanted a 140mm Noctua. Same 228 x 171.5 x 3mm footprint, one round 133mm cutout (140mm frame leaves a ~3.5mm seating lip), four 4.4mm holes on the standard 124.5mm square.
- 1U fan mount. Open-frame bracket holding a 140mm fan flat, so it moves air vertically through the rack. Ears bolt to the front rails, wings carry the fan centred in the case depth (110mm into 220mm).
- Enclosure for the electronics - vented screw-down box with M3 corner bosses.
Yes, you can get an LLM to generate CAD parts.
What made it work:
- Give it real measurements. "A bracket to hold a fan in a rack" gets you nothing. "Rail holes 236.525mm apart, two M6 per side at EIA 1U positions 6.35 and 38.1mm up from the U boundary" gets you a part.
- Use the correct vocabulary. Collar, boss, gusset, fillet, pilot hole, seating lip.
- Always open the result and measure it. I got features in the wrong plane and gussets sitting on top of bolt holes. Open the STEP in Fusion 360 (a clean editable solid, not a mesh) and use the measuring tool on it before committing print time.
That was 80% of the work done. The last 20% was measurement checks and hand edits.

Stripping the EVO-X2
The EVO-X2 came out of its case entirely. Inside a closed rack it does not need a second shell, and that shell was what trapped the heat - the SoC was reheating its own intake air.

The fans
Two Noctua NF-A14x25 G2 PWM chromax.black 140mm fans - one in the top panel, one on the shelf below the EVO-X2, giving a continuous vertical column of air.
These are superb. PWM controllable from 1500rpm all the way down, and at low speed they are completely silent.
The catch: a 4-pin fan on a bare 12V line just runs at whatever its default PWM state gives you. I had no way to control them.
The controller
An ESP32 reads rack air temperature from a DS18B20 and drives both fans on a curve.

Design decisions
- Continuous proportional curve, not on/off. No hysteresis needed, because there is no boundary to oscillate around.
- No power switching. No MOSFET, no relay. The 12V line stays permanently on and only the PWM signal is modulated - at 0% duty the fans stop anyway. One less part, one less failure mode.
- ESP32 over an ATtiny or a thermistor-and-op-amp. Reboots independently of the server, LEDC gives 25kHz PWM for free, and WiFi telemetry stays a software change instead of a hardware change. I had three spare.
- No WiFi in v1. Fewest failure modes. Radio and fan tach pin left free for later.
- One 12V brick. Feeds the fans directly; an LM2596 buck steps it to 5V for the ESP32. One cable into the box.
duty
100% | _________
| ___/
| ___/
~0% |________________/
+-------|--------|--------|--------
30C 40C 50C
Firmware
The core is a pure function: temperature in, 8-bit duty out. No Arduino dependencies, so it compiles both in the sketch and in a plain g++ host build.
// fan_curve.h - pure temperature -> PWM-duty mapping. static const float FAN_TEMP_MIN_C = 30.0f; // at/below this: fans idle (0% PWM) static const float FAN_TEMP_MAX_C = 50.0f; // at/above this: 100% PWM // Map a temperature (deg C) to an 8-bit PWM duty (0..255). // Invalid readings -> 255 (fail-safe full speed). static inline uint8_t tempToDuty(float tempC) { if (isnan(tempC) || tempC < -40.0f || tempC > 125.0f) { return 255; // fail-safe: sensor fault / disconnected (DS18B20 = -127) } if (tempC <= FAN_TEMP_MIN_C) return 0; if (tempC >= FAN_TEMP_MAX_C) return 255; float frac = (tempC - FAN_TEMP_MIN_C) / (FAN_TEMP_MAX_C - FAN_TEMP_MIN_C); long duty = lround(frac * 255.0f); if (duty < 0) duty = 0; if (duty > 255) duty = 255; return (uint8_t)duty; }
Any invalid reading returns 255. setup() also writes 255 before the first read, so a boot loop never leaves the rack unventilated.
Testing
Simulation. Wokwi has an ESP32 and a DS18B20 slider but no 4-pin fan, so two LEDs on GPIO25 stand in as duty meters: dark below 30C, half at 40C, full at 50C, full when you pull the sensor. Great when it worked, but getting a slot to compile on the free tier was a gamble.
Bench. Then the real thing, on a current-limited PSU at ~0.8A, this gave me some peace of mind with over-current-protection.


Holding the sensor between two fingers is enough to activate the >30C duty. Then pull the lead and confirm the fans jump to 100%.
The permanent build

Built in two phases: core board with no capacitors, tested end to end, then the decoupling caps (100uF across 12V for fan inrush, 22uF across 5V at the ESP32). Polarity-sensitive parts go onto a known-good board.
First real soldering project of any size, and a socketed ESP32 plus headers is great practice.

The DS18B20 sits at the warm side of the EVO-X2, measuring the exhaust air.
Results
The Lab Rax works great to combine the loose components in my office.
98C down to around 88C under sustained inference load.
Just as important, it is quiet. Below 30C the fans are stopped, and even once they are running at low duty I cannot hear them at my desk. They only ramp up while a model is generating.
Parts
| Part | Role |
|---|---|
| ESP32-WROOM-32D | LEDC 25kHz PWM + 1-Wire |
| DS18B20 (KY-001 module) | Rack air temperature, onboard 4.7k pull-up |
| 2x Noctua NF-A14x25 G2 PWM chromax.black | 140mm, ~0.14A @ 12V each |
| LM2596 buck converter | 12V to 5V for the ESP32 |
| 12V/2A barrel PSU, 5.5x2.1mm centre-positive | Single supply for fans + buck |
| 5.5x2.1mm female barrel jack to screw terminal | 12V entry point, no soldering |
| 5x7cm double-sided protoboard | The permanent build |
| M6 button head screws + nuts | Lab Rax bolted version |
| PETG filament + hardened steel nozzle | Rack, mounts, enclosure |
Keywords
#Homelab #10InchRack #LabRax #3DPrinting #PETG #ESP32 #DS18B20 #Noctua #PWM #StrixHalo #GMKtec #LocalLLM #llamacpp #CadQuery #Fusion360 #BambuLab