cd ../blog
#esphome#homeassistant#smarthome#iot#led

Making a Cheap “TV LED Strip” a Proper Smart Server Rack Light (ESPHome + Home Assistant)

Turn a cheap Bluetooth TV LED strip into a real Home Assistant light with ESPHome. Full YAML, the 9-byte BLE packet format, and why LED BLE rejects it.

Jaba Macharashvili 4 min read

In this post
  1. The “smart” part lasted about 30 seconds
  2. The idea: make Home Assistant think it’s a normal light
  3. Installing the strip in the rack
  4. ESPHome: BLE client and virtual RGB light
  5. Translating RGB into BLE packets (bluetooth-virtual-light.h)
  6. What those weird numbers mean
  7. Why I store RGB first, then send on blue updates
  8. Home Assistant: it just looks like a light now
  9. Wrapping up
  10. Automation ideas (feel free to steal)

My server rack is basically a black hole. The cables go in, the light never comes out.

It lives in a dark corner, which is fine until you actually need to do something: trace a cable, read port labels, swap a drive, or just see what’s going on. And honestly, I also wanted it to look fancy.

So I decided to add some RGB lights. I didn’t buy a fancy RGB light though. I bought the cheapest “TV LED strip” I could find, because it was easy to get, and the box said “smart” (Bluetooth).

The cheap “Full Color TV Light” strip in its blister pack, with IR remote and USB Bluetooth controller The end result: the strip lighting the rack, controlled as a normal Home Assistant light

The “smart” part lasted about 30 seconds

Home Assistant was already running, so the plan was simple:

  1. add the strip
  2. automate it
  3. done

I tried the LED BLE integration and immediately hit:

“LED BLE Device not supported”

Home Assistant’s add-integration search showing LED BLE as a match

At first I assumed it was pairing. Then I found this thread where other people hit the same error, and the device just wasn’t supported by the integration.

So the situation was:

  • the device is BLE
  • Home Assistant can see it
  • but the integration doesn’t understand its protocol

Which left me two options. Return it and buy something supported, or keep it and make it behave.

I chose option 2. 😄

The idea: make Home Assistant think it’s a normal light

Instead of forcing Home Assistant to speak my strip’s weird BLE dialect, I hid the BLE behind ESPHome:

  • ESPHome connects to the LED controller over BLE (ble_client)
  • ESPHome exposes a normal RGB light entity to Home Assistant
  • Home Assistant controls it like any other light
  • ESPHome translates RGB changes into BLE writes

So I built a virtual light device that Home Assistant already understands.

Installing the strip in the rack

This was sold as a “TV strip”, but it works great as a rack light. I mounted mine so it lights up the whole front area and the cabling, without shining straight into my eyes.

ESPHome: BLE client and virtual RGB light

My LED controller takes commands as BLE GATT writes. The identifiers I needed were:

  • Service UUID: FFE0
  • Characteristic UUID: FFE1

The ESP32 connects as a BLE client and writes byte arrays to that characteristic.

Here’s the ESPHome config I’m using. You’ll need to set your own MAC via ${bluetooth_virtual_light_mac} and include the header file below.

One note: if you wrote “ESP8266” anywhere in your config (I did at first), it won’t work. BLE needs an ESP32.

esphome:
  includes:
    - bluetooth-virtual-light.h

globals:
  - id: effect_active
    type: bool
    restore_value: no
    initial_value: 'false'

binary_sensor:
  - platform: template
    name: "Led BLE Connected"
    id: led_ble_connected_sensor

ble_client:
  - mac_address: ${bluetooth_virtual_light_mac}
    id: led_strip_ble_client
    on_connect:
      then:
        - lambda: |-
            ESP_LOGD("virtual_ble_light", "Connected to BLE device");
        - binary_sensor.template.publish:
            id: led_ble_connected_sensor
            state: ON
        - switch.turn_off: led_ble_on_off_switch

    on_disconnect:
      then:
        - lambda: |-
            ESP_LOGD("virtual_ble_light", "Disconnected from BLE device");
        - binary_sensor.template.publish:
            id: led_ble_connected_sensor
            state: OFF

light:
  - platform: rgb
    id: rbg_led
    name: BLE LED
    red: red_channel_output
    green: green_channel_output
    blue: blue_channel_output

    on_state:
      - lambda: |-
          ESP_LOGD("virtual_ble_light", "State Send");

    on_turn_on:
      - lambda: |-
          if (!id(effect_active)) {
            id(led_ble_on_off_switch).turn_on();
          }

    on_turn_off:
      - lambda: |-
          if (!id(effect_active)) {
            id(led_ble_on_off_switch).turn_off();
          }

    effects:
      - lambda:
          name: "Breathing"
          update_interval: 16s
          lambda: |-
            id(effect_active) = true;
            static bool state = false;

            auto call = id(rbg_led).turn_on();
            call.set_brightness(state ? 1.0 : 0.2);
            call.perform();

            state = !state;

      - lambda:
          name: "RGB Breathing"
          update_interval: 16s
          lambda: |-
            id(effect_active) = true;
            static bool state = false;
            static int color = 0;

            const float colors[12][3] = {
              {0.0, 1.0, 1.0},  // Cyan
              {1.0, 0.5, 0.0},  // Orange
              {1.0, 1.0, 1.0},  // White
              {0.0, 1.0, 0.0},  // Green
              {1.0, 0.0, 0.0},  // Red
              {0.0, 0.0, 1.0},  // Blue
              {0.5, 0.0, 1.0},  // Purple
              {1.0, 0.0, 0.5}   // Pink
            };

            auto call = id(rbg_led).turn_on();

            if (state) {
              call.set_rgb(
                colors[color][0],
                colors[color][1],
                colors[color][2]
              );
              call.set_brightness(1.0);
              color = (color + 1) % 12;
            } else {
              call.set_brightness(0.2);
            }

            call.perform();
            state = !state;

output:
  - platform: template
    id: red_channel_output
    type: float
    min_power: 0.003
    zero_means_zero: true
    write_action:
      - lambda: |-
          SSDBluetoothVirtualLight::updateRed(state);

  - platform: template
    id: green_channel_output
    type: float
    min_power: 0.003
    zero_means_zero: true
    write_action:
      - lambda: |-
          SSDBluetoothVirtualLight::updateGreen(state);

  - platform: template
    id: blue_channel_output
    type: float
    min_power: 0.003
    zero_means_zero: true
    write_action:
      - lambda: |-
          SSDBluetoothVirtualLight::updateBlue(state);
      - ble_client.ble_write:
          id: led_strip_ble_client
          service_uuid: FFE0
          characteristic_uuid: FFE1
          value: !lambda |-
            return SSDBluetoothVirtualLight::getCreateColorUpdateArray();

switch:
  - platform: ble_client
    id: ble_client_enable
    ble_client_id: led_strip_ble_client
    internal: true

  - platform: template
    id: led_ble_on_off_switch
    name: "Turn On Led"
    internal: true
    restore_mode: ALWAYS_OFF
    turn_on_action:
      - ble_client.ble_write:
          id: led_strip_ble_client
          service_uuid: FFE0
          characteristic_uuid: FFE1
          value: !lambda |-
            return SSDBluetoothVirtualLight::turnOnArray;

    turn_off_action:
      - ble_client.ble_write:
          id: led_strip_ble_client
          service_uuid: FFE0
          characteristic_uuid: FFE1
          value: !lambda |-
            return SSDBluetoothVirtualLight::turnOffArray;
      - lambda: |-
          ESP_LOGD(SSDBluetoothVirtualLight::logTag, "SEND MESSAGE TO TURN OFF");

Translating RGB into BLE packets (bluetooth-virtual-light.h)

ESPHome already knows how to be a light. The missing piece was turning “set RGB to (x,y,z)” into bytes my BLE controller understands.

So I wrote a small helper header that:

  • stores the current red, green, and blue values as floats from 0.0 to 1.0
  • converts them to bytes from 0 to 255
  • builds the payload the LED controller expects
  • hands it back to the ESPHome ble_write
#include <vector>

namespace SSDBluetoothVirtualLight {
  const char* logTag = "ssd_virtual_bluetooth_light";

  // Hard-coded command packets for this BLE controller
  const std::vector<uint8_t> turnOffArray = {126, 255, 4, 0, 255, 255, 255, 255, 239};
  const std::vector<uint8_t> turnOnArray  = {126, 255, 4, 1, 255, 255, 255, 255, 239};
  const std::vector<uint8_t> noColorArray = {126, 255, 5, 3, 0, 0, 0, 255, 239};

  float red = 0;
  float green = 0;
  float blue = 0;

  std::vector<uint8_t> getCreateColorUpdateArray() {
    uint8_t convertedRed   = static_cast<uint8_t>(red   * 255);
    uint8_t convertedGreen = static_cast<uint8_t>(green * 255);
    uint8_t convertedBlue  = static_cast<uint8_t>(blue  * 255);

    // Packet format: [126,255,5,3, R,G,B, 255,239]
    return {126, 255, 5, 3, convertedRed, convertedGreen, convertedBlue, 255, 239};
  }

  void updateRed(float value)   { red = value; }
  void updateGreen(float value) { green = value; }
  void updateBlue(float value)  { blue = value; }
}

What those weird numbers mean

This controller speaks in short 9-byte messages.

  • 126 (0x7E) looks like a start byte
  • 239 (0xEF) looks like an end byte
  • the middle bytes are the command and its parameters

Three useful packets:

ON: {126, 255, 4, 1, ... , 239}
OFF: {126, 255, 4, 0, ... , 239}
Set RGB color:
{126, 255, 5, 3, R, G, B, 255, 239}

ESPHome gives channel values as floats. The header converts them to 0 to 255 and drops them into the packet.

Why I store RGB first, then send on blue updates

Home Assistant updates the RGB channels separately:

  • the red output updates red
  • the green output updates green
  • the blue output updates blue and triggers the BLE write

That way each BLE write carries a complete color instead of a half-updated one.

Home Assistant: it just looks like a light now

Once the ESPHome device is online, Home Assistant discovers it normally. You get:

  • light.ble_led, a normal RGB light
  • binary_sensor.led_ble_connected, useful for monitoring

The ESPHome node discovered in Home Assistant as an esp32dev device with 9 entities The Home Assistant controls card with the BLE LED entity toggled on

And now the automations are boring, in a good way.

Wrapping up

This started as “add cheap TV lights to Home Assistant” and turned into “build a BLE-to-Home-Assistant bridge with ESPHome”.

That’s why I like ESPHome. When something is unsupported, you can usually translate it into a device Home Assistant already knows how to talk to.

My rack is now usable at night, automated, and ready to become a status panel later.

Automation ideas (feel free to steal)

Now that it’s just a normal light entity, it can double as a rack status LED:

  • Quiet night mode: after midnight, motion turns it on at 10% only
  • Rack door sensor: door open turns it on, door closed turns it off
  • Solar panel indicator: blue pulse on battery, red flash when low
  • Internet status: dim purple if the WAN is down
  • Backup indicator: breathing during a backup, green blink when it finishes
  • Temperature warning: shift from green to yellow to red based on CPU or rack temperature

Follow along. New posts show up on the RSS feed. I also share my projects on GitHub, MakerWorld and LinkedIn.

← All posts

Got it working, or stuck? [email protected]