Bluetooth low energy central tutorial

Scope

Topics that will be covered include:

    1. Before we begin
      • Necessary equipment and software
      • Necessary prior knowledge
      • Setting up the example projects
    2. Background theory
      • Central and peripheral roles
      • Server and client roles
      • Connections in BLE
    3. The NUS client example
      • Event flow and handling
      • Scanning
      • Connecting
      • Service Discovery
      • Sending and receiving data
      • Pairing and bonding
    4. Summary

Before we begin

Necessary equipment and software

Equipment and software used in this tutorial:

Necessary prior knowledge

It is expected that you have basic knowledge of how to use SES, nRF Connect and how to download your application to your kit. Read the tutorial Setting up an example project on the nRF52 DK to learn how to use your equipment and compile your first BLE application. You should be familiar with how a BLE peripheral device works, from previous tutorials like the advertising tutorial and the Services Tutorial. Specifically, you should be familiar with the (peripheral) example project in our SDK. I will not explain how this service works in this tutorial.

Please browse DevZone, look for documentation on our Infocenter, and read the user guides for your kits if you run into trouble. I encourage you to post any questions you might have on the forum and not below the tutorial. This makes it is easier for other users and Nordic employees to see and search for your post and you will probably get an answer sooner.

We have made a video series on the basics of using SEGGER Embedded Studio. The video series is highly recommend if you don't have any previous experience with SES: https://www.youtube.com/watch?v=YZouRE_Ol8g&list=PLx_tBuQ_KSqGHmzdEL2GWEOeix-S5rgTV

Setting up the example projects

We will set up one NUS server, which we can connect to with our NUS client. You do not have to do this in order to learn from this tutorial, but it is more fun to make something work :)

Setting up the peripheral

  • Download Softdevice 132 from the Nordic website.
  • Open, compile and flash the ble_app_uart example application found in "Your_SDK_path\examples\ble_peripheral". NB!: Make sure that your softdevice is flashed onto your device.
  • Open an uart terminal window in Termite to view the uart output

Setting up the central

  • Download Softdevice 132 from the Nordic website
  • Open, compile and flash the ble_app_uart_c example found in "Your_SDK_path\examples\ble_central". NB!: Make sure that your softdevice is flashed onto your device.
  • Open, compile and flash it to your central device.
  • [Open an uart terminal window in Termite to view the uart output]
  • Keep the project open in SES

The peripheral device will start advertising, and our central device will connect to it. Both device will light up a led to indicate a connection (if you are using Development Kits). If you write anything to one of the terminal windows, it will be sent over to the other device and printed on the terminal window of that device.

Background theory

Central and peripheral roles

The GAP (Generic Access Profile) defines the discovery, connection and link management part of Bluetooth. Central and peripheral are both GAP roles. See this nice Adafruit guide on GAP. The central is the one initiating a connection, as well as decide connection intervals and other connection parameters. Almost everything is initiated by the central, for example connection parameters update or pairing. Allthough a peripheral can request the central to perform these things , it is always up to the central to decide what to do. The peripheral begins its life cycle by advertising, and then responding to scan requests and connection requests from a central. Other GAP roles are the broadcaster and the observer, but these are not relevant for this tutorial.

Server and client roles

Server and client are GATT (Generic Attribute Profile) roles. These roles come into play once a connection has been established, and are not decided by the central and peripheral GAP roles. See this nice Adafruit guide on GATT. The GATT server can in general be described as the device sitting on information or data, while the GATT client is the one seeking this data. A heart rate sensor module is a typical GATT server, because it is the source of the heart rate data. The pulse watch or smartphone connecting to this heart rate device will be the GATT client, because it is the “data consumer”. In the heart rate example, the GAP peripheral is the GATT server, and the GAP central is the GATT client. This is the most normal case, but it is not a rule. In the Apple Notification Center Service, the GAP peripheral (often a smartwatch) is the GATT client, because the data lies on the iOS device (GAP central and GATT server).

Connections in BLE

A peripheral device broadcasts info about itself using advertising. The central device will scan for advertisements, and analyze the advertisements found in the scan result. Based on the advertisement result, the central can decide to connect to a advertising peripheral. It is not possible to connect to a peripheral which is not advertising, even though one knows its address from before. This is because the peripheral will only turn on the receiver for a set amount of time after transmitting an advertisement. This time is used to listen for connection requests and scan requests. The connection requests sent from the central contains connection parameters for the new connection: The connection interval tells the peripheral how often they should communicate. The channel map describes which channels they shall communicate on, and in which order. The peripheral does not respond to this request directly. If it accepts the connection, it will tune in to the right frequency at the right time. If it does this, the connection is established. The central and peripheral will continue to meet at the set channels and times as long as the connection is up. Either part can at any time disconnect actively by telling the other device. The other form of disconnection is when one device stops “meeting up” at the set channels and times. If a certain time passes since the last communication, the remaining device will have a disconnect event.

The NUS client example

In this section i will go trough how the BLE central is implemented in the NUS client ble central example. All functions have a set of "()" after their name. This is to show that it is a function, it does not necessarily mean that it takes no parameters. NUS stand for Nordic Uart Service. We provide one server and one client example project of this service in our SDK. They are named ble_app_uart and ble_app_uart_c in the folder structure. It is called a "uart service" because it emulates a uart link over BLE, i.e. transmission of bytes serially in two directions. The naming might be confusing, since we also have a wired uart connection in the application. In the NUS client application, all NUS related functions are named ble_nus_c_X or nus_c_X, while normal uart function are named uart_X.

Event flow and handling

Our application (the SDK actually) is event driven. This leads to a lot of events flying around, and it is not always easy to understand the flow of an application. In this paragraph i will try to explain the common way modules communicate with each other. If we want to use a module, we must initialize it, and provide it with a callback function. This callback function is a function in our application, which the module can call when it wants to report back to us. An example of this is the ble_nus_c_evt_handler() in main.c. We provide this function to the ble_nus_c module when we initialize it in nus_c_init().

All BLE events is received automatically at the service and application after they have registered as BLE stack event observers with NRF_SDH_BLE_OBSERVER.  We must create an instance of every module that is interested in BLE events. In our example, this is done by the BLE_NUS__C_DEF(m_ble_nus_c), NRF_BLE_GATT_DEF(m_gatt) and the BLE_DB_DISCOVERY_DEF(m_db_disc) for the ble_nus_c, GATT, and the db_discovery module respectively. 

If these modules want to communicate something back to the application based on the BLE event, they will create a new event type based on which module they are. This event is then passed to the application through the callback function registered earlier. We can see that the db_discovery module does not report to the application directly, but rather to the ble_nus_c module. This is because the ble_nus_c module was the one registering a callback in the db_discovery module. When the ble_nus_c module receives a callback from the db_discovery module, it will create its own event type and send this to the application through the ble_nus_c_evt_handler() callback.

Scanning

Scanning is done by listening on the three (37,38,39) channels used for advertising in BLE. Scanning is done at one channel at a time, and the channel is changed every scan window. You start scanning by calling the softdevice with the function call sd_ble_gap_scan_start(&m_scan_params) inside scan_start() in main.c. The m_scan_params struct of the type ble_gap_scan_params_t contains a couple of important variables, which configures our scanning:

  • Active: Perform active scanning, which is sending scan requests to all advertisers asking for their scan response packets in addition to the advertisement packet.
  • Filter policy: if set to BLE_GAP_SCAN_FP_WHITELIST; Ignores devices that are not in our whitelist, which is a list of known devices.
  • Interval: How often we start a scan window
  • Scan window: How long we scan every scan interval
  • Timeout: How long the scanner will run before stopping automatically.

image description

When the scanner receives an advertisement packet, it will return it in a ble_gap_evt_adv_report_t event to the application. This event is "received" from the softdevice after the application has registered as an observer. The event is then passed to the ble_evt_handler(). This function contains a large switch case based on the event type. The case BLE_GAP_EVT_ADV_REPORT handles the advertisement report event.
The advertisement report is found inside the event structure: p_gap_evt->params.adv_report. The type of this report is ble_gap_evt_adv_report_t, which is a structure containing the following structures:

  • ble_gap_addr_t peer_addr: The connected structure contains the peer_addr, which is the Bluetooth address of the device broadcasting the advertisement

  •  int8_t rssi : The structure contains the rssi, which is the signal strength of the device, in dBm.

  • ble_gap_adv_report_type_t type: Advertising types (Connectable, direct/indirect, scan response etc…).

  • ble_data_t data: contains the advertising packet as a byte array (uint8_t) and the length of the data. 

The data field is structured in the following way:

[LENGTH_0][TYPE_0][VALUE_0][LENGTH_1][TYPE_1][VALUE_1] …  [LENGTH_n][TYPE_n][VALUE_n]

If it contains the name “Hello” as the first value of the packet, it will look like this:

[0x5][0x9][0x48,0x65,0x6c,0x6c ,0x6f] …  [LENGTH_n][TYPE_n][VALUE_n]

0x5 is the length of the name, and 0x9 is the type COMPLETE_LOCAL_NAME. The type codes can be found here . They are also defined in ble_gap.h in the nRF SDK. The rest of the values are "Hello" in hex format (ASCII).

As the data field is just a sequence of bytes (uint8_t), we must decode it ourselves. In our example code, we find the line

 if (ble_advdata_uuid_find(p_adv_report->data.p_data, p_adv_report->data.len, &m_nus_uuid))

The ble_advdata_uuid_find() function searches the provided p_adv_report for a UUID specified in the &m_nus_uuid parameter. The function returns a boolean saying whether or not the uuid was found.

We use the information in the advertisement data to decide whether or not we want to connect to a device. In our example project, only the UUID is searched for in the advertisement report. In your own project, you can search for any of the types relevant for you. Flags and name are often checked in addition to the UUID. When we find the device we are looking for, we move on to connecting.

Connecting

Connecting is done by using the sd_ble_gap_connect function. It takets three parameters. The p_peer_addr is the Bluetooth address of the device you wish to connect to. We typically get this from the advertisement packet. The ble_gap_scan_params_t are the same scan parameters we used to scan earlier. These are needed because our device must scan for the next advertisement from the chosen peripheral. As I mentioned in the background theory section, the peripheral will only listen for connection requests in a short time after broadcasting an advertisement packet. The m_connection_param parameter defines the connection parameters of the new connection. It is a struct of the type ble_gap_conn_params_t contains the following fields:

  • min_conn_interval: The minimum accepter connection interval for our application.
  • max_conn_interval: The maximum accepted connection interval for our application.
  • slave_latency: The peripheral is allowed to skip this amount of connection events.
  • conn_sup_timeout: Time between last received packet from the peripheral, and disconnect.

If the connection is successful, a BLE_GAP_EVT_CONNECTED event is sent to the application through ble_evt_handler. If the connection times out, a BLE_GAP_EVT_TIMEOUT is received instead. The connection sequence can be seen in this chart.

In the BLE_GAP_EVT_CONNECTED event we must save the connection handle of our peripheral. This handle is used in many contexts as the identifier for the peripheral. We can see that this is done in ble_nus_evt_handler which is called from ble_evt_handler(). It saves the connection handle inside the global p_ble_evt->evt.gap_evt.conn_handle struct.

  case BLE_GAP_EVT_CONNECTED:
NRF_LOG_INFO("Connected to target");
err_code = ble_nus_c_handles_assign(&m_ble_nus_c, p_ble_evt->evt.gap_evt.conn_handle, NULL);
APP_ERROR_CHECK(err_code)

If we want to use other things than the connection handle from the event, we can find the relevant data field inside  p_ble_evt->evt.gap_evt . We can for example find the peripheral's Bluetooth address here. The connected event is also handled inside db_disc_handler(). You can take a look at how these modules handle various events, but you do not need to understand everything they do. The db_discovery module will be discussed later.

Service discovery

The nRF SDK provides a Database Discovery module called db_discovery. This module handles discovery of services, characteristics and attributes on the remote GATT server on the device we are connected to. You can use the db_discovery module in three easy steps: (All of this is already done in the example project)

1- Create a global ble_db_discovery_t variable in main.c.

    BLE_DB_DISCOVERY_DEF(m_db_disc);

2- Initialize the db_discovery module with ble_db_discovery_init() in main().

3- Add a calback function to handle events and forward the event to their respective service.

static void db_disc_handler(ble_db_discovery_evt_t * p_evt)
{
ble_nus_c_on_db_disc_evt(&m_ble_nus_c, p_evt);
}

4- Register a service UUID which the db_discovery module shall look for in a discovery as well as a function which the db_discovery module shall call when a discovery has been done. This is done in ble_nus_c.c:

   return ble_db_discovery_evt_register(&uart_uuid);

5- Start a service discovery using ble_db_discovery_start() after getting the event BLE_GAP_EVT_CONNECTED in main.c.

    err_code = ble_db_discovery_start(&m_db_disc, p_ble_evt->evt.gap_evt.conn_handle);

We see that number 4 is done by the ble_nus_c module. This means that the service gets notified when a service discovery has been completed. All processing of the service discovery results are also done within the service code (ble_nus_c.c), inside db_discover_evt_handler(). The handler checks if the service discovery was successful, and stores the handles for the various characteristics and attributes which we need later. For example when we want to send data using the TX characteristic, we need the nus_c_evt.handles.nus_tx_handle stored here in order to identify the correct characteristic. Our application is notified of the results in ble_nus_c_evt_handler(), which we registered as our callback function for the ble_nus_c service module in nus_c_init() in main.c.

Sending and receiving data

If we wish to send data to the peripheral, we write to the RX characteristic. This is done with the function ble_nus_c_string_send() inside ble_nus_c.c. Remember that you cannot call this function too fast. Depending on your connection parameters, this function can only be used 2 times per connection interval (minimum 7.5 ms). If you call this function too fast, the rx buffers fill up and you will be returned the error BLE_ERROR_NO_RX_BUFFERS.

The peripheral will send us data by “notifying” the TX characteristic. A notification consists of two actions: Update the value of the characteristic in the local GATT server, and then notify the central of this change. Before the peripheral can notify changes, the central must turn on notifications for the characteristic. By doing this, he is “subscribing to changes” in the characteristic. The central can enable notifications by writing a “1” to the cccd (Client Characteristic Configuration Descriptor) –attribute. Writing a “0” to this descriptor will turn off notifications. Enabling notifications in the example project is done by calling the function ble_nus_c_tx_notif_enable() inside ble_nus_c.c. This function is called by ble_nus_c_evt_handler() on the BLE_NUS_C_EVT_DISCOVERY_COMPLETE: event.

err_code = ble_nus_c_tx_notif_enable(p_ble_nus_c);

After notifications are enabled, new notifications arrive as BLE_GATTC_EVT_HVX ble events. The event is handled in the ble_nus_c ble event handler ble_nus_c_on_ble_evt(), where a function call to on_hvx() is done. on_hvx() use the handle value notification  recieved from the Sotftdevice and checks if it is a notification of the NUS TX characteristics from the peer. The received data is found within the ble_nus_c_evt.p_data variable.

Pairing and bonding

This topic is not specific for central applications, so I will keep it short. In our SDK, we have a library called the Peer Manager (PM), which among other things includes controlling encryption and pairing procedures as well as persistently storing different pieces of data that must be stored when bonded 

In the example project, pairing and bonding is not implemented. This is to keep the example simply and easy to understand. 

Summary

In this tutorial we have learnt about the most important concepts and events related to implementing a central device on a nRF52. We have discussed scanning, connections, service discovery, data transmission and pairing/bonding. There are still many things you might want to implement in your central device, for example adding a different service. I hope this tutorial gave you enough information so that you will be able to do that. If anything is unclear, or you miss an explanation of certain things, let me know in the comments below. I will add to or edit this tutorial based on your feedback.

Changelog

  • 22.02.2016: Updated to use the SDK 10 example instead of the github example
  • 31.08.2018: Updated to suit SDK 15