Below is a breakdown of the components, wiring, and the full ESPHome config.
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 / Amazon) | ~€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 | GPIO16 | LED1 |
LED Strip Right data | GPIO4 | LED4 |
Fan Left ARGB data | GPIO3 | LED2 |
Fan Right ARGB data | GPIO1 | LED3 |
Fan Left PWM | GPIO2 | Q3 |
Fan Right PWM | GPIO15 | Q1 |
Fan Left Tacho | GPIO32 | Q4 |
Fan Right Tacho | GPIO12 | Q2 |
Temperature sensor | GPIO13 | — (onboard) |
GPIO1 and GPIO3 share the UART serial port — the config sets baud_rate: 0 to disable the serial logger, otherwise those channels don't work.
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.
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.
Per-Rack-Unit LED Control
FCOB strips at 768 LEDs/m are controlled in groups of 48 (one addressable IC per group). A 62cm strip gives 10 segments, mapped to 12 rack units: units 1–10 get one segment each, 11 and 12 share the last one.
This is exposed as a Home Assistant service:
- service: set_rack_unit
variables:
unit_number: int
red: int
green: int
blue: int
white: int
In practice this gets called from Home Assistant scripts to highlight specific rack units. A basic status indicator with no extra hardware.
Full ESPHome Config
First flash via USB-C (esphome run), subsequent updates work over ethernet OTA.
# ESPHome Configuration for QuinLED Dig-Quad ABE
# Features: PID Fan Control, ARGB, DS18B20 Temperature, Individual Rack Units for HASS
# Default white lighting with custom color support and temperature override
esphome:
name: kage-controller
friendly_name: "Kage Rack Controller"
esp32:
board: esp32dev
framework:
type: arduino
version: recommended
globals:
- id: custom_red
type: int
restore_value: true
initial_value: '0'
- id: custom_green
type: int
restore_value: true
initial_value: '0'
- id: custom_blue
type: int
restore_value: true
initial_value: '0'
# Default idle color for fans and LED strips
- 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'
.effect_templates:
effects: &all_effects
- addressable_rainbow:
- addressable_color_wipe:
- addressable_scan:
- addressable_twinkle:
- addressable_random_twinkle:
- addressable_fireworks:
- addressable_flicker:
- strobe:
- addressable_lambda:
name: "Breathing"
update_interval: 1ms
lambda: |-
static uint16_t progress = 0;
if (initial_run) {
progress = 0;
it.all() = current_color;
}
float brightness;
if (progress < 400) {
float phase = (progress / 400.0) * (3.14159 / 2.0);
brightness = 0.50 + (sin(phase) * 0.35);
} else if (progress < 500) {
brightness = 0.85;
} else if (progress < 900) {
float phase = ((progress - 500) / 400.0) * (3.14159 / 2.0) + (3.14159 / 2.0);
brightness = 0.50 + (sin(phase) * 0.35);
} else {
brightness = 0.50;
}
for (int i = 0; i < it.size(); i++) {
it[i] = ESPColor(
(uint8_t)(current_color.r * brightness),
(uint8_t)(current_color.g * brightness),
(uint8_t)(current_color.b * brightness),
(uint8_t)(current_color.w * brightness)
);
}
progress++;
if (progress >= 1000) {
progress = 0;
}
api:
services:
- service: set_rack_unit
variables:
unit_number: int
red: int
green: int
blue: int
white: int
then:
- light.turn_on:
id: led_strip_left
- light.turn_on:
id: led_strip_right
- light.addressable_set:
id: led_strip_left
range_from: !lambda 'return (unit_number <= 10) ? (unit_number - 1) : 9;'
range_to: !lambda 'return (unit_number <= 10) ? (unit_number - 1) : 9;'
red: !lambda 'return red / 255.0;'
green: !lambda 'return green / 255.0;'
blue: !lambda 'return blue / 255.0;'
white: !lambda 'return white / 255.0;'
- light.addressable_set:
id: led_strip_right
range_from: !lambda 'return (unit_number <= 10) ? (unit_number - 1) : 9;'
range_to: !lambda 'return (unit_number <= 10) ? (unit_number - 1) : 9;'
red: !lambda 'return red / 255.0;'
green: !lambda 'return green / 255.0;'
blue: !lambda 'return blue / 255.0;'
white: !lambda 'return white / 255.0;'
- service: set_custom_color
variables:
red: int
green: int
blue: int
then:
- globals.set:
id: custom_red
value: !lambda 'return red;'
- globals.set:
id: custom_green
value: !lambda 'return green;'
- globals.set:
id: custom_blue
value: !lambda 'return blue;'
- service: 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;'
- service: set_pid_color_control
variables:
enabled: bool
then:
- globals.set:
id: allow_pid_color_control
value: !lambda 'return enabled;'
ota:
platform: esphome
# Serial logger disabled — GPIO1/GPIO3 conflict with LED channels
logger:
baud_rate: 0
# Ethernet (QuinLED Dig-Quad 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
# DS18B20 temperature sensor (onboard)
one_wire:
- platform: gpio
pin: GPIO13
sensor:
- 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
- platform: pulse_counter
pin:
number: GPIO12
mode: INPUT_PULLUP
name: "Fan Left RPM"
id: fan_left_rpm
update_interval: 1s
filters:
- multiply: 30.0
unit_of_measurement: "RPM"
accuracy_decimals: 0
- platform: pulse_counter
pin:
number: GPIO32
mode: INPUT_PULLUP # Required for be quiet! open-collector tacho
name: "Fan Right RPM"
id: fan_right_rpm
update_interval: 1s
filters:
- multiply: 30.0
unit_of_measurement: "RPM"
accuracy_decimals: 0
output:
- platform: ledc
pin: GPIO15
id: fan_left_pwm_output
frequency: 25000 Hz
min_power: 0.1
- platform: ledc
pin: GPIO2
id: fan_right_pwm_output
frequency: 25000 Hz
min_power: 0.1
- 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: |-
if (!id(allow_pid_color_control)) {
return;
}
static int prev_r = -1, prev_g = -1, prev_b = -1;
float pwm = state;
int r, g, b;
if (pwm <= 0.40) {
r = id(default_red);
g = id(default_green);
b = id(default_blue);
} else if (pwm <= 0.70) {
float ratio = (pwm - 0.40) / 0.30;
r = (int)(ratio * 255);
g = (int)(ratio * 165);
b = (int)((1.0 - ratio) * 255);
} else {
float ratio = (pwm - 0.70) / 0.30;
r = 255;
g = (int)((1.0 - ratio) * 165);
b = 0;
}
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;
}
select:
- platform: template
name: "Lighting Effect"
id: lighting_effect
optimistic: true
restore_value: true
initial_option: "Default"
options:
- "Default"
- "Rainbow"
- "Color Wipe"
- "Scan"
- "Twinkle"
- "Random Twinkle"
- "Fireworks"
- "Flicker"
- "Strobe"
- "Breathing"
set_action:
- if:
condition:
lambda: 'return x == "Default";'
then:
- globals.set:
id: allow_pid_color_control
value: 'true'
- light.turn_on:
id: led_strip_left
effect: "None"
red: 0%
green: 0%
blue: 0%
white: 100%
- light.turn_on:
id: led_strip_right
effect: "None"
red: 0%
green: 0%
blue: 0%
white: 100%
- 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;'
- if:
condition:
lambda: 'return x != "Default";'
then:
- 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;'
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:
- platform: neopixelbus
type: GRB
variant: 800KBPS
pin: GPIO1
num_leds: 20
name: "Fan Left ARGB"
id: fan_left_argb
method:
type: esp32_rmt
channel: 2
effects: *all_effects
- platform: neopixelbus
type: GRB
variant: 800KBPS
pin: GPIO3
num_leds: 20
name: "Fan Right ARGB"
id: fan_right_argb
method:
type: esp32_rmt
channel: 3
effects: *all_effects
# 62cm strip, 768 LEDs/m = 10 addressable ICs
- platform: neopixelbus
type: WRGB
variant: SK6812
pin: GPIO4
num_leds: 10
name: "LED Strip Left"
id: led_strip_left
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
default_transition_length: 0s
method:
type: esp32_rmt
channel: 0
effects: *all_effects
binary_sensor:
- platform: template
name: "System Online"
lambda: |-
return true;
text_sensor:
- platform: template
name: "Network Connection"
lambda: |-
return {"Ethernet Connected"};
update_interval: 60s
Questions or building something similar? Leave a comment below.