Understanding BLE throughput on Android

We have been told that the BLE can reach a throughput of 1 Mbps (or 2 Mbps with Bluetooth 5.0). But why do we always feel it’s extremely slow in practice? In this article, I will try to explain this, and how to improve throughput on Android.

How BLE devices communicate

Each BLE connection consists of two devices. The device in the central role (like a master) scans and connects to a peripheral device (like a slave).

Each device communicates with its peer on a given period called Connection Interval. The interval ranges from 7.5 ms to 4 seconds, with an increment of 1.25 ms. During each Interval, the devices can exchange one or more packets with each other (always started by the central, then followed by the peripheral). Between each packet sent, there is a 150μs delay, known as Inter Frame Space. Each instance of communication between two devices is called a Connection Event.

Connection Interval

Note that the central will decide the length of Connection Intervals, and the maximum number of packets allowed per Connection Event, and the peripheral can request / suggest different values.

BLE packets

Each data channel packet transmitted during a Connection Event has the following structure:

Data Channel Packet

In Bluetooth 4.0 and 4.1, the maximum size of ATT Data is 23 bytes. In Bluetooth 4.2, an optional feature, Data Length Extension (DLE), was introduced to allow the ATT Data to be extended to up to 251 bytes, reducing the over head significantly.

ATT packets

The applications data is defined by the Attribute Protocol (ATT). Each ATT packet looks like below:

ATT Packet

The Maximum Transmission Unit (MTU) defines the maximum size allowed for each ATT packet, which can be anywhere between 23 bytes and infinite. If one ATT packet is too big to fit in one BLE packet, the BLE stack will take care of the fragmentation and reassembly. This is transparent to the application developers.

The Op Code defines the operation, such as Write Request, Write Response, etc. For operations like Read, Write, and Notification, there will also be an Attribute Handle (2 bytes) to identify the data.

There will also be a 4-byte-long Message Integrity Check (MIC) data at the end of the Attribute Data, if encryption is enabled.

Improve throughput

With the above knowledge in mind, there can be several ways to improve the throughput on Android.

Fine-tune connection interval and data packet length

A good balance between Connection Interval and Data Packet Length can significantly improve the throughput.

For example, with a lower Connection Interval, the devices can talk more often, and therefore achieving a higher throughput. However, if there are lots of data to transmit, or the Data Packet Length is high, a higher Connection Interval could result in a higher throughput, because more data can be transferred per interval.

On Android, though there is no API for the application to adjust Connection Interval, changes can be requested by the peripheral.

Support for Data Length Extension (DLE) is added in Android 6.0 (Marshmallow), and used by most modern devices.

Use 2M PHY

Support for Bluetooth 5 was added in Android 8 (Orea), allowing the symbol rate to be increased to 2 mega symbol per second. We can request 2M PHY using the BluetoothGatt.setPreferredPhy() method:

class BleWrapper {
  private val gattCallback = object : BluetoothGattCallback() {
    override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
      if (status == BluetoothGatt.GATT_SUCCESS) {
        gatt.setPreferredPhy(
          BluetoothDevice.PHY_LE_1M_MASK and BluetoothDevice.PHY_LE_2M_MASK,
          BluetoothDevice.PHY_LE_1M_MASK and BluetoothDevice.PHY_LE_2M_MASK,
          BluetoothDevice.PHY_OPTION_NO_PREFERRED
        )
      }
    }

    override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
      // This will be called, even if no PHY change happens.
    }
  }

  ...
}

Increase ATT MTU

On Android, the default ATT MTU is 23 bytes, and the maximum allowed MTU is 517 bytes. We can request a bigger MTU using the BluetoothGatt.requestMtu() method, and the negotiated result will be available in the BluetoothGattCallback.onMtuChanged() callback, e.g.:

class BleWrapper {
  companion object {
    private const val DEFAULT_ATT_MTU = 23
    private const val MAX_ATT_MTU = 517
  }

  var attMtu = DEFAULT_ATT_MTU
    private get

  private val gattCallback = object : BluetoothGattCallback() {
    override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
      if (status == BluetoothGatt.GATT_SUCCESS) {
        gatt.requestMtu(MAX_ATT_MTU)
      }
    }

    override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
      if (status == BluetoothGatt.GATT_SUCCESS) {
        attMtu = mtu
      }
    }
  }

  ...
}

Note that there is always a one-byte Op Code, and a two-byte Attribute Handle for most operations, so the actual max number of Attribute Data (application bytes) is MTU - 3.

Write without response

There are two main different ways to send data to a peripheral.

Write Request requires acknowledgment from the peripheral (either success or failure), before the next ATT packet could be sent. Note that in practice, it usually takes longer time than Inter Frame Space to prepare the response, meaning the response needs to wait until the next Connection Event, resulting in a lower throughput.

If this becomes a bottle neck, we can use Write Command to send data. It requires no acknowledgement from the peripheral. Therefore, there is a (rare) chance that the ATT packet is dropped by the peripheral, so you might want to implement some application level checking and retrying mechanism.

To write without response on Android:

fun writeCharacteristic(
  gatt: BluetoothGatt,
  characteristic: BluetoothGattCharacteristic,
  value: ByteArray
) : Boolean {
  // Check if Write without Response is supported.
  if (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE == 0) {
    return false
  }

  characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
  characteristic.value = value
  return gatt.writeCharacteristic(characteristic)
}

Note that we should always wait for the onCharacteristicWrite() callback before starting a new write, even if we write without responses.

Subscribe to notifications

When a peripheral needs to inform the central about a characteristic’s value changes, it can use either an Indication (requiring acknowledgment from the central) or a Notification (requires no acknowledgment).

To enable a Notification on Android:

fun enableNotification(
  gatt: BluetoothGatt,
  characteristic: BluetoothGattCharacteristic,
) : Boolean {
  // Check if Notification is supported.
  if (characteristic.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY == 0) {
    return false
  }

  if (!gatt.setCharacteristicNotification(characteristic, true)) {
    return false
  }

  val descriptor = characteristic.getDescriptor(
    UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") // This is defined by Bluetooth spec.
  ) ?: return false
  descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
  return gatt. writeDescriptor(descriptor)
}

We can use the BluetoothGattCallback.onDescriptorWrite() callback to see if the Notification is successfully enabled. Once enabled, any incoming Notification will be delivered via the BluetoothGattCallback.onCharacteristicChanged() callback.

Conclusion

In this article, we briefly discussed how devices communicate with each other over BLE, and how to improve the throughput on Android.

There are other ways to improve the throughput (such as data compression), but that would require designing and implementing your own protocol on top of GATT. I’ll therefore leave it for you.


See also

comments powered by Disqus