Connecting to Bluetooth Low Energy devices in ESPHome

Bluetooth Low Energy (BLE) devices have two ways to transfer data:

  • Connectionless: This is called "broadcasting" or "advertising": the device advertises its existence, its capabilities and some data, and every BLE device in the neighbourhood can pick this up.

  • With a connection: This requires the client device to connect to the server device and ask for the data.

Until recently, ESPHome only supported reading BLE data without a connection. In my book, I gave some examples about how to use this functionality. I also referred to pull request #1177 by Ben Buxton, which was only merged in ESPHome 1.18.0, after the book was published.

In this blog post, I'll show you how to read the battery level and button presses from a Gigaset keeper Bluetooth tracker, as well as the heart rate of a heart rate sensor, for instance in your fitness tracker.

Setting up a BLE client

If you want your ESPHome device to connect to another device using BLE, you first need to add a ble_client component, which requires an esp32_ble_tracker component. For instance:

esp32_ble_tracker:

ble_client:
  - mac_address: FF:EE:DD:CC:BB:AA
    id: gigaset_keeper

Just specify the BLE MAC address of the device you want to connect to, and give it an ID.

Reading a one-byte characteristic

After connecting to the device, you can create a BLE Client sensor to read one-byte characteristics such as the battery level from your device.

Each BLE server (the device you connect to) has various services and characteristics. The Bluetooth Special Interest Group has published a lot of standard services and their characteristics in their Specifications List. So if you want to read a device's battery level, consult the Battery Service 1.0 document. You see that it defines one characteristic, Battery Level, which "returns the current battery level as a percentage from 0% to 100%; 0% represents a battery that is fully discharged, 100% represents a battery that is fully charged."

Each service and characteristic has a UUID. The standard services and characteristics are defined in the Bluetooth SIG's Assigned Numbers specifications. These are 16-bit numbers. For instance, the Battery service has UUID 0x180f and the Battery Level characteristic has UUID 0x2a19. To read this in ESPHome, you add a sensor of the ble_client platform:

sensor:
  - platform: ble_client
    ble_client_id: gigaset_keeper
    name: "Gigaset keeper battery level"
    service_uuid: '180f'
    characteristic_uuid: '2a19'
    notify: true
    icon: 'mdi:battery'
    accuracy_decimals: 0
    unit_of_measurement: '%'

Make sure to refer in ble_client_id to the ID of the BLE client you defined before.

If you compile this, your ESPHome device connects to your Bluetooth tracker and subscribes to notifications for the battery level. [1]

The Gigaset keeper has another one-byte characteristic that's easy to read, but it's not a standard one: the number of clicks on the button. By exploring the characteristics with nRF Connect, clicking the button and reading their values, you'll find the right one. You can then read this in ESPHome with:

sensor:
  - platform: ble_client
    ble_client_id: gigaset_keeper
    name: "Gigaset keeper button clicks"
    service_uuid: '6d696368-616c-206f-6c65-737a637a796b'
    characteristic_uuid: '66696c69-7020-726f-6d61-6e6f77736b69'
    notify: true
    accuracy_decimals: 0

Every time you click the button on the Gigaset keeper, you'll get an update with the click count. Of course you're probably not interested in the number of clicks, but just in the event that a click happens. You can act on the click with an on_value automation in the sensor, which can for instance toggle a switch in Home Assistant. This way you can use your Bluetooth tracker as a wireless button for anything you want.

Reading arbitrary characteristic values

In ESPHome 1.18 you could only read the first byte of a characteristic, and it would be converted to a float number. In ESPHome 1.19, David Kiliani contributed a nice pull request (#1851) that allows you to add a lambda function to parse the raw data. All received bytes of the characteristic are passed to the lambda as a variable x of type std::vector<uint8_t>. The function has to return a single float value.

You can use this for example to read the value of a heart rate sensor. If you read the specification of the Heart Rate Service, you'll see that the Heart Rate Measurement characteristic is more complex than just a number. There's a byte with some flags, the next one or two bytes is the heart rate, and then come some other bytes. So if you have access to the full raw bytes of the characteristic, you can read the heart rate like this:

sensor:
  - platform: ble_client
    ble_client_id: heart_rate_monitor
    id: heart_rate_measurement
    name: "${node_name} Heart rate measurement"
    service_uuid: '180d'  # Heart Rate Service
    characteristic_uuid: '2a37'  # Heart Rate Measurement
    notify: true
    lambda: |-
      uint16_t heart_rate_measurement = x[1];
      if (x[0] & 1) {
          heart_rate_measurement += (x[2] << 8);
      }
      return (float)heart_rate_measurement;
    icon: 'mdi:heart'
    unit_of_measurement: 'bpm'

Note how the heart rate is in the second byte (x[1]), and if the rightmost bit of x[0] is set, the third byte holds the most significant byte of the 16-bit heart rate value.

However, this way you can still only return numbers. What if you want to read a string? For instance, the device name is accessible in characteristic 0x2a00. Luckily, there's a trick. First define a template text sensor:

text_sensor:
  - platform: template
    name: "${node_name} heart rate sensor name"
    id: heart_rate_sensor_name

And then define a BLE client sensor that accesses the raw bytes of the Device Name characteristic, converts it to a string and publishes it to the template text sensor:

sensor:
  - platform: ble_client
    ble_client_id: heart_rate_monitor
    id: device_name
    service_uuid: '1800'  # Generic Access Profile
    characteristic_uuid: '2a00'  # Device Name
    lambda: |-
      std::string data_string(x.begin(), x.end());
      id(heart_rate_sensor_name).publish_state(data_string.c_str());
      return (float)x.size();

The only weirdness is that you need to return a float in the lambda, because it's a sensor that's expected to return a float value.

After someone on the Home Assistant forum asked how he could read the heart rate of his BLE heart rate monitor, I implemented all this in a project that displays the heart rate and device name of a BLE heart rate sensor on an M5Stack Core or LilyGO TTGO T-Display ESP32, my two go-to ESP32 boards. I published the source code on GitHub as koenvervloesem/ESPHome-Heart-Rate-Display.

This looks like this:

/images/esphome-heart-rate-display-m5stack-core.jpg

This is just one example of the many new possibilities with Bluetooth Low Energy in ESPHome 1.19.