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.

Keep Reading