BLE data and file transfer on Android
There's a lot of confusion on how data should be transferred between two BLE devices. Perhaps the BLE is quite complicated for those with
no prior experience with it. Moreover, it's quite depressing even for those who have spent several years with such devices. This is quite true because
there's a wide variety of sensors and other BLE devices that don't work according to the standards. However, here we provide an
example that may be considered as a generic guideline for engineering Android BLE data transfer apps. The intention is to illustrate how a file
or data is exchanged between an Android device and a BLE capable peripheral. The BLE peripheral sends data and the app in Android receives it.
The file size may be from a few bytes to hundreds of megabytes although the max data rate is around 20 kbytes / second.
Scanning for our BLE device
The scanning performance is very poor with most Android devices if the default settings are used. It's recommend to use the
Scansettings.SCAN_MODE_LOW_LATENCY if the user needs to select a device to connect from the UI. This shouldn't be running in the
background at all times as it consumes a lot of power. The code below illustrates how the devices are quickly discovered:
private void btStartDeviceScan() {
startScanUI();
AsyncTask.execute(new Runnable() {
@Override
public void run() {
if (btScanner != null && btAdapter.isEnabled()) {
ScanSettings settings = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build();
btScanner.startScan(null, settings, leScanCallback);
}
}
});
if (btScanner == null) {
stopScanUI();
}
}
Once a scan result has been found, we look for its contents. It's possible to utilize the hardware assisted filters but
they're not available on all Android devices so let's just look for all scan results and do the filtering in our software instead. Here we have the leScanCallback()
and the processScanResult() that investigate the results. We look for our service SERVICE_PARCEL_UUID. Please notice that
it's important to stop scanning before connecting to the device. Otherwise the scan may be continuing its work meanwhile.
Connecting to the device
In fact, we're searching for our SERVICE_UUID. The uuid UUID used in this example is utilized elsewhere (on other projects) in serial connection services but the
128 bit UUID should be unique for the particular service so do not blindly copy it for other than testing purposes. If you're building your own service you'd
need to use an arbitrary 128 bit UUID. Of course, you can go and register your service at bluetooth.com and get a 16 bit UUID but it would cost some time and money.
Nordic Semiconductor's NRF Connect (Android app) may be used to
get an overall view of the UUID topologies. In short, there's a service (services are collections of characteristics) UUID, the service characteristics UUID(s), for
example, one for RX and another for TX, and a way to configure the service
characteristics (configuration UUID(s)), one for each configurable characteristics.
The BLE terms aren't very informative for all programmers. Some analogies may be drawn in the following way:
Service UUID: This is what you advertise to others, a reference to your set of "functions". A unique value.
Service Characteristics UUIDs: The "functions" what your service provides, for example RX and TX
Client Characteristics Configuration Descriptor UUIDs: The "switches" that make the Service Characteristics do something (start sending notifications, stop sending notifications).
One for each "function" (RX, TX), but if it's not configurable, then it doesn't need to exist. Standard value 0x2902 must be used.
The code below shows how the SERVICE_UUID can be searched and how the device may be connected
with the connectGatt() function:
private void processScanResult(ScanResult result) {
boolean foundDevice = false;
ScanRecord scanRecord = result.getScanRecord();
if (scanRecord == null) {
return;
}
byte[] bytes = scanRecord.getServiceData(SERVICE_PARCEL_UUID);
if (bytes == null) {
List<ParcelUuid> uuids = scanRecord.getServiceUuids();
if (uuids == null)
return;
if (uuids.isEmpty())
return;
for (ParcelUuid uuid : uuids) {
Log.d(TAG, "Found scan record: " + uuid.toString());
if (uuid.equals(SERVICE_PARCEL_UUID)) {
Log.d(TAG, "Found proper scan record: " + uuid.toString());
foundDevice = true;
}
}
if (!foundDevice)
return;
}
Log.d(TAG, "Connecting to: " + scanRecord.toString());
BluetoothDevice device = result.getDevice();
String deviceAddress = device.getAddress();
stopBleScan();
BluetoothDevice mDevice = btAdapter.getRemoteDevice(deviceAddress);
mGatt = mDevice.connectGatt(this, false, gattCallback);
}
private ScanCallback leScanCallback = new ScanCallback() {
@Override
public void onBatchScanResults(List<ScanResult> results) {
for (ScanResult result : results) {
processScanResult(result);
}
}
@Override
public void onScanFailed(int errorCode) {
Log.d(TAG, "Scan error: " + errorCode);
}
@Override
public void onScanResult(int callbackType, ScanResult result) {
processScanResult(result);
}
};
UUIDs
The service (SERV_UUID) is a 128-bit UUID we're looking for and the characteristics is a UUID contained in the service itself. The CCCD is the Client Characteristics Configuration Descriptor,
which is for configuring the notifications on/off for the service itself. It's a constant whereas the SERV_UUID and CHAR_UUID may vary - put whatever you wish into those but
keep in mind that they'd better be aligned (similar to each other). All these are required for one way data / file transfer.
private static final String SERV_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E";
private static final ParcelUuid SERVICE_PARCEL_UUID = ParcelUuid.fromString(SERV_UUID);
private static final String CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E";
private static final UUID CHARACTERISTICS_UUID = UUID.fromString(CHAR_UUID);
private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
Why is the CCCD a 128 bit string? From the standard we see it being a 16 bit value 0x2902 only? Well, in like manner the Eddystone UUID is 0xFEAA, declared
"0000FEAA-0000-1000-8000-00805F9B34FB". See that they differ only a bit (2902 -> FEAA). The rest just indicate the system that this is indeed a 16 bit UUID.
Starting to send and receive data
Below the gattCallback() is declared. Eventually we enable the notifications (see the line with comments) which results in the peripheral device to start transmitting data - 20 bytes at a time.
Then we try to use the CONNECTION_PRIORITY_MAX in order to get the fastest connection parameters. This means faster data
transfer if it's accepted by the peripheral device. It should enforce a connection parameter update request on the peripheral but if no support is present in the peripheral
it's possible that the request is rejected (or not understood) and no faster parameters are used. Keep in mind that the peripheral device should be configured so that if
the notifications are enabled then it will start sending data at 20 byte junks. In this example, only Android side is addressed.
private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
switch (newState) {
case BluetoothProfile.STATE_CONNECTED:
gatt.discoverServices();
break;
case BluetoothProfile.STATE_DISCONNECTED:
gatt.close();
break;
default:
}
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (gatt.getService(SERVICE_UUID) == null) {
gatt.disconnect();
return;
}
if (gatt.getService(SERVICE_UUID).getCharacteristic(CHARACTERISTICS_UUID) == null) {
gatt.disconnect();
return;
}
BluetoothGattCharacteristic characteristic = gatt
.getService(SERVICE_UUID)
.getCharacteristic(CHARACTERISTICS_UUID);
gatt.setCharacteristicNotification(characteristic, true);
BluetoothGattDescriptor descriptor =
characteristic.getDescriptor(CCCD);
if (descriptor == null) {
gatt.disconnect();
return;
}
// Enable the notifications
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(descriptor);
if (!gatt.requestConnectionPriority(CONNECTION_PRIORITY_HIGH)) {
Log.i(TAG, "No CONNECTION_PRIORITY_HIGH!");
}
}
@Override
public void onCharacteristicRead(BluetoothGatt gatt,
BluetoothGattCharacteristic
characteristic, int status) {
readFromRemoteCharacteristics(characteristic);
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
readFromRemoteCharacteristics(characteristic);
}
};
private void readFromRemoteCharacteristics(BluetoothGattCharacteristic
characteristic) {
// Read from the correct UUID into a local file
if (CHARACTERISTICS_UUID.equals(characteristic.getUuid())) {
byte[] data = characteristic.getValue();
dataRxAmount += data.length;
fileStoreData(data);
}
}
Eventually we store the received data to a local file. Often times there's only 20 bytes included in a notification.
Known bugs - easy to produce
gatt.disconnect() isn't enough if the whole BLE session needs to be terminated. If gatt.close() is not called at all,
you may experience seeing duplicate data via two or more descriptors. For a clean termination, call disconnect() and close().
|