Building a Bluetooth application on nRF Connect SDK - Contrasting to SoftDevice - Part 2 Central role

This is part 2 of the series Building a Bluetooth application on nRF Connect SDK - Contrasting to SoftDevice based Bluetooth applications

You can find the other parts here:

Part 1 - Peripheral Role.

Part 3 - Optimizing the connection.

In Part 1 we covered the generic architecture and the peripheral role. In Part 2 we will cover the Central Role and the GATT client. 

1. Scanning and connecting 

The first step to establishing a connection is to scan and then connect. The scanning module in nRF Connect SDK is documented here.

The following code is extracted from the central_uart example in nRF Connect SDK v1.5.1: 

static int scan_init(void)
{
	int err;
	struct bt_scan_init_param scan_init = {
		.connect_if_match = 1,
        .scan_param =  BT_LE_SCAN_PARAM(BT_LE_SCAN_TYPE_PASSIVE, \
					    BT_LE_SCAN_OPT_FILTER_DUPLICATE, \
					    BT_GAP_SCAN_FAST_INTERVAL, \
					    BT_GAP_SCAN_FAST_WINDOW)
	};

	bt_scan_init(&scan_init);
	bt_scan_cb_register(&scan_cb);

	err = bt_scan_filter_add(BT_SCAN_FILTER_TYPE_UUID, BT_UUID_NUS_SERVICE);
	if (err) {
		LOG_ERR("Scanning filters cannot be set (err %d)", err);
		return err;
	}

	err = bt_scan_filter_enable(BT_SCAN_UUID_FILTER, false);
	if (err) {
		LOG_ERR("Filters cannot be turned on (err %d)", err);
		return err;
	}

	LOG_INF("Scan module initialized");
	return err;
}

static void scan_filter_match(struct bt_scan_device_info *device_info,
			      struct bt_scan_filter_match *filter_match,
			      bool connectable)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(device_info->recv_info->addr, addr, sizeof(addr));

	LOG_INF("Filters matched. Address: %s connectable: %d",
		log_strdup(addr), connectable);
}
static void scan_connecting(struct bt_scan_device_info *device_info,
			    struct bt_conn *conn)
{
	default_conn = bt_conn_ref(conn);
}
BT_SCAN_CB_INIT(scan_cb, scan_filter_match, NULL,
		scan_connecting_error, scan_connecting);

The scan parameters is initialized in scan_init() function. If the conn_param (or scan_param) is not defined or NULL, the default connection parameter will be used (BT_LE_CONN_PARAM_DEFAULT). 
If connect_if_match is set, the central will automatically connect to the devices when it matches the filters. 

If you don't plan to let the stack connect to the matched device, you can handle the matched advertising packet inside scan_filter_match() function which is registered with BT_SCAN_CB_INIT(). There you can call bt_conn_le_create() to manually connect to the remote peer. 

There are multiple filters you can choose from. In this case, we are using BT_SCAN_FILTER_TYPE_UUID and looking for BT_UUID_NUS_SERVICE.
If you are scanning without filter, then you need to define a "scan_filter_no_match" callback and you will receive a call back for every single advertising packet received. 

This scan module is quite similar to the nrf_ble_scan module we have in nRF5 SDK. But there is a difference here in the whitelist and blocklist. 

In addition to the traditional whitelist (enable using CONFIG_BT_WHITELIST, add an address using bt_le_whitelist_add()) which is filtered by the radio hardware, you can also add addresses to the filter list with BT_SCAN_FILTER_TYPE_ADDR but this will be handled by the software (slower, more power consumption). And there is something new here is the blocklist filter, you can add multiple addresses to the blocklist using bt_scan_blocklist_device_add(). The devices in the block list will not generate any event, including the scan_filter_no_match callback.

To start scanning, you call bt_scan_start() with the scan type (active - with scan request or passive - no scan request). The connection will be established automatically if connect_if_match  is set, or by calling  bt_conn_le_create() as mentioned above. 

2. Link Encryption and Security 

In this section, we break down how security is enforced in a central application. The following code is extracted from central_uart:

static void security_changed(struct bt_conn *conn, bt_security_t level,
			     enum bt_security_err err)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	if (!err) {
		LOG_INF("Security changed: %s level %u", log_strdup(addr),
			level);
	} else {
		LOG_WRN("Security failed: %s level %u err %d", log_strdup(addr),
			level, err);
	}

	gatt_discover(conn);
}

static struct bt_conn_cb conn_callbacks = {
	.connected = connected,
	.disconnected = disconnected,
	.security_changed = security_changed
};

static void auth_cancel(struct bt_conn *conn)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	LOG_INF("Pairing cancelled: %s", log_strdup(addr));
}


static void pairing_confirm(struct bt_conn *conn)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	bt_conn_auth_pairing_confirm(conn);

	LOG_INF("Pairing confirmed: %s", log_strdup(addr));
}


static void pairing_complete(struct bt_conn *conn, bool bonded)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	LOG_INF("Pairing completed: %s, bonded: %d", log_strdup(addr),
		bonded);
}


static void pairing_failed(struct bt_conn *conn, enum bt_security_err reason)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	LOG_WRN("Pairing failed conn: %s, reason %d", log_strdup(addr),
		reason);
}

static struct bt_conn_auth_cb conn_auth_callbacks = {
	.cancel = auth_cancel,
	.pairing_confirm = pairing_confirm,
	.pairing_complete = pairing_complete,
	.pairing_failed = pairing_failed
};

The security_changed() callback is declared in the connection callbacks list conn_callbacks and the pairing callbacks is declared in the authentication callback conn_auth_callbacks. Note that security_changed() and pairing_complete() are not exactly the same. The pairing_* callback only occurs when the 2 peers pair/bond for the first time. It's when they exchange security information and come up with a key to use. On the next connections where they simply re-use the key they agree on previously,  you will only receive security_changed() callback ( similar to BLE_GAP_EVT_AUTH_STATUS event in SoftDevice). 

In conn_auth_callbacks, if you define the callback for example passkey_display() this would automatically configure your pairing capability of displaying. central_hids is a good example of how to handle passkey_display.

static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey)
{
	char addr[BT_ADDR_LE_STR_LEN];

	bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));

	printk("Passkey for %s: %06u\n", addr, passkey);
}

If you don't define pairing_confirm callback, the pairing request from the peer will be rejected automatically. 

To actually trigger the pairing request or re-encrypt the link you need to call bt_conn_set_security() with the desired security level. If the peer is already paired previously with the correct level, the link will be re-encrypted if not, a new pairing sequence will occur with the new security level. Usually, this function is called inside the connected() callback. 
Here is the definition of different security levels in Bluetooth Spec v5.1 for your reference (level 0 means no encryption): 

To delete bond information of a peer device, you can call bt_unpair(), if you want to delete bond information of all node, you call this function with NULL of BT_ADDR_LE_ANY in the peer's address. 

Compare to how the security is handled in nRF5 SDK, the connection management module in nRF Connect SDK can handle most of the tasks that "peer_manager" module has. I couldn't find the configuration to allow re-pairing when one peer removes the bond and want to establish a new bond ("allow_repair" in nRF5 SDK). You would need to handle the pairing_failed() callback, remove the bond on the peer, and try again. 

Note: You can use BT_SMP_ALLOW_UNAUTH_OVERWRITE=y to allow overwriting of unauthenticated bonds (just work bonding)

3. Service Discovery 

A central device usually equipped with a GATT client, performing a service discovery is an important role of it. 

To trigger service discovery for a particular service UUID, you would need to call bt_gatt_dm_start() please note in the central_uart example we call gatt_discover() at two locations, the normal one is after the link is encrypted (inside security_changed() ) and the second one is if it's failed to set security for some reason. When calling the function, you provide the UUID and callbacks. 

struct bt_gatt_dm_cb discovery_cb = {
	.completed         = discovery_complete,
	.service_not_found = discovery_service_not_found,
	.error_found       = discovery_error,
};

static void gatt_discover(struct bt_conn *conn)
{
	int err;

	if (conn != default_conn) {
		return;
	}

	err = bt_gatt_dm_start(conn,
			       BT_UUID_NUS_SERVICE,
			       &discovery_cb,
			       &nus_client);
	if (err) {
		LOG_ERR("could not start the discovery procedure, error "
			"code: %d", err);
	}
}

After the service discovery is performed and if the service is found you will receive the callback to completed's callback. Inside that you can:

- Print the service, characteristic and handle bt_gatt_dm_data_print() (requires CONFIG_BT_GATT_DM_DATA_PRINT)

- Assign the handles into the GATT client you have (bt_nus_handles_assign() )

- Enable notification (bt_nus_subscribe_receive () )

- And release the data by bt_gatt_dm_data_release(). Note that you always have to release the data before you do a new service discovery for other service(s). 

static void discovery_complete(struct bt_gatt_dm *dm,
			       void *context)
{
	struct bt_nus_client *nus = context;
	LOG_INF("Service discovery completed");

	bt_gatAsst_dm_data_print(dm);

	bt_nus_handles_assign(dm, nus);
	bt_nus_subscribe_receive(nus);

	bt_gatt_dm_data_release(dm);
}

Next, we will look into how we initialize and handle the GATT client on the central. 

4. GATT Client 

The actual initialization of the GATT client actually is when you assign the peer's GATT handles value to the client's handles. Let's have a look at the NUS client bt_nus_handles_assign(): 

int bt_nus_handles_assign(struct bt_gatt_dm *dm,
			  struct bt_nus_client *nus_c)
{
	const struct bt_gatt_dm_attr *gatt_service_attr =
			bt_gatt_dm_service_get(dm);
	const struct bt_gatt_service_val *gatt_service =
			bt_gatt_dm_attr_service_val(gatt_service_attr);
	const struct bt_gatt_dm_attr *gatt_chrc;
	const struct bt_gatt_dm_attr *gatt_desc;

	if (bt_uuid_cmp(gatt_service->uuid, BT_UUID_NUS_SERVICE)) {
		return -ENOTSUP;
	}
	LOG_DBG("Getting handles from NUS service.");
	memset(&nus_c->handles, 0xFF, sizeof(nus_c->handles));

	/* NUS TX Characteristic */
	gatt_chrc = bt_gatt_dm_char_by_uuid(dm, BT_UUID_NUS_TX);
	if (!gatt_chrc) {
		LOG_ERR("Missing NUS TX characteristic.");
		return -EINVAL;
	}
	/* NUS TX */
	gatt_desc = bt_gatt_dm_desc_by_uuid(dm, gatt_chrc, BT_UUID_NUS_TX);
	if (!gatt_desc) {
		LOG_ERR("Missing NUS TX value descriptor in characteristic.");
		return -EINVAL;
	}
	LOG_DBG("Found handle for NUS TX characteristic.");
	nus_c->handles.tx = gatt_desc->handle;
	/* NUS TX CCC */
	gatt_desc = bt_gatt_dm_desc_by_uuid(dm, gatt_chrc, BT_UUID_GATT_CCC);
	if (!gatt_desc) {
		LOG_ERR("Missing NUS TX CCC in characteristic.");
		return -EINVAL;
	}
	LOG_DBG("Found handle for CCC of NUS TX characteristic.");
	nus_c->handles.tx_ccc = gatt_desc->handle;

	/* NUS RX Characteristic */
	gatt_chrc = bt_gatt_dm_char_by_uuid(dm, BT_UUID_NUS_RX);
	if (!gatt_chrc) {
		LOG_ERR("Missing NUS RX characteristic.");
		return -EINVAL;
	}
	/* NUS RX */
	gatt_desc = bt_gatt_dm_desc_by_uuid(dm, gatt_chrc, BT_UUID_NUS_RX);
	if (!gatt_desc) {
		LOG_ERR("Missing NUS RX value descriptor in characteristic.");
		return -EINVAL;
	}
	LOG_DBG("Found handle for NUS RX characteristic.");
	nus_c->handles.rx = gatt_desc->handle;

	/* Assign connection instance. */
	nus_c->conn = bt_gatt_dm_conn_get(dm);
	return 0;
}

We call this function in the service discovery completed callback. The function will assign the values (handles) we find in the ATT table of the peer device (NUS server). In the function we assign the handles of NUS TX characteristic and NUS RX characteristic to nus_c->handles.txnus_c->handles.rx as well as the CCCD handle nus_c->handles.tx_ccc. They are the most important data we need to communicate with the NUS server. 

After the handles are assigned it's pretty straight forward on how to use them.

- You enable notification (write to CCCD) by using bt_gatt_subscribe(). For the function, you need to provide the CCCD's handle and the characteristic value handle for the callback.

- You can send data using bt_gatt_write() with the rx handle that you get from service discovery. You may notice in the NUS example we use write request (expect a write response, only one write request at a time) if you want to have higher throughput, you should use write command bt_gatt_write_without_response()

int bt_nus_client_send(struct bt_nus_client *nus_c, const uint8_t *data,
		       uint16_t len)
{
	int err;

	if (!nus_c->conn) {
		return -ENOTCONN;
	}

	if (atomic_test_and_set_bit(&nus_c->state, NUS_C_RX_WRITE_PENDING)) {
		return -EALREADY;
	}

	nus_c->rx_write_params.func = on_sent;
	nus_c->rx_write_params.handle = nus_c->handles.rx;
	nus_c->rx_write_params.offset = 0;
	nus_c->rx_write_params.data = data;
	nus_c->rx_write_params.length = len;

	err = bt_gatt_write(nus_c->conn, &nus_c->rx_write_params);
	if (err) {
		atomic_clear_bit(&nus_c->state, NUS_C_RX_WRITE_PENDING);
	}

	return err;
}

Atomic set was used with NUS_C_RX_WRITE_PENDING to avoid having multiple write requests requested at the same time. 

This is the end of PART 2 - Central role in the nRF Connect SDK Bluetooth series. If you want to cover more topic related to this please leave a comment here. But if you have questions or issues about this tutorial please open a new DevZone case as it's not frequently monitored here. 

In PART 3 (TBD) we will cover more advanced features such as Data Length, PHY Request, ATT MTU, how to improve throughput.