https://github.com/ilia-ae/rpm-fun_esphome
ππ¦π ESPHome fan controller for ESP32-S3-DevKitC-1: 4 PWM channels + 6 RPM inputs, Home Assistant native API.
https://github.com/ilia-ae/rpm-fun_esphome
arduino esp32 esp32-s3 esphome fan-control fan-controller home-assistant pc-fan pulse-counter pwm ws2812
Last synced: 11 days ago
JSON representation
ππ¦π ESPHome fan controller for ESP32-S3-DevKitC-1: 4 PWM channels + 6 RPM inputs, Home Assistant native API.
- Host: GitHub
- URL: https://github.com/ilia-ae/rpm-fun_esphome
- Owner: ilia-ae
- License: mit
- Created: 2025-02-21T12:37:49.000Z (over 1 year ago)
- Default Branch: main
- Last Pushed: 2026-05-18T06:14:11.000Z (about 1 month ago)
- Last Synced: 2026-05-18T07:43:05.520Z (about 1 month ago)
- Topics: arduino, esp32, esp32-s3, esphome, fan-control, fan-controller, home-assistant, pc-fan, pulse-counter, pwm, ws2812
- Homepage:
- Size: 51.8 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# rpm-fun
[](https://esphome.io/)
[](https://www.espressif.com/en/products/socs/esp32-s3)
[](https://github.com/espressif/arduino-esp32)
[](https://www.home-assistant.io/)
[](#license)
[](rpm-fun.yaml)
ESPHome firmware for the **ESP32-S3-DevKitC-1** that turns the board into a fan controller with **Home Assistant** integration:
- **4 fans with full PWM speed control + RPM readback** (variable 0 β 100 %, 25 kHz, Intel 4-Wire Fan Spec)
- **+ 2 fans, RPM readback only** (no PWM channel β these fans run at whatever fixed speed they are wired to: usually 100 % if connected straight to +12 V)
- β **6 tachometer inputs total, 4 PWM outputs total**
- **WS2812 status LED ring** showing Wi-Fi state and fan activity
- **Native API with encryption** for Home Assistant
- **OTA updates** over the network
- **Fallback Wi-Fi access point** + captive portal when the main SSID is unreachable
- persistent speed/brightness state (`restore_value: true`)
- SNTP time sync and a ready-to-uncomment OLED display block
---
## Table of Contents
- [Hardware Requirements](#hardware-requirements)
- [Pinout](#pinout)
- [Why this layout? (hardware constraints)](#why-this-layout-hardware-constraints)
- [Wiring a 4-pin PC fan](#wiring-a-4-pin-pc-fan)
- [Power budget](#power-budget)
- [Home Assistant entities](#home-assistant-entities)
- [LED indication](#led-indication)
- [Installation](#installation)
- [`secrets.yaml`](#secretsyaml)
- [OTA updates](#ota-updates)
- [Implementation notes](#implementation-notes)
- [Troubleshooting](#troubleshooting)
- [License](#license)
---
## Hardware Requirements
| Component | Requirement |
|---|---|
| MCU board | **ESP32-S3-DevKitC-1** (v1.0 / v1.1). Use a variant **without octal-PSRAM** (`N8`, `N16`, `N8R2`), see note below |
| ESPHome | **β₯ 2024.12.4** (see `esphome.min_version`) |
| Framework | Arduino (required by `esp32_rmt_led_strip` and the `pulse_counter` legacy mode used here) |
| Fans | Standard **4-pin PWM PC fans** (Intel spec): GND / +12 V / Tach / PWM. Up to 6 tach, up to 4 PWM-controlled |
| Fan power | External **+12 V** PSU. The ESP32 is powered from USB-C on the DevKitC-1. **A common ground is mandatory** (see [Power budget](#power-budget) below) |
| Pull-ups | External **10 kΞ©** pull-ups from each Tach line to +3.3 V on **fans 1β4** (see note below) |
### Why "no octal-PSRAM"
ESP32-S3 modules with the `R8` suffix (8 MB octal PSRAM) wire **GPIO33 β GPIO37** to the SPI PSRAM data bus (`SPIIO4 β SPIIO7` + `SPIDQS`), per the [ESP32-S3 Hardware Design Guidelines](https://docs.espressif.com/projects/esp-hardware-design-guidelines/en/latest/esp32s3/schematic-checklist.html). Those pins **cannot be used as GPIO** on R8 boards.
This firmware touches **three** pins inside that reserved range:
- **GPIO35** β PWM output for fan 3
- **GPIO36** β tachometer input for fan 6
- **GPIO37** β PWM output for fan 4
So on an `N16R8` / `N32R8` module the firmware will fail to boot or corrupt PSRAM access. Use a non-R8 variant (`N8`, `N16`, `N8R2` quad-PSRAM is fine), or remap fans 3, 4, and 6 to free pins.
---
## Pinout
### Tachometer inputs (RPM)
| Fan | GPIO | Method | Pull-up | HA entity |
|---|---|---|---|---|
| Fan 1 | GPIO5 | PCNT (hardware) | external 10 kΞ© β 3.3 V | `Fan RPM 1 (GPIO5)` |
| Fan 2 | GPIO6 | PCNT | external 10 kΞ© β 3.3 V | `Fan RPM 2 (GPIO6)` |
| Fan 3 | GPIO7 | PCNT | external 10 kΞ© β 3.3 V | `Fan RPM 3 (GPIO7)` |
| Fan 4 | GPIO8 | PCNT | external 10 kΞ© β 3.3 V | `Fan RPM 4 (GPIO8)` |
| Fan 5 | GPIO9 | software, 13 Β΅s glitch filter | **internal** `INPUT_PULLUP` | `Fan RPM 5 (GPIO9)` |
| Fan 6 | GPIO36 | software, 13 Β΅s glitch filter | **internal** `INPUT_PULLUP` | `Fan RPM 6 (GPIO36)` |
The tach output of a PC fan is **open-collector / open-drain**, so it always needs a pull-up. Fans 1β4 declare the pin with the short syntax (`pin: GPIO5`), which does not propagate `INPUT_PULLUP` into the PCNT peripheral β that is why an **external resistor (4.7 β 10 kΞ© to 3.3 V)** is required. Fans 5 and 6 use the long pin syntax and rely on the MCU's internal pull-up.
### PWM outputs
| Fan | GPIO | Frequency | Driver | HA entity |
|---|---|---|---|---|
| Fan 1 | GPIO17 | 25 kHz | LEDC | `Fan Speed 1 (GPIO17)` |
| Fan 2 | GPIO18 | 25 kHz | LEDC | `Fan Speed 2 (GPIO18)` |
| Fan 3 | **GPIO35** | 25 kHz | LEDC | `Fan Speed 3 (GPIO35)` |
| Fan 4 | **GPIO37** | 25 kHz | LEDC | `Fan Speed 4 (GPIO37)` |
`min_power: 0.1` clamps the duty cycle floor to 10 % so the fan always spins reliably and avoids stall noise on cold start.
### Other peripherals
| Function | GPIO | Notes |
|---|---|---|
| WS2812 LED ring (3 LEDs) | GPIO48 | RBG order, `chipset: ws2812`, `rmt_channel: 2`, `internal: true` β not exposed to HA |
| IΒ²C SDA | GPIO41 | 400 kHz |
| IΒ²C SCL | GPIO40 | Reserved for the optional SSD1306 OLED (block is commented out in YAML) |
### Fan capabilities at a glance
This is the most important thing to understand before wiring anything:
| Fan slot | Tach (RPM read) | PWM (speed control) | What you get in Home Assistant |
|---|---|---|---|
| **Fan 1** | GPIO5 | GPIO17 | RPM sensor + speed slider 0 β 100 % |
| **Fan 2** | GPIO6 | GPIO18 | RPM sensor + speed slider 0 β 100 % |
| **Fan 3** | GPIO7 | GPIO35 | RPM sensor + speed slider 0 β 100 % |
| **Fan 4** | GPIO8 | GPIO37 | RPM sensor + speed slider 0 β 100 % |
| **Fan 5** | GPIO9 | β *(no PWM channel)* | RPM sensor only β **fan runs at whatever fixed speed it is wired to** |
| **Fan 6** | GPIO36 | β *(no PWM channel)* | RPM sensor only β **fan runs at whatever fixed speed it is wired to** |
**What this means in practice for fans 5 and 6:**
- If you wire the PWM pin straight to +12 V (or leave it floating on most fans), the fan runs at **100 % always**. There is no slider in HA to slow it down.
- If you wire the PWM pin to GND, most modern fans interpret 0 % duty as "stop" or "minimum" β also a fixed state.
- If you want variable speed on fans 5 and 6 too, you need an **external PWM source** for them (a separate 25 kHz signal from another MCU, a hardware fan controller, or a motherboard header). This firmware will only **read** their RPM in that case.
- A common pattern: use fans 1 β 4 for case/radiator fans you want to control from HA, and assign fans 5 β 6 to a CPU-block pump tach and a PSU fan tach β both of which are typically not meant to be PWM-controlled anyway.
#### Why is it 4 controlled and not 6?
The intuitive answer β "because ESP32-S3 PWM hardware only supports 4 channels" β is **wrong**. The LEDC peripheral on the ESP32-S3 has **8 PWM channels** and all of them can share a single 25 kHz timer, so the chip could comfortably drive **6 or even 8 PWM fans** at the same time ([ESP-IDF LEDC docs](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/ledc.html)).
The real reason this firmware ships with **4 PWM + 6 tach** is a **deliberate design choice** matching the most common PC-cooling layout:
| Role in a typical PC | Wants speed control? | Wants RPM readback? |
|---|---|---|
| Case / radiator fans (3 β 4 of them) | **yes** β slow them down at idle, ramp them up under load | yes |
| CPU water-block pump | usually no β runs at fixed speed for stable flow | **yes** β losing the pump = thermal emergency, must be monitored |
| PSU fan | no β controlled by the PSU itself | **yes** β leading indicator of PSU dying |
| GPU / SSD / chassis-monitor fans | no β controlled by their own card or BIOS | **yes** β useful telemetry |
That is a 4-PWM + 2-tach-only split, plus the 4 controlled fans also report their own RPM β **4 PWM + 6 tach total**. Exactly what this YAML implements.
If your build does not fit this pattern and you genuinely need all 6 fans speed-controlled, the silicon allows it β see the [extension recipe](#extend-to-6-pwm-controlled-fans). For the full silicon-vs-design analysis (LEDC channels, PCNT cap, INPUT_PULLUP quirk) see [Why this layout?](#why-this-layout-hardware-constraints) below.
---
## Why this layout? (hardware constraints)
The asymmetric "**6 tach, only 4 PWM**" layout and the split between PCNT (fans 1β4) and software pulse counting (fans 5β6) is sometimes attributed to "the ESP32 can't handle that many fans." That is **not** the actual story β ESP32-S3 silicon could drive more. The real reasons are a stack of smaller constraints in the ESPHome layer and the standard PC use case.
### What the silicon actually supports
| Peripheral | ESP32-S3 silicon capacity | Implication for fan control |
|---|---|---|
| **LEDC (PWM)** | 8 channels, 4 timers, **low-speed mode only** ([ESP-IDF docs](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/ledc.html)) | All channels can share a single 25 kHz timer β in theory **8 PWM fans** at once |
| **PCNT** | 8 units Γ 2 channels = **16 hardware channels** ([ESP-IDF docs](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/pcnt.html)) | Plenty for 6 fan tachs |
| **RMT** | 4 TX + 4 RX channels | WS2812 strip consumes 1 TX, plenty left |
So the **chip itself is not the bottleneck**. What actually shapes the layout:
### 1. ESPHome wraps PCNT with an 8-channel cap
ESPHome's `pulse_counter` component documents an explicit limit: *"A maximum of 8 channels can be used"* when `use_pcnt: true` ([ESPHome docs](https://esphome.io/components/sensor/pulse_counter.html)). This is a wrapper-level constraint, not a silicon one. For 6 fans it is **not binding**, but it caps any future expansion at 8 channels per device.
### 2. PCNT's 13 Β΅s glitch filter ceiling
From the same ESPHome docs: *"on the ESP32, when using the hardware pulse counter \[`internal_filter`\] can not be higher than 13 Β΅s β¦ for the ESP8266 or with `use_pcnt: false` you can use larger intervals too."* If you want a heavier debounce (for long, noisy fan cables β common in 19" racks or 1U sleds), you must fall back to software counting.
### 3. `INPUT_PULLUP` vs PCNT pin syntax
PC-fan tachs are open-collector and need a pull-up. ESPHome's `pulse_counter` accepts two pin syntaxes:
- **short** β `pin: GPIO5` β used by fans 1β4. Concise but does not carry a `mode:` (no internal pull-up), so each line needs an **external** 4.7 β 10 kΞ© resistor on the board.
- **expanded** β `pin: { number: GPIO9, mode: INPUT_PULLUP }` β used by fans 5β6. Cleanest way to enable the MCU's internal pull-up. In practice this pairs more reliably with `use_pcnt: false`, hence the software-counter fallback on those two channels.
This is the **specific reason** fans 5 and 6 dropped off PCNT: it was not a unit-count exhaustion, it was a deliberate choice to get an internal pull-up on those signals without adding more discrete components to the board.
### 4. LEDC channels vs the 4-fan PWM choice
LEDC has 8 channels on ESP32-S3, all able to share one 25 kHz timer, so **6 PWM outputs are perfectly feasible**. The reason this firmware exposes only 4 is the **typical case-fan use case**: enthusiast and SFF PC builds usually have 3β4 actively controlled case/radiator fans plus 2 "report-only" signals (CPU-block pump tach, PSU fan tach, or a non-PWM Molex fan). Putting the dial on 4 controlled + 6 monitored matches that pattern.
### TL;DR
| You might thinkβ¦ | β¦but the actual reason is |
|---|---|
| "The chip can't drive 6 PWM fans" | LEDC can do 8 at 25 kHz on a shared timer β the 4-PWM cap is a **design choice for typical PC fan setups**, not silicon |
| "PCNT ran out for fans 5β6" | PCNT has 16 channels; the **8-channel ESPHome cap** is the real ceiling, and even that isn't hit at 6 fans |
| "Software counting was a forced fallback" | It was a **pragmatic choice for clean internal pull-ups** on the open-collector tach lines (and a free filter > 13 Β΅s option for noisy installations) |
If you want to extend to 6 PWM outputs, add two more `ledc` outputs on free GPIOs (e.g. 15, 16) and two more `number.template` entities driving them β no chip changes required.
---
## Wiring a 4-pin PC fan
Standard 4-pin PC-fan connector (looking into the fan-side socket):
```
ββββ Pin 1 GND β GND (tied to both board GND and PSU GND)
β
ββββ Pin 2 +12V β +12 V from external PSU
β
ββββ Pin 3 TACH β ESP32 GPIO (RPM input) + pull-up to 3.3 V
β
ββββ Pin 4 PWM β ESP32 GPIO (PWM output, 25 kHz)
```
**Important:**
- A **common ground** between the ESP32 and the 12 V PSU is mandatory, otherwise the tach signal is just noise.
- The ESP32 drives PWM at **3.3 V**. The Intel 4-Wire Fan Spec allows 0 β 5.25 V on the PWM input with a switching threshold around 1.8 V, so 3.3 V is a valid logic-high. The fan internally pulls up the PWM input, no external resistor needed on that line.
- The tach line emits **two pulses per revolution** β that is why the filter is `rpm = x / 2.0`.
---
## Power budget
The ESP32 is fed from USB (β€ 500 mA at 5 V is plenty for the SoC + LED ring). The **fans run off the external 12 V PSU**, which you size yourself.
Rough current draw per fan at 12 V:
| Fan class | Typical running current | Inrush at start |
|---|---|---|
| 120 mm case fan (e.g. Arctic P12) | 80 β 150 mA | up to 2Γ running |
| 140 mm case fan (Noctua NF-A14) | 80 β 200 mA | up to 2Γ running |
| 120 mm high-static-pressure (server / radiator) | 200 β 500 mA | up to 3Γ running |
| 40/60 mm 1U server fan | 200 β 800 mA | up to 3Γ running |
**Budget rule of thumb:** size the 12 V PSU for the **sum of inrush currents** of all fans you might spin up simultaneously. A 6-fan build of regular case fans is comfortably served by a **12 V / 2 A (24 W)** brick. A build with high-RPM radiator or server fans wants **12 V / 4 β 5 A** to survive simultaneous cold starts without a brownout.
Always tie the **PSU ground to the ESP32 ground** β otherwise the tach signal is just floating noise.
---
## Home Assistant entities
Exposed via the native API:
### Sensors
| Entity | Description |
|---|---|
| `sensor.fan_rpm_1` β¦ `fan_rpm_6` | Fan speed in RPM |
| `sensor.wifi_signal` | Wi-Fi strength as a **percentage** (custom mapping `(RSSI + 100) Γ 2`, clamped 0 β 100) |
### Numbers (template, `restore_value: true`)
| Entity | Range | Step | Default | Purpose |
|---|---|---|---|---|
| `number.led_brightness` | 0.0 β 1.0 | 0.01 | 0.2 | LED ring brightness |
| `number.rpm_update_interval` | 1 β 60 s | 1 | 30 | Tach polling interval; restarts `rpm_update_loop` on change |
| `number.fan_speed_1` β¦ `fan_speed_4` | 0 β 100 % | 1 | 50 | PWM duty cycle of the corresponding fan |
### Switch
| Entity | Action |
|---|---|
| `switch.restart_rpm_fun` | Soft reboot of the device |
---
## LED indication
The WS2812 ring of 3 LEDs is declared `internal: true` β Home Assistant does not see it. It is a **local status indicator** only:
| State | Colour / behaviour |
|---|---|
| Wi-Fi connected, fans idle | **Green**, steady |
| Wi-Fi disconnected, fans idle | **Magenta**, blinking 500 ms on / 500 ms off |
| Any fan spinning (RPM > 0) | **Yellow**, blinking 200 ms on / 200 ms off (interleaved with the connection-state colour β see note below) |
| Just (re)connected to Wi-Fi | Brief green + `init_pwm` re-applies saved PWM values |
> **Note on interleaving.** Two `interval:` blocks run in parallel: the **1 s block** sets the colour from Wi-Fi state, the **500 ms block** triggers the yellow fan-activity blink. When fans are spinning and Wi-Fi is connected, the visible result is **mostly yellow flashes with brief green flashes every second**, not pure yellow. This is by design β both signals stay observable at the same time.
Brightness is controlled from HA via `number.led_brightness`.
---
## Installation
### 1. Clone
```bash
git clone https://github.com/ilia-ae/rpm-fun_esphome.git
cd rpm-fun_esphome
```
### 2. Install ESPHome
```bash
# pip
pip install esphome
# or Docker
docker pull ghcr.io/esphome/esphome
```
### 3. Create `secrets.yaml`
Place it next to `rpm-fun.yaml` (template below).
### 4. Compile and flash
First time over USB:
```bash
esphome run rpm-fun.yaml
# or, with Docker:
docker run --rm -v "${PWD}":/config --device=/dev/ttyACM0 -it ghcr.io/esphome/esphome run rpm-fun.yaml
```
Subsequent updates **over the air**:
```bash
esphome upload rpm-fun.yaml --device rpm-fun.local
```
### 5. Add to Home Assistant
The device announces itself via mDNS as `rpm-fun.local`. Confirm the integration in **Settings β Devices & Services β ESPHome** and paste the `api_ha` key from `secrets.yaml`.
---
## `secrets.yaml`
Template (create this file next to `rpm-fun.yaml`):
```yaml
wifi_ssid: "YourWiFiSSID"
wifi_password: "YourWiFiPassword"
# 32-byte base64 key for the native API encryption
# Generate: `openssl rand -base64 32`
api_ha: "PUT_32BYTE_BASE64_KEY_HERE="
# Password for OTA updates
ota_key: "your-strong-ota-password"
# Password for the recovery Wi-Fi access point (fallback hotspot).
# Minimum 8 characters.
ap_password: "your-fallback-ap-password"
```
> A template lives at [`secrets.yaml.example`](secrets.yaml.example) β `cp secrets.yaml.example secrets.yaml` and fill it in. `secrets.yaml` is already in [`.gitignore`](.gitignore); **never commit it**.
### Fallback hotspot
If the main Wi-Fi is unreachable at boot or drops out, the device exposes an open AP for recovery:
| Field | Value |
|---|---|
| SSID | `Rpm-Fun Fallback Hotspot` |
| Password | from `!secret ap_password` in your local `secrets.yaml` β **set your own**, do not leave the example value |
| Captive-portal URL | opens automatically; manual fallback: |
Connect any phone/laptop to the SSID, accept the captive-portal prompt (or open directly), and you get a web form to set new Wi-Fi credentials without re-flashing.
---
## OTA updates
ESPHome native OTA platform. Password is taken from `!secret ota_key`. To push an update:
```bash
esphome upload rpm-fun.yaml --device rpm-fun.local
```
ESPHome will open a TCP session to the device (default port 3232) and stream the new binary.
---
## Implementation notes
### Why fans 5 β 6 are on software counting
See the [Why this layout?](#why-this-layout-hardware-constraints) section above for the full story. Short version: this is **not** a PCNT capacity issue; it is a deliberate move to enable the MCU's internal `INPUT_PULLUP` on those two open-collector tach lines via the expanded pin syntax. If you want to switch them back to PCNT, change `use_pcnt: false` β `true`, drop the `mode: INPUT_PULLUP` from the pin block, add an external 4.7 β 10 kΞ© pull-up to 3.3 V on the board, and remove `internal_filter` (it behaves differently under PCNT).
### Why `init_pwm` fires on `on_connect`
After a reset or brown-out, `restore_value: true` restores the target percentages in `number.fan_speed_*`, but ESPHome **does not** auto-call `set_action` on restore. The `init_pwm` script runs from `wifi.on_connect`, syncing the actual LEDC outputs back to the saved values. Without it, the fans would sit at LEDC's default duty until the user nudges the slider in HA.
### The RPM polling loop
`pulse_counter` with `update_interval: never` means **"never auto-publish"**. Polling is driven explicitly by the `rpm_update_loop` script (`mode: restart`), which runs `component.update` on all six sensors in sequence with a delay of `rpm_update_interval Γ 1000 ms`. Changing the interval in HA restarts the loop via `on_value: script.execute rpm_update_loop`.
`mode: restart` matters: when the interval changes, the old loop is aborted and a new one starts with the new delay β no overlapping concurrent loops.
### Wi-Fi β percent
ESPHome reports Wi-Fi signal in dBm by default. The lambda `quality = (RSSI + 100) Γ 2`, clamped to 0 β 100, gives a dashboard-friendly percentage (β50 dBm β 100 %, β100 dBm β 0 %).
---
## Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| Fan 5 or 6 spins at full speed and ignores HA | **Expected.** Fans 5 and 6 only have tachometer inputs in this firmware β there is no PWM channel for them. They run at whatever speed their power wiring dictates (typically 100 % off straight +12 V). See [Fan capabilities at a glance](#fan-capabilities-at-a-glance) and the [extension recipe](#extend-to-6-pwm-controlled-fans) if you need to add PWM control. |
| Tach reads 0 on fans 1 β 4 | Missing external 4.7 β 10 kΞ© pull-up from Tach to 3.3 V. Add the resistor. |
| RPM jitters Β± 50 at a steady speed | Filter too short or no common ground between PSU and ESP. Tie the grounds. |
| Fan refuses to start, chirps | `min_power` too low, or the fan's PWM input is fussy about 3.3 V. Try raising `min_power` to `0.2`. |
| Firmware hangs at boot | Module is `R8` (octal PSRAM); GPIO33 β 37 are reserved for PSRAM, and this firmware uses GPIO35 (PWM 3), GPIO36 (tach 6) and GPIO37 (PWM 4). Use a non-R8 variant or remap fans 3, 4, and 6. |
| LED ring shows the wrong colour | Incorrect `rgb_order`. Try swapping `RBG` β `GRB` β `RGB` in the `light` block. |
| Device not visible in HA | The `api.encryption.key` in `secrets.yaml` must be valid base64 of 32 bytes (`openssl rand -base64 32`). |
| OTA fails | Check free space (β₯ 1 MB on the ESP32-S3 `ota` partition). |
---
## Extensions
### Extend to 6 PWM-controlled fans
If 4 controlled fans is not enough and you want fans 5 and 6 fully variable too, you need to add two more PWM outputs and two more sliders. ESP32-S3's LEDC has 8 channels total and they can all share the same 25 kHz timer, so the silicon is fine β see [Why this layout?](#why-this-layout-hardware-constraints).
Pick two free GPIOs (good candidates: GPIO15, GPIO16 β both safe, no strapping function, no PSRAM conflict on any S3 variant), then **add** to `rpm-fun.yaml`:
```yaml
output:
# β¦existing 4 entriesβ¦
- platform: ledc
id: pwm_fan_5
pin: GPIO15
frequency: 25000Hz
min_power: 0.1
- platform: ledc
id: pwm_fan_6
pin: GPIO16
frequency: 25000Hz
min_power: 0.1
number:
# β¦existing entriesβ¦
- platform: template
id: fan_speed_5
name: "Fan Speed 5 (GPIO15)"
min_value: 0
max_value: 100
step: 1
initial_value: 50
restore_value: true
optimistic: true
set_action:
- lambda: |-
id(pwm_fan_5).set_level(x / 100.0);
id(fan_speed_5).publish_state(x);
- platform: template
id: fan_speed_6
name: "Fan Speed 6 (GPIO16)"
# β¦same structure, swap 5 β 6, GPIO15 β GPIO16β¦
```
Optionally also extend the `init_pwm` script so the new outputs get restored on (re)connect:
```yaml
script:
- id: init_pwm
then:
- lambda: |-
id(pwm_fan_1).set_level(id(fan_speed_1).state / 100.0);
id(pwm_fan_2).set_level(id(fan_speed_2).state / 100.0);
id(pwm_fan_3).set_level(id(fan_speed_3).state / 100.0);
id(pwm_fan_4).set_level(id(fan_speed_4).state / 100.0);
id(pwm_fan_5).set_level(id(fan_speed_5).state / 100.0);
id(pwm_fan_6).set_level(id(fan_speed_6).state / 100.0);
```
Re-flash, and HA picks up `Fan Speed 5` and `Fan Speed 6` automatically.
### OLED display
The YAML contains a commented SSD1306 72Γ40 OLED block (IΒ²C, address `0x3C`, on SDA = 41 / SCL = 40). Uncomment the `display:` block (the matching `font:` block is already present) to show six fan RPMs, SNTP time, and Wi-Fi quality on the screen.
---
## License
MIT Β© [Ilia Arestov](https://ilia.ae/en/)
See [LICENSE](LICENSE) for the full text.
---
## References
- [ESP-IDF β LED Control (LEDC) on ESP32-S3](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/ledc.html)
- [ESP-IDF β Pulse Counter (PCNT) on ESP32-S3](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/pcnt.html)
- [ESPHome β `pulse_counter` sensor](https://esphome.io/components/sensor/pulse_counter.html)
- [ESPHome β `ledc` output](https://esphome.io/components/output/ledc/)
- [Intel 4-Wire PWM Fan Specification](https://www.formfactors.org/) (background on the 25 kHz target and the open-collector tach signal)