Hello Fairy BLE string lights in ESPHome
Fair warning: I developed an integration running on an ESP32 with ESPHome, not a Home Assistant custom integration using the BlueTooth proxy. In retrospect, that might have been a better path. Anyhow, the info here should be enough for someone to go that route if they want.
Background
I bought some cheap “Hello Fairy” Bluetooth string lights from Amazon for the holidays. They come with an IR remote and you can download a Bluetooth app for your phone.
Everything’s better with Home Assistant, so challenge accepted.
How-To
This is a bit of a process log that might help others to reverse-engineer their own simple BLE devices for Home Assistant.
I don’t have a Bluetooth sniffer, so the first problem was figuring out the relevant Bluetooth uuids. There’s a nice utilty call nRF Connect you can get on your phone from the App Store that will let you see Bluetooth advertisements; this was sufficient to find the service UUID, a notification UUID, and the command UUID.
You can also find your BLE MAC from there, or from the esp32_ble_tracker in esphome.
With the MAC you can set up a ble_client sensor that acts on specific advertisements. In the Hello Fairy case, we get a nice message from the notify UUID whenever the state of the lights is changed by the IR remote control.
Decoding
After pressing many buttons we start to notice some patterns:
# b/w aa01 08 000000 01 02 63 0064 7d
# r/g aa01 08 000000 01 02 12 0064 2c
# w aa01 08 000000 01 02 15 0064 2f
# red aa01 0b 000000 01 01 0000 03e8 03e8 8e
# off aa01 0b 000000 00 01 0000 03e8 03e8 8d
# gn aa01 0b 000000 01 01 0078 03e8 03e8 06
# b aa01 0b 000000 01 01 00f0 03e8 03e8 7e
# y aa01 0b 000000 01 01 003c 03e8 03e8 ca
The last byte changes for every command - a reasonable hint that it might be a checksum. The on/off state was clearly tied to byte 6. Byte 7 seems to indicate two different command types, 01 for pure colors and 02 for preset patterns. The last two bytes before the checksum seem to remain constant for a given brightness setting, so let’s assume that’s what it means.
For the presets, byte 8 must reflect the preset number.
For the colors, we have 4 bytes, and mostly the second set of 2 remain constant. The first set of 2 always gives a value between 0-359. So it’s not much of a leap to assume HSV with hue 0-3590. Saturation and brightness values range 0-1000, with brightness value changing by 100 for each up/down brightness button press on the remote (which has a range of 10 steps), so let’s assume the 0-1000 means 0-100.0%.
Taking a small leap of faith that the last byte is indeed a checksum, there are 8 data bytes following the ‘08’ commands and 11 bytes following ‘0b’, so byte 3 must be data length. And a simple checksum of those data bytes does indeed match the reported checksum value, so now we’ve got the whole thing.
I couldn’t find a HSV light component in ESPHome, so I have to do some math mapping back and forth to RGB, and then I can set my light entity in Home Assistant to match the last IR command state. (hsv2rgb script in the code below). Yay!
Sending commands
Ok, I can display what the IR remote sends, but how do I send Bluetooth commands from Home Assistant? Googling around, I found some previous attempts at these lights that revealed exactly three commands: on, off, and something unknown.
# on aa020101ae
# off aa020100ad
# bright? color? aa030701001403e8038cbb
Given my previous discovery, byte 3 must mean data length and the last byte again looks like a checksum. ‘aa02’ must mean power. Adding some spaces makes it clearer:
aa03 07 01 0014 03e8 038c bb
Last 3 sets of 2 bytes look like our HSV, and byte 4 must be the color/preset type from before. So now we should be able to reconstruct any command.
There’s only one writable Bluetooth attribute advertised, so that’s the one to try to set. (We can actually use the nRF Connect app to write here to test out our theory before coding up anything in esphome.) But in the end: success!
The Code
There was significant pain trying to deal with HSV<->RGB conversion, mainly because ESPHome seems to just use RGB to indicate hue and tracks brightness in a separate attribute. Anyhow, I kind of glued this together in code in probably not the best manner.
Another tricky bit was to try to get the light entity to both display the color set via the remote, and also to be able to set the lights itself. It kind of goes like: set color with remote updates the light entity in esphome. The light entity realizes it was changed, so it then tries to set the color. So I have to temporarily disable the light entity change effects if we were just updated from the remote.
Third tricky bit was that the light entity actually doesn’t just switch to a new color, it tries to transition over a period of time. This means it sends commands repeatedly, and it was not obvious how to stop that. I ended up using an acknowledge message coming from the lights on the BLE notify attribute to ignore the recurring sends until the lights were ready for the next step in the transition, and then make sure that the final (target color) command was sent. (Edit: setting transition time to 0 makes this easier.)
So the code looks significantly more complicated than I had wished or expected. In retrospect, it would probably have been a better idea to do this with a custom integration in Home Assistant directly and used the ESPHome Bluetooth Proxy to simply pass the messages. This would also let it work across any bluetooth proxy in your home, not just a specific esp. Oh well, next time…
(Change fairy_mac below to yours…)
packages:
esphome.bluetooth-proxy: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main
esp32: !include .common-esp32.yaml
common: !include .common.yaml
substitutions:
name: "esp32-airy"
friendly_name: Airy
svcuuid: 49535343-fe7d-4ae5-8fa9-9fafd205e455
cmduuid: 49535343-8841-43f4-a8d4-ecbe34729bb3
ntfuuid: 49535343-1E4D-4BD9-BA61-23C647249616
fairy_mac: 11:11:00:32:40:FD
globals:
- id: fairy_name
type: std::string
restore_value: no
- id: fhsv
type: int[3]
restore_value: yes
- id: acked
type: bool
restore_value: no
initial_value: 'true'
- id: deferred_send
type: bool
restore_value: no
initial_value: 'false'
- id: notify_only
type: bool
restore_value: no
initial_value: 'false'
- id: delay_counter
type: int
restore_value: no
initial_value: '0'
- id: delay_old
type: int
restore_value: no
initial_value: '0'
esp32_ble_tracker:
on_ble_advertise:
- mac_address:
then:
- lambda: |-
if (x.get_name().find("Hello Fairy-") != 0)
return;
if (id(fairy_name) == x.get_name())
return;
if (x.address_str() == "${fairy_mac}")
id(fairy_name) = x.get_name();
ESP_LOGD("ble_adv", "New BLE device");
ESP_LOGD("ble_adv", " address: %s", x.address_str().c_str());
ESP_LOGD("ble_adv", " name: %s", x.get_name().c_str());
for (auto data : x.get_manufacturer_datas()) {
ESP_LOGD("ble_adv", " manufacturer: %s", data.uuid.to_string().c_str());
}
ble_client:
- mac_address: ${fairy_mac}
id: fairy_cli
auto_connect: true
on_connect:
then:
- lambda: |-
ESP_LOGD("ble_client_lambda", "Connected to BLE device %s", id(fairy_name).c_str());
id(bleconn).publish_state(true);
on_disconnect:
then:
- lambda: |-
ESP_LOGD("ble_client_lambda", "Disconnected from BLE device %s", id(fairy_name).c_str());
id(bleconn).publish_state(false);
sensor:
- platform: ble_client
name: notification
type: characteristic
ble_client_id: fairy_cli
service_uuid: ${svcuuid}
characteristic_uuid: ${cmduuid}
internal: true
update_interval: 60s
lambda: |-
std::string resp = "";
char buffer[3];
for (int i = 0; i < x.size(); i++) {
sprintf(buffer, "%02x", x[i]);
resp += buffer;
}
// Reading cmd always gets this
if (resp != "68656c6c6f00") {
ESP_LOGD("cmd", "read %d bytes %s", x.size(), resp.c_str());
}
return 0.0;
- platform: ble_client
name: notification
type: characteristic
ble_client_id: fairy_cli
service_uuid: ${svcuuid}
characteristic_uuid: ${ntfuuid}
notify: true
internal: true
# polling this uuid doesnt work
update_interval: 24h
lambda: |-
//ESP_LOGD("ntfy", "status notify: %d", x.size());
std::string resp = "";
char buffer[3];
for (int i = 0; i < x.size(); i++) {
sprintf(buffer, "%02x", x[i]);
resp += buffer;
}
if (x.size() == 4) { // ACK aa0200ac, aa0300ad
ESP_LOGD("ntfy", "command %d ACK", x[1]);
id(acked) = true;
return 0.0;
}
if (x.size() < 12) {
ESP_LOGD("ntfy", "unknown response %s", resp.c_str());
return 1.0;
}
// Interesting part after byte 6
id(fe_status).publish_state(resp.substr(12));
// byte 6 is power state
id(fe_power).publish_state(x[6]);
if (x[6] == 0) {
// Make light entity match reported state
id(fe_rgb).turn_off().perform();
return (float)x.size();
}
if (x[7] == 1) { // hsv
//c8-9 hsv color xxxx H 0-359 degrees
//c10-11 hsv sat xxxx 0?-1000
//c12-13 hsv bright xxxx 100-1000 by 100's
id(fhsv)[0] = x[8] * 256 + x[9];
id(fhsv)[1] = (x[10] * 256 + x[11]) / 10;
id(fhsv)[2] = (x[12] * 256 + x[13]) / 10;
ESP_LOGD("ntfy", "mode=color: HSV(%d,%d,%d)", id(fhsv)[0], id(fhsv)[1], id(fhsv)[2]);
// Change the light entity to match reported fairy state;
// don't send new settings to fairy
id(notify_only) = true;
id(hsv2rgb)->execute();
} else if (x[7] == 2) { // Preset
int preset = x[8];
int bright = (x[9] * 256 + x[10]) / 10;
ESP_LOGD("ntfy", "mode=preset: p=%d V=%d", preset, bright);
id(notify_only) = true;
auto call = id(fe_preset).make_call();
call.set_value(preset);
call.perform();
} else {
ESP_LOGD("ntfy", "mode=%d unknown", x[7]);
}
return (float)x.size();
binary_sensor:
- platform: template
name: ble_connected
id: bleconn
device_class: connectivity
entity_category: diagnostic
text_sensor:
- platform: template
name: remote
icon: mdi:remote-tv
id: fe_status
on_value:
then:
- lambda: |-
ESP_LOGD("text", "New notify %s", x.c_str());
script:
- id: print_state
mode: single
parameters:
tag: string
then:
- lambda: |-
ESP_LOGD("light", "tag=%s s=%f, b=%f rgb(%f,%f,%f)", tag.c_str(),
id(fe_rgb).current_values.get_state(),
id(fe_rgb).current_values.get_brightness(),
id(fe_rgb).current_values.get_red(),
id(fe_rgb).current_values.get_green(),
id(fe_rgb).current_values.get_blue());
return;
- id: hsv2rgb
mode: single
then:
- lambda: |-
ESP_LOGD("h2r", "HSV(%d,%d,%d) ->", id(fhsv)[0], id(fhsv)[1], id(fhsv)[2]);
ESPHSVColor hsv(id(fhsv)[0]*255/360, id(fhsv)[1]*255/100, id(fhsv)[2]*255/100);
Color rgb = hsv.to_rgb();
// Transition the light entity in the frontend;
ESP_LOGD("h2r", "->RGB(%f,%f,%f)", rgb.r/255.0,rgb.g/255.0,rgb.b/255.0);
id(print_state)->execute("h2r_pre");
auto call = id(fe_rgb).turn_on();
call.set_transition_length(0); // in ms
call.set_color_mode(ColorMode::RGB);
call.set_brightness(id(fhsv)[2]/100.0);
call.set_rgb(rgb.r/255.0,rgb.g/255.0,rgb.b/255.0);
call.perform();
id(print_state)->execute("h2r_post");
return;
- id: rgb2hsv
mode: single
parameters:
tag: string
then:
- lambda: |-
float r = id(fe_rgb).current_values.get_red();
float g = id(fe_rgb).current_values.get_green();
float b = id(fe_rgb).current_values.get_blue();
ESP_LOGD("r2h", "%s RGB(%f,%f,%f) ->", tag.c_str(), r, g, b);
float v = max(r, max(g, b));
float n = v - min(r, min(g, b));
float h;
if (n == 0) {
h = 0;
} else if (v == r) {
h = (g - b) / n;
} else if (v == g) {
h = 2 + (b - r) / n;
} else {
h = 4 + (r - g) / n;
}
if (h < 0) {
h += 6;
}
id(fhsv)[0] = (int)(60 * h);
id(fhsv)[1] = (int)(v ? (n / v) * 100 : 0);
//id(fhsv)[2] = (int)(v * 100);
// light entity has a separate brightness channel
id(fhsv)[2] = (int)(id(fe_rgb).current_values.get_brightness() * 100);
ESP_LOGD("r2h", "-> HSV(%d,%d,%d)", id(fhsv)[0], id(fhsv)[1], id(fhsv)[2]);
return;
- id: sendhsv
mode: single
then:
- if:
# don't send commands if we're just being notified (i.e. ir remote)
# or if we're still waiting for a response.
condition:
lambda: |-
//ESP_LOGD("send", "n/o=%d, ack=%d", id(notify_only), id(acked));
if (!id(notify_only) && !id(acked)) {
// We're not notifying but can't send yet because we're still waiting for the last ack
ESP_LOGD("send", "skipping send (previous unacked)");
id(deferred_send) = true;
}
return (!id(notify_only) && id(acked));
then:
- ble_client.ble_write:
id: fairy_cli
service_uuid: ${svcuuid}
characteristic_uuid: ${cmduuid}
# lambda returning an std::vector<uint8_t>
value: !lambda |-
// send color on from hsv as (0-360,0-1000,0-1000)
ESP_LOGD("send", "HSV(%d,%d,%d)", id(fhsv)[0], id(fhsv)[1], id(fhsv)[2]);
std::vector<uint8_t> cmd = {0xaa,0x03,0x07,0x01,0xff,0xff,0xff,0xff,0xff,0xff,0xff};
cmd[4] = id(fhsv)[0] / 256;
cmd[5] = id(fhsv)[0] % 256;
cmd[6] = (id(fhsv)[1]*10) / 256;
cmd[7] = (id(fhsv)[1]*10) % 256;
cmd[8] = (id(fhsv)[2]*10) / 256;
cmd[9] = (id(fhsv)[2]*10) % 256;
int csum = 0;
for (int i = 0; i < cmd.size(); i++) {
csum += cmd[i];
}
cmd[10] = csum % 256;
std::string msg = "";
char buffer[4];
for (int i = 0; i < cmd.size(); i++) {
sprintf(buffer, "%02x ", cmd[i]);
msg += buffer;
}
id(acked) = false;
id(deferred_send) = false;
ESP_LOGD("send", "set hsv %s", msg.c_str());
return cmd;
- id: sendpatt
mode: single
parameters:
pattern: int
then:
- if:
# don't send commands if we're just being notified (i.e. ir remote)
# or if we're still waiting for a response.
condition:
lambda: 'return (!id(notify_only));'
then:
- ble_client.ble_write:
id: fairy_cli
service_uuid: ${svcuuid}
characteristic_uuid: ${cmduuid}
value: !lambda |-
ESP_LOGD("send", "patt (%d)", pattern);
std::vector<uint8_t> cmd = {0xaa,0x03,0x04,0x02,0xff,0xff,0xff,0xff};
cmd[4] = pattern % 256;
cmd[5] = (id(fhsv)[2]*10) / 256;
cmd[6] = (id(fhsv)[2]*10) % 256;
int csum = 0;
for (int i = 0; i < cmd.size(); i++) {
csum += cmd[i];
}
cmd[7] = csum % 256;
{ // print
std::string msg = "";
char buffer[4];
for (int i = 0; i < cmd.size(); i++) {
sprintf(buffer, "%02x ", cmd[i]);
msg += buffer;
}
ESP_LOGD("send", "set patt %s", msg.c_str());
}
id(acked) = false;
return cmd;
else:
- globals.set:
id: notify_only
value: 'false'
# countdown to find the end of transition changes
- id: countdown
mode: single
then:
- lambda: |-
id(delay_counter) = 1;
id(delay_old) = 1;
- delay: 200ms
- while:
condition:
lambda: |-
if (id(delay_counter) > id(delay_old)) {
id(delay_old) = id(delay_counter);
return true;
} else {
return false;
}
then:
- delay: 200ms
- lambda: |-
id(delay_counter) = 0;
id(delay_old) = 0;
return;
#- script.execute:
# id: sendhsv
- delay: 100ms
- globals.set:
id: notify_only
value: 'false'
- globals.set:
id: acked
value: 'true'
# Sends on/off commands to Fairy
switch:
- platform: template
name: power
id: fe_power
device_class: switch
restore_mode: RESTORE_DEFAULT_OFF
optimistic: true
internal: true
turn_on_action:
- ble_client.ble_write:
id: fairy_cli
service_uuid: ${svcuuid}
characteristic_uuid: ${cmduuid}
value: [0xaa,0x02,0x01,0x01,0xae]
turn_off_action:
- ble_client.ble_write:
id: fairy_cli
service_uuid: ${svcuuid}
characteristic_uuid: ${cmduuid}
value: [0xaa,0x02,0x01,0x00,0xad]
number:
- platform: template
name: preset
id: fe_preset
icon: mdi:car-shift-pattern
min_value: 1
max_value: 58
step: 1
optimistic: True
restore_value: True
set_action:
- if:
condition:
switch.is_off: fe_power
then:
light.turn_on: fe_rgb
- script.execute:
id: sendpatt
pattern: !lambda 'return x;'
# This exposes a rgb light entity on the HA side
light:
- platform: rgb
name: string
id: fe_rgb
icon: mdi:string-lights
red: red
green: green
blue: blue
default_transition_length: 0ms
restore_mode: RESTORE_DEFAULT_OFF
on_state:
- logger.log: "Light State Change"
- script.execute:
id: print_state
tag: "light state"
- script.execute:
id: countdown
on_turn_on:
- logger.log: "Light On"
- script.execute:
id: print_state
tag: "light pre-on"
- switch.turn_on:
id: fe_power
- script.execute:
id: print_state
tag: "light post-on"
on_turn_off:
- logger.log: "Light Off"
- switch.turn_off:
id: fe_power
effects:
- random:
name: "Slow Random"
transition_length: 0s
update_interval: 5s
- lambda:
name: "Blue & White"
update_interval: 24h
lambda: |-
id(fe_preset).make_call().set_value(41).perform();
- lambda:
name: "Orange Fireworks"
update_interval: 24h
lambda: |-
id(fe_preset).make_call().set_value(58).perform();
# The values here are set from the HA-side color picker,
# starting from the previous settings,
# driven in a sequence over the transition time.
output:
- platform: template
id: red
type: float
write_action:
- lambda: |-
//ESP_LOGD("outp", "adj red %f", state);
id(delay_counter)+=1;
return;
- platform: template
id: green
type: float
write_action:
- lambda: |-
//ESP_LOGD("outp", "adj green %f", state);
id(delay_counter)+=1;
return;
- platform: template
id: blue
type: float
write_action:
- lambda: |-
//ESP_LOGD("outp", "%d adj blue %f", id(delay_counter), state);
id(delay_counter)+=1;
return;
- script.execute:
id: rgb2hsv
tag: 'blue'
- if:
# don't send commands if we're just being notified (i.e. ir remote)
# or if we're still waiting for a response.
condition:
lambda: 'return (!id(notify_only));'
then:
- script.execute:
id: sendhsv
# Reverse-engineering notes follow...
# "transparent uart" tx/rx uuids
# tx: 49535343-1E4D-4BD9-BA61-23C647249616 -- notify
# broadcasts status update when notify is on, some samples"
# b/w aa01 08 000000 01 02 63 0064 7d
# r/g aa01 08 000000 01 02 12 0064 2c
# w aa01 08 000000 01 02 15 0064 2f
# red aa01 0b00 0000 0101 0000 03e8 03e8 8e
# off aa01 0b00 0000 0001 0000 03e8 03e8 8d
# gn aa01 0b00 0000 0101 0078 03e8 03e8 06
# off 00 last bit=previous-1
# b aa01 0b00 0000 0101 00f0 03e8 03e8 7e
# y aa01 0b00 0000 0101 003c 03e8 03e8 ca
# decoding notify status:
#Byte Meaning Value
#0-1 prefix aa01
#2 cmd len 08/0b
#3-5 ? 000000
#6 on/off 01/00
#7 color/preset 01/02
#c8-9 hsv color xxxx 0-359 deg
#c10-11 hsv sat xxxx 0?-1000 (100.0%)
#c12-13 hsv bright xxxx 100-900 by 100's (100.0%)
#c14 csum xx simple sum checksum
#p8 preset# xx 01-ff?
#p9-10 hsv bright
#p11 csum
#
# rx: 49535343-8841-43f4-a8d4-ecbe34729bb3 -- w,r, w w/o resp
# on aa020101ae
# off aa020100ad
# response ACK on tx in both cases: aa0200ac
# bright? color? aa030701001403e8038cbb
# decoding cmd
#Byte Meaning Value
#0 prefix aa
#1 cmd 02=power, 03=color/preset
#2 len 01/07
#p3 off/on 00/01
#p4 csum simple sum checksum
#c3 color/preset 01/02
#c4-5 hsv H 0-359
#c6-7 hsv S 0-1000
#c8-9 hsv L 100-900 by 100's
#c10 csum
#dim gn should be: aa 03 07 01 0078 03e8 03e8 03
# patt pp should be: aa03 ln 02 pp 03e8 cs