Turns an ESP32-C3 into a web-based controller for up to eight 4-pin PWM fans. It hosts its own control panel — open the board's address on your network and set each fan's speed from a phone or laptop, with live RPM readback on up to four fans. No cloud service, no app to install, nothing to keep running on a computer.
Each fan gets its own 0–100 % slider, driven at the 25 kHz the 4-pin fan spec calls for. Give them names that mean something — "Radiator", "Intake" — instead of numbers.
Reads each fan's tachometer and pushes the actual measured speed to the browser once a second — immediate confirmation a fan is really spinning, and how fast. Available with up to four fans; see below.
Speeds, names, fan count and the board's own network name are stored on the board. After a power cut it comes back exactly as you left it, without waiting for anything to reconnect.
This page hands the board your network credentials over USB right after flashing. Nothing to recompile, and the same firmware works on every board you flash.
fan-control-xxxxxx.local, which you can rename
under Settings. If your network doesn't resolve
.local names, use the IP address instead.Each fan's +5 V and GND go straight to your 5 V supply. The ESP32 only touches the PWM and tach pins — it carries none of the fans' current.
The ESP32's ground must still be tied to the supply's ground. Without that shared reference the PWM and tach signals have nothing to measure against and won't read correctly.
PWM is driven at 25 kHz per the 4-pin fan spec, as a normal push-pull output — no pull-up needed.
Tach is open-collector on the fan side, so it needs a pull-up; the firmware enables the ESP32's internal one, so no external resistor is required. Fans emit 2 pulses per revolution, which the RPM calculation already accounts for.
The C3 only has eight freely usable GPIOs — the rest are taken by flash, USB, UART and boot straps. That isn't enough to give eight fans both a PWM and a tach line, so the second group changes role with the fan count. Set it in the control panel; nothing needs reflashing.
| Fan | PWM | Tach (1–4 fans) | PWM (5–8 fans) |
|---|---|---|---|
| 1 | GPIO 0 | GPIO 5 | GPIO 0 |
| 2 | GPIO 1 | GPIO 6 | GPIO 1 |
| 3 | GPIO 3 | GPIO 7 | GPIO 3 |
| 4 | GPIO 4 | GPIO 10 | GPIO 4 |
| 5 | — | — | GPIO 5 |
| 6 | — | — | GPIO 6 |
| 7 | — | — | GPIO 7 |
| 8 | — | — | GPIO 10 |
Above four fans the tach lines are dropped for every fan, not just the extra ones — those pins are carrying PWM instead. If live RPM matters more to you than fan count, stay at four or fewer. Wire only as many fans as you have; unused pins are never initialised.
Untested — written from the firmware's API, not yet run against real hardware. Treat it as a starting point rather than a recipe.
The board speaks plain HTTP, so Home Assistant can drive it with its built-in RESTful Switch. No MQTT broker, no add-on, nothing to discover. Once the switch exists you can pass it on to HomeKit via the HomeKit Bridge integration.
Name it under Settings in the control panel, then check Home Assistant can actually reach it — run this from the HA host:
curl http://fan-control-office.local/api/fans
If that returns JSON, use the hostname below. If it doesn't, don't
fight it: .local resolution from inside HA's container
is unreliable. Set a DHCP reservation on your router and use the IP
address instead — it's arguably the more robust option anyway.
configuration.yamlswitch:
- platform: rest
name: Office fans
resource: http://fan-control-office.local/api/fan
state_resource: http://fan-control-office.local/api/fans
body_on: '{"id": 0, "speed": 70}'
body_off: '{"id": 0, "speed": 0}'
is_on_template: "{{ value_json[0].speed > 0 }}"
headers:
Content-Type: application/json
One block per fan, changing both the id
in the bodies and the value_json[N] index to match —
they have to agree, or the switch reports a different fan's state.
speed: 70 is what "on" means; pick what suits. Avoid
going much below 20 %: 4-pin fans generally stall at low duty,
so Home Assistant would report the fan on while it sits still.
After restarting Home Assistant the entity appears under Developer tools → States. To reach the Home app, add the HomeKit Bridge integration, include the entity, and scan the pairing code.
Two things to expect. State is polled roughly every 30 seconds, so a change made in the board's own panel takes a moment to show up in Home Assistant — fine for on/off, but the two can briefly disagree. And "off" is persistent: it writes 0 % to the board's stored settings, so the fan stays off through a power cut rather than being a momentary override.
Note: installing erases any fan names/speeds and Wi-Fi credentials already saved on the board (you'll be asked to confirm).