Below is a breakdown of the components, wiring, and the full ESPHome config.
Updated September 2026: per-rack-unit lights now use ESPHome partition lights with a master switch, the fan RPM formula is fixed, the wiring table matches the config, and the full config is tested on ESPHome 2026.9.
Some links in this post are affiliate links. I may earn a small commission at no extra cost to you.
Components
The controller is a QuinLED-Dig-Quad. It drives 5 LED channels, has an onboard DS18B20 temperature sensor, ethernet, and GPIO headers for PWM fan control and tachometer feedback. I couldn't find any alternative that combines all of that.
The LED strips are BTF-Lighting FCOB RGBW at 768 LEDs/m. At that density individual diodes aren't visible, just continuous light. I specifically chose the RGBW variant as a dedicated white channel produces cleaner white than mixing RGB at full brightness.
The fans are be quiet! Light Wings 140mm which are genuinely quiet, even at higher speeds. The ARGB connector runs at 5V, separate from the 12V motor circuit, which is why there are two PSUs.
Component | Price (approx.) |
|---|---|
~€50 | |
~€50 | |
BTF-Lighting FCOB RGBW strips, 2× 62cm (BTF-Lighting) | ~€25 |
Mean Well HDR-30-12 (12V DIN rail PSU) | ~€20 |
Mean Well HDR-30-5 (5V DIN rail PSU) | ~€15 |
19” Rack DIN rail | ~€30 |
€10 | |
Total | ~€200 / ~$230 |
I’ve installed everything in a Lanberg 12U rack cabinet and the Xiaomi Mijia Electric Precision Screwdriver certainly was a welcome help.
Why ESPHome Over WLED
WLED is the obvious choice for addressable LEDs, but it doesn't do fan control. ESPHome has native components for PID climate control, PWM output, tachometer input, and LED strips. Fans and lights in one config. Everything shows up in Home Assistant automatically via the ESPHome integration, no manual setup required.
Wiring
The QuinLED takes 12V in and distributes it to the LED strips and fan motors via its output terminals.
The Light Wings ARGB fans run at 5V, which the QuinLED doesn't provide. That's why we have a second HDR-30-5 PSU. Since is only has a single output, a WAGO connector distributes it to both fans.
The signal wires (PWM, tachometer, ARGB data) connect to the QuinLED's GPIO headers.
Function | GPIO | QuinLED Pin |
|---|---|---|
LED Strip Left data | GPIO4 | LED4 |
LED Strip Right data | GPIO16 | LED1 |
Fan Left ARGB data | GPIO1 | LED3 |
Fan Right ARGB data | GPIO3 | LED2 |
Fan Left PWM | GPIO15 | Q1 |
Fan Right PWM | GPIO2 | Q3 |
Fan Left Tacho | GPIO12 | Q2 |
Fan Right Tacho | GPIO32 | Q4 |
Temperature sensor | GPIO13 | — (onboard) |
GPIO1 and GPIO3 are also the ESP32's UART pins. In my setup the fan ARGB channels work fine with the serial logger enabled. If you see flicker or a dead channel, set baud_rate: 0 under logger: to free them up.
ESPHome Configuration
The full config is at the bottom of this page, but a few sections are worth explaining.
PID Fan Control
Most fan controllers are on/off: switch on above a threshold, off below it. ESPHome's pid climate component works differently: fan speed scales continuously with how far the temperature deviates from the target.
climate:
- platform: pid
name: "Kage Cooling PID"
sensor: kage_temp
default_target_temperature: 25.0
cool_output: both_fans_output
control_parameters:
kp: 0.25
ki: 0.002
kd: 0.02
deadband_parameters:
threshold_high: 1.0
threshold_low: -1.0
The deadband_parameters set a ±1°C window where the controller doesn't adjust. Without it, fans hunt continuously around the setpoint. Both fans run from a shared template output at 25 kHz PWM, keeping switching noise out of the audible range. I run mine at a 30°C target; inside the deadband the fans idle at their 10% minimum. The config also has a failsafe: if the temperature sensor stops reporting, both fans go to 100%.
RGB Feedback Based on Cooling Load
Fan ARGB color tracks the PWM duty cycle:
if (pwm <= 0.40) {
// Idle — default color (white)
} else if (pwm <= 0.70) {
// Moderate — blue → orange gradient
} else {
// Aggressive — orange → red
}
At idle (PWM ≤ 40%) fans show the default color, white in this case. Above that, color shifts through blue, orange, and red as load increases. Selecting any effect from Home Assistant disables the PID color override automatically.
Fan RPM
ESPHome's pulse_counter already reports pulses per minute, and these fans give two pulses per rotation, so the right filter is multiply: 0.5. An earlier version of this post used multiply: 30, which reads 60× too high.
- platform: pulse_counter
pin:
number: GPIO12
mode: INPUT_PULLUP
name: "Fan Left RPM"
update_interval: 5s
internal_filter: 13us
filters:
- multiply: 0.5
unit_of_measurement: "RPM"
Per-Rack-Unit LED Control
FCOB strips at 768 LEDs/m are controlled in groups of 48 (one addressable IC per group), so a 62cm strip has 10 segments for 12 rack units. Instead of a custom service, each section of the rack is an ESPHome partition light that combines the same segments on the left and right strip. Each one shows up in Home Assistant as a normal light with its own color and brightness:
- platform: partition
name: "Rack U7 NUCs"
id: rack_u7
segments:
- id: led_strip_left
from: 4
to: 4
- id: led_strip_right
from: 4
to: 4
One segment covers about 6.2cm and a rack unit is 4.45cm, so the sections don't line up perfectly. I grouped the units by device: U3–U6 (DIN rail and a blank) share three segments, the UPS at the bottom gets two. A Rack Master Lights switch turns everything on or off, and the Lighting Effect select runs effects across the full strips. The Breathing effect uses ESPHome's built-in pulse effect, which fades by time instead of by loop speed.
Full ESPHome Config
First flash via USB-C (esphome run), subsequent updates work over ethernet OTA. The API encryption key goes in your secrets.yaml as api_encryption_key. Note that ESPHome has deprecated neopixelbus on ESP32 in favour of esp32_rmt_led_strip, with removal targeted for 2027.1; I'll update this config when I migrate.
# ESPHome configuration for the QuinLED Dig-Quad ABE (Kage rack controller)
# PID fan control, fan ARGB, DS18B20 temperature, per-rack-unit lights, master switch
# Tested on ESPHome 2026.9
esphome:
name: kage-controller
friendly_name: "Kage Rack Controller"
esp32:
board: esp32dev
framework:
type: arduino
version: recommended
globals:
# Default idle color for the fans (set via the set_default_color service)
- id: default_red
type: int
restore_value: true
initial_value: '255'
- id: default_green
type: int
restore_value: true
initial_value: '255'
- id: default_blue
type: int
restore_value: true
initial_value: '255'
# PID color control flag - when false, effects override PID colors
- id: allow_pid_color_control
type: bool
restore_value: true
initial_value: 'true'
# When false, the PID never turns the fan lights on (lights stay off while cooling changes)
- id: master_lights_enabled
type: bool
restore_value: true
initial_value: 'true'
.effect_templates:
effects: &all_effects
- addressable_rainbow:
- addressable_color_wipe:
- addressable_scan:
- addressable_twinkle:
- addressable_random_twinkle:
- addressable_fireworks:
- addressable_flicker:
name: "Flicker" # match the select option (default name is "Addressable Flicker")
- strobe:
# Built-in pulse: time-based, so the speed doesn't depend on loop speed.
# 2s fade + 1s hold at each end = 6s cycle.
- pulse:
name: "Breathing"
transition_length: 2s
update_interval: 3s
min_brightness: 50%
max_brightness: 85%
api:
encryption:
key: !secret api_encryption_key
actions:
- action: set_default_color
variables:
red: int
green: int
blue: int
then:
- globals.set:
id: default_red
value: !lambda 'return red;'
- globals.set:
id: default_green
value: !lambda 'return green;'
- globals.set:
id: default_blue
value: !lambda 'return blue;'
- action: set_pid_color_control
variables:
enabled: bool
then:
- globals.set:
id: allow_pid_color_control
value: !lambda 'return enabled;'
ota:
- platform: esphome
# GPIO1/GPIO3 double as UART pins; the fan ARGB channels work fine with the logger enabled here
logger:
baud_rate: 115200
# Ethernet Configuration (QuinLED-ESP32-ABE variant)
ethernet:
type: LAN8720
mdc_pin: GPIO23
mdio_pin: GPIO18
clk:
pin: GPIO17
mode: CLK_OUT
phy_addr: 0
power_pin: GPIO5
manual_ip:
static_ip: 10.69.3.10
gateway: 10.69.3.1
subnet: 255.255.255.0
dns1: 10.69.3.1
# One-Wire bus for DS18B20 temperature sensor
one_wire:
- platform: gpio
pin: GPIO13
sensor:
# DS18B20 Temperature Sensor - sensor.kage_controller_kage_temperature
- platform: dallas_temp
name: "Kage Temperature"
id: kage_temp
update_interval: 5s
accuracy_decimals: 1
filters:
- sliding_window_moving_average:
window_size: 3
send_every: 1
# pulse_counter reports pulses per minute; the fans give 2 pulses per rotation
- platform: pulse_counter
pin:
number: GPIO12 # Q2
mode: INPUT_PULLUP
name: "Fan Left RPM"
id: fan_left_rpm
update_interval: 5s
internal_filter: 13us
filters:
- multiply: 0.5
unit_of_measurement: "RPM"
accuracy_decimals: 0
- platform: pulse_counter
pin:
number: GPIO32 # Q4
mode: INPUT_PULLUP # Required for Be quiet! open-collector tacho
name: "Fan Right RPM"
id: fan_right_rpm
update_interval: 5s
internal_filter: 13us
filters:
- multiply: 0.5
unit_of_measurement: "RPM"
accuracy_decimals: 0
- platform: uptime
name: "Uptime"
entity_category: diagnostic
# Fan PWM Outputs
output:
- platform: ledc
pin: GPIO15 # Q1
id: fan_left_pwm_output
frequency: 25000 Hz # For quiet operation
min_power: 0.1 # Start boost to prevent fan stall
- platform: ledc
pin: GPIO2 # Q3
id: fan_right_pwm_output
frequency: 25000 Hz
min_power: 0.1
# Template output that controls both fans together
- platform: template
id: both_fans_output
type: float
write_action:
- output.set_level:
id: fan_left_pwm_output
level: !lambda 'return state;'
- output.set_level:
id: fan_right_pwm_output
level: !lambda 'return state;'
- lambda: |-
// Fan ARGB color follows cooling effort (PWM level), unless an effect is active
// or the lights are switched off
if (!id(allow_pid_color_control) || !id(master_lights_enabled)) {
return;
}
// Track previous RGB values to prevent redundant updates
static int prev_r = -1, prev_g = -1, prev_b = -1;
float pwm = state;
int r, g, b;
if (pwm <= 0.40) {
// Idle/Light cooling - use default color from globals
r = id(default_red);
g = id(default_green);
b = id(default_blue);
} else if (pwm <= 0.70) {
// Moderate cooling - Blue → Orange gradient
float ratio = (pwm - 0.40) / 0.30;
r = (int)(ratio * 255);
g = (int)(ratio * 165);
b = (int)((1.0 - ratio) * 255);
} else {
// Aggressive cooling - Orange → Red gradient
float ratio = (pwm - 0.70) / 0.30;
r = 255;
g = (int)((1.0 - ratio) * 165);
b = 0;
}
// Only update fan ARGBs if color changed by more than 2 on any channel
if (abs(r - prev_r) > 2 || abs(g - prev_g) > 2 || abs(b - prev_b) > 2) {
auto call1 = id(fan_left_argb).turn_on();
call1.set_rgb(r/255.0, g/255.0, b/255.0);
call1.perform();
auto call2 = id(fan_right_argb).turn_on();
call2.set_rgb(r/255.0, g/255.0, b/255.0);
call2.perform();
prev_r = r;
prev_g = g;
prev_b = b;
}
# Failsafe: without a temperature reading the PID has no input, so run the fans flat out
interval:
- interval: 30s
then:
- if:
condition:
lambda: 'return isnan(id(kage_temp).state);'
then:
- logger.log:
level: WARN
format: "Temperature sensor unavailable - fans forced to 100%%"
- output.set_level:
id: fan_left_pwm_output
level: 100%
- output.set_level:
id: fan_right_pwm_output
level: 100%
select:
- platform: template
name: "Lighting Effect"
id: lighting_effect
optimistic: true
restore_value: true
initial_option: "Default"
options:
- "Off"
- "Default"
- "Rainbow"
- "Color Wipe"
- "Scan"
- "Twinkle"
- "Random Twinkle"
- "Fireworks"
- "Flicker"
- "Strobe"
- "Breathing"
set_action:
- if:
condition:
lambda: 'return x == "Off";'
then:
- globals.set:
id: master_lights_enabled
value: 'false'
- globals.set:
id: allow_pid_color_control
value: 'true'
- script.execute: all_lights_off
- if:
condition:
lambda: 'return x == "Default";'
then:
- globals.set:
id: master_lights_enabled
value: 'true'
- globals.set:
id: allow_pid_color_control
value: 'true'
# Stop any effect running on the strips, then set every rack unit to pure white
- light.turn_on:
id: led_strip_left
effect: "None"
- light.turn_on:
id: led_strip_right
effect: "None"
- script.execute: rack_units_white
- light.turn_on:
id: fan_left_argb
effect: "None"
red: !lambda 'return id(default_red)/255.0;'
green: !lambda 'return id(default_green)/255.0;'
blue: !lambda 'return id(default_blue)/255.0;'
- light.turn_on:
id: fan_right_argb
effect: "None"
red: !lambda 'return id(default_red)/255.0;'
green: !lambda 'return id(default_green)/255.0;'
blue: !lambda 'return id(default_blue)/255.0;'
# All other effects run on the full strips (partitions have no effects)
- if:
condition:
lambda: 'return x != "Off" && x != "Default";'
then:
- globals.set:
id: master_lights_enabled
value: 'true'
- globals.set:
id: allow_pid_color_control
value: 'false'
- light.turn_on:
id: led_strip_left
effect: !lambda 'return x;'
white: 0%
- light.turn_on:
id: led_strip_right
effect: !lambda 'return x;'
white: 0%
- light.turn_on:
id: fan_left_argb
effect: !lambda 'return x;'
- light.turn_on:
id: fan_right_argb
effect: !lambda 'return x;'
script:
- id: rack_units_white
then:
- light.turn_on: { id: rack_u1, red: 0%, green: 0%, blue: 0%, white: 100% }
- light.turn_on: { id: rack_u2, red: 0%, green: 0%, blue: 0%, white: 100% }
- light.turn_on: { id: rack_u3_u6, red: 0%, green: 0%, blue: 0%, white: 100% }
- light.turn_on: { id: rack_u7, red: 0%, green: 0%, blue: 0%, white: 100% }
- light.turn_on: { id: rack_u8_u9, red: 0%, green: 0%, blue: 0%, white: 100% }
- light.turn_on: { id: rack_u10_u12, red: 0%, green: 0%, blue: 0%, white: 100% }
- id: all_lights_off
then:
- light.turn_off: rack_u1
- light.turn_off: rack_u2
- light.turn_off: rack_u3_u6
- light.turn_off: rack_u7
- light.turn_off: rack_u8_u9
- light.turn_off: rack_u10_u12
- light.turn_off: led_strip_left
- light.turn_off: led_strip_right
- light.turn_off: fan_left_argb
- light.turn_off: fan_right_argb
# PID Climate Controller - Automatic temperature-based fan control
# When mode is OFF: manual fan control works
# When mode is COOL: automatic PID control takes over
# Target temperature is restored from flash after a reboot; I run mine at 30 °C
climate:
- platform: pid
name: "Kage Cooling PID"
id: kage_pid
sensor: kage_temp
default_target_temperature: 25.0
cool_output: both_fans_output
control_parameters:
kp: 0.25
ki: 0.002
kd: 0.02
deadband_parameters:
threshold_high: 1.0
threshold_low: -1.0
visual:
min_temperature: 15.0
max_temperature: 50.0
temperature_step: 0.5
light:
# Fan Left ARGB
- platform: neopixelbus
type: GRB
variant: 800KBPS
pin: GPIO1 # LED3
num_leds: 20
name: "Fan Left ARGB"
id: fan_left_argb
method:
type: esp32_rmt
channel: 2
effects: *all_effects
# Fan Right ARGB
- platform: neopixelbus
type: GRB
variant: 800KBPS
pin: GPIO3 # LED2
num_leds: 20
name: "Fan Right ARGB"
id: fan_right_argb
method:
type: esp32_rmt
channel: 3
effects: *all_effects
# LED strips: 62cm at 768 LEDs/m = 10 addressable ICs (48 physical LEDs each).
# Internal: Home Assistant uses the partition lights below; the select runs effects on these.
- platform: neopixelbus
type: WRGB
variant: SK6812
pin: GPIO4
num_leds: 10
name: "LED Strip Left"
id: led_strip_left
internal: true
default_transition_length: 0s
method:
type: esp32_rmt
channel: 1
effects: *all_effects
- platform: neopixelbus
type: WRGB
variant: SK6812
pin: GPIO16
num_leds: 10
name: "LED Strip Right"
id: led_strip_right
internal: true
default_transition_length: 0s
method:
type: esp32_rmt
channel: 0
effects: *all_effects
# Partition lights — one per rack section, left + right strip combined.
# IC 0 is at the bottom of the rack, IC 9 at the top; U1 is the top unit.
# One IC covers ~6.2cm, a rack unit 4.45cm, so the top sections can't line up exactly.
# Mapping for my 12U cabinet:
# U1 Patch Panel IC 9
# U2 Dream Machine SE IC 8
# U3-U6 DIN + blank IC 5-7
# U7 NUCs IC 4
# U8-U9 Patch + UNAS IC 2-3
# U10-U12 UPS IC 0-1
- platform: partition
name: "Rack U1 Patch Panel"
id: rack_u1
default_transition_length: 0s
segments:
- id: led_strip_left
from: 9
to: 9
- id: led_strip_right
from: 9
to: 9
- platform: partition
name: "Rack U2 Dream Machine SE"
id: rack_u2
default_transition_length: 0s
segments:
- id: led_strip_left
from: 8
to: 8
- id: led_strip_right
from: 8
to: 8
- platform: partition
name: "Rack U3-U6 DIN"
id: rack_u3_u6
default_transition_length: 0s
segments:
- id: led_strip_left
from: 5
to: 7
- id: led_strip_right
from: 5
to: 7
- platform: partition
name: "Rack U7 NUCs"
id: rack_u7
default_transition_length: 0s
segments:
- id: led_strip_left
from: 4
to: 4
- id: led_strip_right
from: 4
to: 4
- platform: partition
name: "Rack U8-U9 Patch UNAS"
id: rack_u8_u9
default_transition_length: 0s
segments:
- id: led_strip_left
from: 2
to: 3
- id: led_strip_right
from: 2
to: 3
- platform: partition
name: "Rack U10-U12 UPS"
id: rack_u10_u12
default_transition_length: 0s
segments:
- id: led_strip_left
from: 0
to: 1
- id: led_strip_right
from: 0
to: 1
switch:
- platform: template
name: "Rack Master Lights"
id: master_light_switch
icon: "mdi:lightbulb-group"
lambda: 'return id(master_lights_enabled);'
turn_on_action:
- globals.set:
id: master_lights_enabled
value: 'true'
- select.set:
id: lighting_effect
option: "Default"
turn_off_action:
- select.set:
id: lighting_effect
option: "Off"
binary_sensor:
- platform: status
name: "Status"
Questions or building something similar? Leave a comment below.