
31 Aug 2021 Working With Core Bluetooth
It has been around 20 years since we started using mobile phones in our everyday life. Over this time more and more hardware has been packed into them, meaning that they can now also be used for activities as diverse as taking photos, satellite navigation and health tracking.
Each time new hardware is added to phones, the underlying SDKs are updated to allow developers access this hardware from their apps. However, the sheer size of modern phone SDKs means it’s rare for a single mobile engineer to have worked with every piece of hardware that a phone has to offer.
In my career as an iOS developer I have had the opportunity to work with APIs for accessing the accelerometer, gyroscope, camera, GPS, networked printers and, most recently, Bluetooth Low Energy (BLE). I have found BLE to be the most complex of them all to use.
In this post I will walk you through some of this complexity, starting with the theory of using BLE on iOS devices, then moving onto my experiences building a real-world commercial application. Whilst this article will be focusing on iOS, many of the concepts and lessons are transferable to Android devices.
Introducing Core Bluetooth
Core Bluetooth (CB) is the Apple framework for accessing BLE on iOS devices. However, documentation, case studies and examples on how to use CB are sparse. Most of the time I found the official documentation not as helpful as I hoped. The only other option is the official BLE documentation, but that is extremely technical, kind of hard-to-follow and generally not very helpful for app developers.
So instead I found myself learning about CB the hard way: reading existing code, debugging and manual testing. But before I get into the difficulties, I’ll outline some of the key concepts.
Core Bluetooth Concepts
When we think of devices communicating with each other, we commonly distinguish between (at the very least) two roles: the client and the server, In the Bluetooth context these are referred to respectively as a “peripheral” and a “central”. Like most Apple frameworks, CB, provides developers with handy delegates to implement the mentioned roles: CBPeripheralManagerDelegate
and CBCentralManagerDelegate
.
How are devices connected?
As well as defining objects to implement the central and peripheral delegates, it’s also necessary to define a service identifier. This is a UUID that is used by devices to identify themselves as peers. Matching of a service ID is abstracted from the developer, but once it succeeds, delegate methods are invoked.
For the purpose of this article I will talk about the read operation and how data can be sent from one peripheral to a central device using characteristics. Write operations work in a very similar way, so I’ll be leaving those for you to explore separately. Note that in order to achieve what we’re doing here, pairing is not needed. Consequently, I won’t cover it in this post.
Bluetooth Central
The role of a device acting as central is to search and connect to any peripheral with a matching UUID. The didDiscover
callback method will be invoked with a peripheral
object that we can use to have control over the connection to it:
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String : Any],
rssi RSSI: NSNumber) {
...
central.connect(peripheral)
..
}
Now that we are connected we would like to exchange some data. To request particular data, we define characteristic IDs. These can be thought of as properties that will be sent from the peripheral to the central device. To discover what characteristics are available, we first ask the peripheral to provide us with the service matching the relevant ID of our application:
func centralManager(_ central: CBCentralManager,
didConnect peripheral: CBPeripheral) {
...
// retrieve the service definition/object
peripheral.discoverServices([ServiceUUID])
...
}
We then implement the CBPeripheralDelegate
(not to be confused with
) to first look up the service object and then the characteristics that we are after:CBPeripheralManagerDelegate
func peripheral(_ peripheral: CBPeripheral,
didDiscoverServices error: Error?) {
...
guard let services = peripheral.services,
let service = services.first else {
return
}
// request the definitions of the relevant characteristics
peripheral.discoverCharacteristics(
[Characteristic_A_UUID, Characteristic_B_UUID],
for: service
)
...
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
...
// retrieves the characteristics definitions, not the values.
// This will contain only the characteristics requested above.
guard let characteristics = service.characteristics,
characteristics.count > 0 else {
return
}
...
}
Implementing the delegate provides the definitions of the information we are after, but not the data itself. To get the actual data, we invoke peripheral.readValue(for: characteristics.first)
. This will result in yet another delegate method invocation where we can finally see the value of the characteristic:
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
...
// value's type is Data
characteristic.value
...
}
Bluetooth Peripheral
A device working as a peripheral will have to advertise a service and respond to requests made by central. Fortunately, implementing a peripheral is simpler than implementing a central.
When creating the manager object, we specify the service ID and the characteristics to broadcast, and provide a reference to a
:CBPeripheralManagerDelegate
// Remember to implement the CBPeripheralManagerDelegate.
let peripheral = CBPeripheralManager(delegate: self, queue: queue, options: nil)
// Create the service to advertise and set the characteristics
let service = CBMutableService(type: MyServiceUUID, primary: true)
service.characteristics = [Characteristic_A_UUID, Characteristic_B_UUID]
// Add service and start advertising
peripheral.add(service)
peripheral.startAdvertising([CBAdvertisementDataServiceUUIDsKey : [MyServiceUUID]])
Now our peripheral is advertising for our service. At this point the central is able to discover and establish a connection to our peripheral!
However, in contrast to the central, all the peripheral has to do is respond to read requests. Every other interaction it has with the central is handled by the SDK. This includes handling incoming connections and providing both service and characteristic details. All we need to do is respond to characteristic read requests:
func peripheralManager(_ peripheral: CBPeripheralManager,
didReceiveRead request: CBATTRequest) {
...
switch request.characteristic.uuid {
case Characteristic_A_UUID:
request.value = valueOfA
case Characteristic_B_UUID:
request.value = valueOfB
}
peripheral.respond(to: request, withResult: .success)
...
}
Learning the hard way
In the previous section I gave you a general understanding of how Core Bluetooth works in iOS. Once I had learnt this much, I said to myself “I’ve got this”. Little did I know my journey was only getting started. In this section I’ll explain some of the things I had to learn the hard way.
Data exchange
One of the first problems I encountered was the fact that my messages (characteristics) were, for some reason, incomplete. As it turns out there is a maximum amount of data that can be sent on each read, although the Apple documentation fails to mention this important fact.
In order to fix this issue, the peripheral needs to use the offset sent in the read request and respond accordingly:
// data is the data relevant for the characteristic
guard request.offset < data.count else {
peripheral.respond(to: request, withResult: .invalidOffset)
return
}
guard request.offset >= data.count else {
// the receiver already read all the data in its last read request
peripheral.respond(to: request, withResult: .success)
return
}
request.value = (request.offset == 0 ?
data :
data.subdata(in: request.offset..<data.count))
peripheral.respond(to: request, withResult: .success)
Luckily this was a simple task and easy to resolve. However it was frustrating that this is not in the documentation. Furthermore, it seems that it would be such a common issue that it could (or should) be handled by Core Bluetooth, rather than by every developer having to deal with it themselves.
Connection pool
Once we were in production, connections between devices would seem to simply stop working without warning. Devices that in the past had connected to each just fine would suddenly fail to do so.
This hadn’t been a problem in development, or even whilst we were testing in the field. There was nothing in the device debug logs, and we couldn’t think of any reason it might happen.
If we relaunched the app for debugging it would initially starting connecting to other devices as expected. However, after a while it would stop once again, and the debugger could not see any delegate methods being triggered. Forcing scanning to stop and then starting it again made no difference.
It turns out that iOS uses a device connection pool. This makes sense, but Core Bluetooth is not smart enough to detect when a connection is terminated. Consequently, all device connections stays in the connection pool, irrespective of whether the actual device is still in proximity. Eventually the pool reaches capacity, and no more devices can connect.
For example, if I have two test devices connected together at home and take one of them to the office, the actual radio connection is lost once the devices far enough away from each other. Unfortunately the corresponding item in the connection pool is not automatically removed, meaning space for future connections (for example, with other devices at the office) is not freed-up.
To fix this we needed to remove connections ourselves from the pool, and find some heuristic that would trigger removal. In the end we used timestamps to track the last time there was connection, but you could also use things like distance and signal strength if it was more appropriate for your use-case.
Bewildering as this was, in retrospect I can see why Core Bluetooth leaves this to the developers to implement. There are some circumstances where we might not want a connection to end. For example, I might have a beacon at home which I want my device to maintain state with – even if it’s temporarily out-of-range – rather than having to go through a full discovery cycle every time.
Identifying peripherals
As mentioned earlier, when a peripheral is discovered and the didDiscover
delegate method is invoked, we can easily obtain the UUID. However, it turns out that if you are connecting to an Android device, after a period of time this UUID can change, meaning the UUID can no longer be used to uniquely identify that device.
Instead, if you want to retain connections with Android devices, every time didDiscover
is called, disregard peripheral.identifier.uuidString
and instead check the device identifier in the data that is provided in CBAdvertisementDataManufacturerDataKey
:
var androidId = nil
if let manuData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
manuData.count > 2 {
// At this point we know this is an Android device as iOS does not send this.
// We set a max of 8 bytes for an int64 to use as ID.
// The first 2 bytes represent manufacturerID
// see https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers
let identifierData = Data(manuData.subdata(in: 2..<min(8, manuData.count)))
var longValueData = Data(identifierData)
// we make sure the length is 8
if longValueData.count < 8 {
longValueData.append(Data(repeating: 0, count: 8 - longValueData.count))
}
if let longValue = longValueData.int64(0) {
androidId = Int64(longValue)
}
}
Compare that ID against previously connected peripherals and manually disconnect them if necessary. We don’t want to take unnecessary spots in that connection pool by keeping duplicates do we?
Conclusion
Working with Core Bluetooth was difficult but rewarding.
The documentation and sample code are limited, and it was hard to debug and test. At times the logs would seem to be out of order, meaning it was hard to know for sure what really happened first, or why it happened. This was especially difficult when there was more than one device connected. Whilst mocking objects for testing would help in-principle, my limited knowledge on how BLE works and data is exchanged made it difficult to create effective stubs.
Tracing device IDs through endless log files was frustrating, but also made finding and fixing each issue feel like an achievement. As a mobile engineer it’s rare to get a chance to work with Core Bluetooth, so overall I relished the opportunity. I just hope that sharing my experiences here will help make it a little bit easier for others who also get the chance to use it.
No Comments