Search code examples
bluetooth-lowenergyesp32bluezbluetooth-gatt

Trying to understand BLE UUIDs and handles (with bluetoothctl example)


As far as I understand, in BLE, UUIDs are universal IDs that serve to uniquely identify a BLE attribute. They can be 2, 4 or 128 bytes. When they are 2 or 4, the remaining bits until 128 are filled with a standard BLE base UUID: 0000-1000-8000-00805f9b34fb. Is this correct?

Having UUIDs, why are handles needed? From what I have seen in my example, they are displayed as part of the attribute 'identifier', e.g.

# bluetoothctl capture

Primary Service (Handle 0x0920)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028
    000000ff-0000-1000-8000-00805f9b34fb
    Unknown

According to the logs I'm printing (which I will show below), the handle is 28. Is this correct? If so, what is the meaning of that 'Handle 0x0920'?

I have implemented a GATT server example in an ESP32 board. For now, I'm printing some logs to verify that the numbers I see (UUIDs, handles...) match those I see when connecting with bluetoothctl. Here are the logs:

I (1009) ESP32-DHT11: Service (inst. ID 0, uuid ff) created
I (1009) ESP32-DHT11: Service (inst. = 0, uuid = ff) started --> handle = 28
I (1009) ESP32-DHT11: Charac. (uuid ff01) added to service ff
I (1019) ESP32-DHT11: Charac. ff01 --> handle = 2a
I (1019) ESP32-DHT11: Descriptor (uuid 2902) added to service ff
I (1029) ESP32-DHT11: Charac. descr. 2902 --> handle = 2b
I (1029) ESP32-DHT11: Adv. parmeters set successfully

And here is the complete bluetoothctl capture:

Primary Service (Handle 0x0009)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0001
    00001801-0000-1000-8000-00805f9b34fb
    Generic Attribute Profile
Characteristic (Handle 0xae24)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0001/char0002
    00002a05-0000-1000-8000-00805f9b34fb
    Service Changed
Descriptor (Handle 0x0015)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0001/char0002/desc0004
    00002902-0000-1000-8000-00805f9b34fb
    Client Characteristic Configuration


Primary Service (Handle 0x0920)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028
    000000ff-0000-1000-8000-00805f9b34fb
    Unknown
Characteristic (Handle 0x6c84)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028/char0029
    0000ff01-0000-1000-8000-00805f9b34fb
    Unknown
Descriptor (Handle 0x0015)
    /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028/char0029/desc002b
    00002902-0000-1000-8000-00805f9b34fb
    Client Characteristic Configuration

I believe the first three attributes belong to the GAP layer and therefore can be ignored. Let's focus in the 3 next. As you can see, the UUIDs in my logs match those in the bluetoothctl capture. If we consider handles the last part of the attributes identifiers, all of them match save one (/org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028/char0029 --> 0x29, which should be 0x2a according to my logs) Why does this happen?

What are exactly the path-like 'identifiers'? (e.g. /org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028/char0029)

Code

The source code for this is quite long, so I attach it here at the end: main.c

#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"

#include "nvs_flash.h"
#include "nvs.h"

#include "esp_system.h"
#include "esp_log.h"

#include "esp_bt.h"
#include "esp_bt_defs.h"
#include "esp_bt_main.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"
#include "esp_gatt_common_api.h"

#include "sdkconfig.h"
#include "app.h"

#define SENSOR_NAME "ESP32-DHT11"
#define TAG SENSOR_NAME

#define ARG_UNUSED(arg) ((void)arg)

static int16_t temp, hum;

static esp_attr_value_t sensor_data = {
    .attr_max_len = (uint16_t)sizeof(temp),
    .attr_len = (uint16_t)sizeof(temp),
    .attr_value = (uint8_t*)(&temp),
};

static void gap_handler(esp_gap_ble_cb_event_t event,
                        esp_ble_gap_cb_param_t* param)
{
    switch (event) {
    case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT:
        esp_ble_gap_start_advertising(&adv_params);
        break;
    default:
        break;
    }
}

// TODO service_def is global and opaque.

static uint32_t short_uuid(esp_bt_uuid_t uuid)
{
    switch (uuid.len) {
        case 2:
            return uuid.uuid.uuid16;
        case 4:
            return uuid.uuid.uuid32;
        default:
            return (uint32_t)-1;
    }
}

/**
 * @brief Handle for the event REG. Triggered when an app. (a.k.a. profile) is
 * registered.
 *
 */
static esp_err_t gatts_register_evt_handler(esp_gatts_cb_event_t event,
                                            esp_gatt_if_t gatts_if,
                                            esp_ble_gatts_cb_param_t* param)
{
    esp_err_t rc = esp_ble_gatts_create_service(gatts_if,
                                                &service_def.service_id,
                                                GATT_HANDLE_COUNT);
    if (rc != ESP_OK) {
        return rc;
    }

    ESP_LOGI(TAG,
             "Service (inst. ID %x, uuid %x) created",
             service_def.service_id.id.inst_id,
             short_uuid(service_def.service_id.id.uuid));

    return rc;
}

/**
 * @brief Handle for the event CREATE (triggered after creating a GATTS
 * service). Stores the handle of the previously created service, starts it and
 * adds a characteristic to it.
 *
 */
static esp_err_t gatts_create_evt_handler(esp_gatts_cb_event_t event,
                                          esp_gatt_if_t gatts_if,
                                          esp_ble_gatts_cb_param_t* param)
{
    ARG_UNUSED(event);
    ARG_UNUSED(gatts_if);

    service_def.service_handle = param->create.service_handle;

    esp_err_t rc = esp_ble_gatts_start_service(service_def.service_handle);
    if (rc != ESP_OK) {
        return rc;
    }

    ESP_LOGI(TAG,
             "Service (inst. = %x, uuid = %x) started --> handle = %x",
             service_def.service_id.id.inst_id,
             short_uuid(service_def.service_id.id.uuid),
             service_def.service_handle);

    rc = esp_ble_gatts_add_char(service_def.service_handle,
                                &service_def.char_uuid,
                                ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
                                ESP_GATT_CHAR_PROP_BIT_READ
                                    | ESP_GATT_CHAR_PROP_BIT_NOTIFY,
                                &sensor_data,
                                NULL);
    if (rc != ESP_OK) {
        return rc;
    }

    ESP_LOGI(TAG,
            "Charac. (uuid %x) added to service %x",
            short_uuid(service_def.char_uuid),
            short_uuid(service_def.service_id.id.uuid));

    return rc;
}

/**
 * @brief Handles the event ADD_CHAR, which is triggered after a characteristic
 * has been added successfully. When a charac. is added, its handle is generated
 * (at runtime). This function gets it and stores it. Also, it adds a charac.
 * descriptor to the service.
 *
 */
static esp_err_t gatts_add_char_evt_handler(esp_gatts_cb_event_t event,
                                            esp_gatt_if_t gatts_if,
                                            esp_ble_gatts_cb_param_t* param)
{
    ARG_UNUSED(event);
    ARG_UNUSED(gatts_if);

    service_def.char_handle = param->add_char.attr_handle;

    ESP_LOGI(TAG,
             "Charac. %x --> handle = %x",
             short_uuid(service_def.char_uuid),
             service_def.char_handle);

    esp_err_t rc = esp_ble_gatts_add_char_descr(service_def.service_handle,
                                                &service_def.descr_uuid,
                                                ESP_GATT_PERM_READ
                                                    | ESP_GATT_PERM_WRITE,
                                                NULL,
                                                NULL);
    if (rc != ESP_OK) {
        return rc;
    }

    ESP_LOGI(TAG,
            "Descriptor (uuid %x) added to service %x",
            short_uuid(service_def.descr_uuid),
            short_uuid(service_def.service_id.id.uuid));

    return rc;
}

/**
 * @brief Handles the event ADD_CHAR, which is triggered after a descriptor
 * has been added successfully. It gets and stores such descriptor handle and
 * overrides the default advertising data.
 *
 */
static esp_err_t gatts_add_char_descr_evt_handler(
    esp_gatts_cb_event_t event,
    esp_gatt_if_t gatts_if,
    esp_ble_gatts_cb_param_t* param)
{
    ARG_UNUSED(event);
    ARG_UNUSED(gatts_if);

    service_def.descr_handle = param->add_char_descr.attr_handle;

    ESP_LOGI(TAG,
             "Charac. descr. %x --> handle = %x",
             short_uuid(service_def.descr_uuid),
             service_def.descr_handle);

    esp_err_t rc = esp_ble_gap_config_adv_data(&adv_data);
    if (rc != ESP_OK) {
        return rc;
    }

    ESP_LOGI(TAG, "Adv. parmeters set successfully");

    return rc;
}

/**
 * @brief Handles the event CONNECT, which is triggered by a host connection.
 * Updates the current connection params. with those from the incoming
 * connection. Also, stores the incoming GATTS interface ID (which signals
 * which service is being read) and connection ID.
 *
 * @todo Does gatts_if actually designate the app./profile rather than the
 * service?
 *
 */
static esp_err_t gatts_connect_evt_handler(esp_gatts_cb_event_t event,
                                           esp_gatt_if_t gatts_if,
                                           esp_ble_gatts_cb_param_t* param)
{
    ARG_UNUSED(event);

    update_conn_params(param->connect.remote_bda);
    service_def.gatts_if = gatts_if;
    service_def.client_write_conn = param->write.conn_id;

    ESP_LOGI(TAG,
             "Host connected, GATTS if. ID = %x, conn. ID = %x",
             service_def.gatts_if,
             service_def.client_write_conn);

    return ESP_OK;
}

/**
 * @brief Handles the event READ, which is triggered by a host read.
 *
 */
static esp_err_t gatts_read_evt_handler(esp_gatts_cb_event_t event,
                                        esp_gatt_if_t gatts_if,
                                        esp_ble_gatts_cb_param_t* param)
{
    ARG_UNUSED(event);

    ESP_LOGI(TAG, "Read on %x detected", param->read.handle);

    esp_gatt_rsp_t rsp = {0};
    rsp.attr_value.handle = param->read.handle;
    rsp.attr_value.len = sensor_data.attr_len;
    memcpy(rsp.attr_value.value, sensor_data.attr_value, sensor_data.attr_len);

    return esp_ble_gatts_send_response(gatts_if,
                                       param->read.conn_id,
                                       param->read.trans_id,
                                       ESP_GATT_OK,
                                       &rsp);
}

/**
 * @brief GATT event handler.
 *
 * @see esp_ble_gatts_app_register
 */
static void gatt_handler(esp_gatts_cb_event_t event,
                         esp_gatt_if_t gatts_if,
                         esp_ble_gatts_cb_param_t* param)
{
    esp_err_t rc = ESP_OK;

    switch (event) {
    case ESP_GATTS_REG_EVT:
        rc = gatts_register_evt_handler(event, gatts_if, param);
        break;

    case ESP_GATTS_CREATE_EVT:
        rc = gatts_create_evt_handler(event, gatts_if, param);
        break;

    case ESP_GATTS_ADD_CHAR_EVT:
        rc = gatts_add_char_evt_handler(event, gatts_if, param);
        break;

    case ESP_GATTS_ADD_CHAR_DESCR_EVT:
        rc = gatts_add_char_descr_evt_handler(event, gatts_if, param);
        break;

    case ESP_GATTS_CONNECT_EVT: {
        rc = gatts_connect_evt_handler(event, gatts_if, param);
        break;
    }

    case ESP_GATTS_READ_EVT:
        rc = gatts_read_evt_handler(event, gatts_if, param);
        break;

    case ESP_GATTS_WRITE_EVT: {
        ESP_LOGI(TAG,
                 "ESP_GATTS_WRITE_EVT %x %x",
                 service_def.descr_handle,
                 param->write.handle);

        if (service_def.descr_handle == param->write.handle) {
            uint16_t descr_value =
                param->write.value[1] << 8 | param->write.value[0];

            if (descr_value != 0x0000) {
                ESP_LOGI(TAG, "notify enable");
                esp_ble_gatts_send_indicate(gatts_if,
                                            param->write.conn_id,
                                            service_def.char_handle,
                                            sensor_data.attr_len,
                                            sensor_data.attr_value,
                                            false);
            } else {
                ESP_LOGI(TAG, "notify disable");
            }
            esp_ble_gatts_send_response(gatts_if,
                                        param->write.conn_id,
                                        param->write.trans_id,
                                        ESP_GATT_OK,
                                        NULL);
        } else {
            esp_ble_gatts_send_response(gatts_if,
                                        param->write.conn_id,
                                        param->write.trans_id,
                                        ESP_GATT_WRITE_NOT_PERMIT,
                                        NULL);
        }
        break;
    }

    case ESP_GATTS_DISCONNECT_EVT:
        service_def.gatts_if = 0;
        esp_ble_gap_start_advertising(&adv_params);
        break;

    default:
        break;
    }

    if (rc != ESP_OK) {
        ESP_LOGE(TAG, "GATTS error %d", rc);
    }
}

static int16_t asd = 0;

static int dht_read_data(int16_t* hum, int16_t* temp)
{
    if (asd == 255) {
        asd = 0;
    } else {
        asd++;
    }
    *hum = asd;
    *temp = 2 * asd;
    return ESP_OK;
}

static void read_temp_task(void* arg)
{
    while (1) {
        vTaskDelay(2000 / portTICK_PERIOD_MS);
        if (dht_read_data(&hum, &temp) == ESP_OK) {
            temp /= 10;
            if (service_def.gatts_if > 0) {
                esp_ble_gatts_send_indicate(service_def.gatts_if,
                                            service_def.client_write_conn,
                                            service_def.char_handle,
                                            sensor_data.attr_len,
                                            sensor_data.attr_value,
                                            false);
            }
        } else {
            ESP_LOGE(TAG, "DHT11 read failed");
        }
    }
}

void app_main(void)
{
    init_service_def();

    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES
        || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ESP_ERROR_CHECK(nvs_flash_init());
    }
    esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);

    esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
    esp_bt_controller_init(&bt_cfg);
    esp_bt_controller_enable(ESP_BT_MODE_BLE);
    esp_bluedroid_init();
    esp_bluedroid_enable();

    esp_ble_gap_register_callback(gap_handler);
    esp_ble_gatts_register_callback(gatt_handler);
    esp_ble_gatts_app_register(0);

    xTaskCreate(read_temp_task,
                "temp",
                configMINIMAL_STACK_SIZE * 3,
                NULL,
                5,
                NULL);
}

app.c


#include "app.h"
#include <string.h>

static uint8_t adv_service_uuid128[32] = {
    0xfb,
    0x34,
    0x9b,
    0x5f,
    0x80,
    0x00,
    0x00,
    0x80,
    0x00,
    0x10,
    0x00,
    0x00,
    0xFF,
    0x00,
    0x00,
    0x00,
};

esp_ble_adv_data_t adv_data = {
    .set_scan_rsp = false,
    .include_name = true,
    .include_txpower = false,
    .min_interval = 0x0006,
    .max_interval = 0x0010,
    .appearance = 0x00,
    .manufacturer_len = 0,
    .p_manufacturer_data = NULL,
    .service_data_len = 0,
    .p_service_data = NULL,
    .service_uuid_len = sizeof(adv_service_uuid128),
    .p_service_uuid = adv_service_uuid128,
    .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT),
};

esp_ble_adv_params_t adv_params = {
    .adv_int_min = 0x20,
    .adv_int_max = 0x40,
    .adv_type = ADV_TYPE_IND,
    .own_addr_type = BLE_ADDR_TYPE_PUBLIC,
    .channel_map = ADV_CHNL_ALL,
    .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};

service_info_t service_def;

void init_service_def(void)
{
    service_def.service_id.is_primary = true;
    service_def.service_id.id.inst_id = 0x00;
    service_def.service_id.id.uuid.len = ESP_UUID_LEN_16;
    service_def.service_id.id.uuid.uuid.uuid16 = GATT_SERVICE_UUID;

    service_def.char_uuid.len = ESP_UUID_LEN_16;
    service_def.char_uuid.uuid.uuid16 = GATT_CHARACTERISTIC_UUID;

    service_def.descr_uuid.len = ESP_UUID_LEN_16;
    service_def.descr_uuid.uuid.uuid16 = ESP_GATT_UUID_CHAR_CLIENT_CONFIG;

    service_def.gatts_if = 0;
}

void update_conn_params(esp_bd_addr_t remote_bda)
{
    esp_ble_conn_update_params_t conn_params = {0};
    memcpy(conn_params.bda, remote_bda, sizeof(esp_bd_addr_t));
    conn_params.latency = 0;
    conn_params.max_int = 0x20;
    conn_params.min_int = 0x10;
    conn_params.timeout = 400;
    esp_ble_gap_update_conn_params(&conn_params);
}

app.h



#ifndef gattex_app_h_
#define gattex_app_h_

#include <stdint.h>
#include <stddef.h>
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"

#define GATT_SERVICE_UUID 0x00FF
#define GATT_CHARACTERISTIC_UUID 0xFF01
#define GATT_HANDLE_COUNT 4

typedef struct
{
    uint16_t service_handle;
    esp_gatt_srvc_id_t service_id;
    uint16_t char_handle;
    esp_bt_uuid_t char_uuid;
    uint16_t descr_handle;
    esp_bt_uuid_t descr_uuid;
    esp_gatt_if_t gatts_if;
    uint16_t client_write_conn;

} service_info_t;

extern esp_ble_adv_data_t adv_data;
extern esp_ble_adv_params_t adv_params;
extern service_info_t service_def;

void init_service_def(void);
void update_conn_params(esp_bd_addr_t remote_bda);

#endif

Solution

  • I have been investigating more and found specific answers to my questions, specially to those related to bluetoothctl in particular:

    When they are 2 or 4, the remaining bits until 128 are filled with a standard BLE base UUID: 0000-1000-8000-00805f9b34fb. Is this correct?

    As explained by other people, the answer is yes.

    Having UUIDs, why are handles needed?

    Other people answered this, but just to add some comments in case they help:

    • UUID defines the type of the attribute, e.g. heart rate. This, most of the times, is registered in the SIG BLE database for UUIDs. That means that any device providing, for example, a heart rate, will use the heart rate's UUID. See BLE assigned numbers
    • Handles are the way a central device has to refer to a peripheral attribute in particular. E.g. a smartphone might be talking with 2 different smart watches, both of them providing the heart rate. Both watches will use the same UUID for this attribute, but the handle will (99% sure) be different. The handles could match, but it would be a mere chance.

    Now, the bluetoothctl specific part:

    [...] (/org/bluez/hci0/dev_0C_B8_15_F6_61_3E/service0028/char0029 --> 0x29, which should be 0x2a according to my logs) Why does this happen?

    It seems that bluetoothctl abstract out several attributes. In general, characteristics are sets of attributes in which one of them will contain the actual value, and many others will be meta-data for the characteristic, e.g. to describe its properties (notify, indicate etc.). This is what is happening here.

    I have discovered this by using a lower level tool: gatttool. With it, I have been able to list all the attributes that describe the characteristic in question:

    gatttool -b 0C:B8:15:F6:61:3E --char-desc 0x002a
    handle = 0x0001, uuid = 00002800-0000-1000-8000-00805f9b34fb
    handle = 0x0002, uuid = 00002803-0000-1000-8000-00805f9b34fb
    handle = 0x0003, uuid = 00002a05-0000-1000-8000-00805f9b34fb
    handle = 0x0004, uuid = 00002902-0000-1000-8000-00805f9b34fb
    handle = 0x0014, uuid = 00002800-0000-1000-8000-00805f9b34fb
    handle = 0x0015, uuid = 00002803-0000-1000-8000-00805f9b34fb
    handle = 0x0016, uuid = 00002a00-0000-1000-8000-00805f9b34fb
    handle = 0x0017, uuid = 00002803-0000-1000-8000-00805f9b34fb
    handle = 0x0018, uuid = 00002a01-0000-1000-8000-00805f9b34fb
    handle = 0x0019, uuid = 00002803-0000-1000-8000-00805f9b34fb
    handle = 0x001a, uuid = 00002aa6-0000-1000-8000-00805f9b34fb
    handle = 0x0028, uuid = 00002800-0000-1000-8000-00805f9b34fb
    handle = 0x0029, uuid = 00002803-0000-1000-8000-00805f9b34fb
    handle = 0x002a, uuid = 0000ff01-0000-1000-8000-00805f9b34fb
    handle = 0x002b, uuid = 00002902-0000-1000-8000-00805f9b34fb
    

    As you can see, there is a big list of them. More interesting than that, both 0x2a and 0x29 handles are there. I have tried reading both:

    [0C:B8:15:F6:61:3E][LE]> char-read-hnd 0x002a
    Characteristic value/descriptor: 32 00 
    

    I can tell the above value is the actual charac. value (I know it because I have written the program that generates it, the code is above). So 0x2a is the handle of the attr. that contains the actual value of the characteristic in question.

    Now, reading 0x29 gives:

    [0C:B8:15:F6:61:3E][LE]> char-read-hnd 0x0029
    Characteristic value/descriptor: 12 2a 00 01 ff
    

    That value can be decoded by following this table, which describes the contents of Characteristic declaration attributes:

    enter image description here

    As you can see, the first byte is 0x12 which represents the flags of permitted operations (in this case: read,notify. See Notes below). The next 2 are the handle of the attribute it is describing: 0x002a (same handle as above. Notice that the endianness needs to be switched). The last 2 bytes are the UUID of the characteristic in question, that is, 0x01ff (which is an UUID invented by me, it's not in the SIG's registered UUIDs provided above). Again, this needs to switch the endianness.

    What are exactly the path-like 'identifiers'?

    As explained by other people, this is not part of the BLE standard, it's a D-Bus thing.

    In summary, the source of all this confusion comes from reading standard BLE documentation/books and the fact that bluetoothctl abstracts out a good part of this without providing documentation about what it is doing (I haven't found a single document explaining the data displayed by bluetoothctl at least)

    Notes

    The characteristic properties is a bitfield whose bits, according to the ESP-IDF implementation, are:

    /* definition of characteristic properties */
    #define    ESP_GATT_CHAR_PROP_BIT_BROADCAST    (1 << 0)       /* 0x01 */    /* relate to BTA_GATT_CHAR_PROP_BIT_BROADCAST in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_READ         (1 << 1)       /* 0x02 */    /* relate to BTA_GATT_CHAR_PROP_BIT_READ in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_WRITE_NR     (1 << 2)       /* 0x04 */    /* relate to BTA_GATT_CHAR_PROP_BIT_WRITE_NR in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_WRITE        (1 << 3)       /* 0x08 */    /* relate to BTA_GATT_CHAR_PROP_BIT_WRITE in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_NOTIFY       (1 << 4)       /* 0x10 */    /* relate to BTA_GATT_CHAR_PROP_BIT_NOTIFY in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_INDICATE     (1 << 5)       /* 0x20 */    /* relate to BTA_GATT_CHAR_PROP_BIT_INDICATE in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_AUTH         (1 << 6)       /* 0x40 */    /* relate to BTA_GATT_CHAR_PROP_BIT_AUTH in bta/bta_gatt_api.h */
    #define    ESP_GATT_CHAR_PROP_BIT_EXT_PROP     (1 << 7)       /* 0x80 */    /* relate to BTA_GATT_CHAR_PROP_BIT_EXT_PROP in bta/bta_gatt_api.h */
    typedef uint8_t esp_gatt_char_prop_t;
    

    As you can see in my code, this characteristic is

    ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_NOTIFY
    

    which is ((1 << 4) | (1 << 1)) == 0x12.