In this post, I’d like to explain how to get your app to pair with a Bluetooth LE peripheral, reconnect on subsequent app launches, and stay connected as the devices comes in and out of range - handle all the realistic scenarios. I’ll also talk about the gotchas when working with CoreBluetooth, and outline a good example that you can build on.
Sample code to demonstrate all this is here in a subsequent article, please check it out.
Before delving into the nitty gritty, I’ll explain what needs to happen at a high level.
Here’s how pairing with a new device works:
poweredOn
state.And here’s how reconnecting to that device on subsequent launches of your app works:
And here’s how you re-connect when the device goes out of range:
.connectionTimeout
Firstly, you’ll need to include CoreBluetooth into your app. In Xcode, go into your target settings > General > Linked Frameworks and Libraries, click ‘+’ and select CoreBluetooth.
Next, you should consider enabling background mode if your use-case requires it. If so, head for target settings > Capabilities > Background Modes > Uses Bluetooth LE accessories.
Go to your Info.plist, and add a key named ‘Privacy - Bluetooth Peripheral Usage Description’ and set the value to something like ‘MyAwesomeApp connects to your MyBrilliantPeripheral via Bluetooth’. This is shown to the user by iOS.
At app startup, create a CBCentralManager
instance. Typically you’ll do this in some kind of singleton. Since it requires the delegate passed to the initialiser, you can’t use the same singleton as the delegate due to Swift’s init rules. You must also pass in a restore ID for reconnects across app launches to work. Hopefully you’re only using one CBCentralManager, and thus can use a constant for this id. If you pass ‘nil’ for the queue, all the central/peripheral delegates will be called on the main thread, which is probably reasonable.
class MyBluetoothLEManager {
static let shared = MyBluetoothLEManager()
private let central = CBCentralManager(
delegate: MyCBCentralManagerDelegate.shared,
queue: nil,
options: [
// Alert the user if BT is turned off.
CBCentralManagerOptionShowPowerAlertKey: true,
// ID to allow restoration.
CBCentralManagerOptionRestoreIdentifierKey: "MyRestoreIdentifierKey",
])
Sidenote re CBCentralManagerOptionRestoreIdentifierKey: According to Apple’s docs, the CBCentralManagerOptionRestoreIdentifierKey is used by iOS to persist the CBCentralManager’s state across app startups, not the individual peripherals. Thus if you want to handle multiple peripherals, you’d just use the single CBCentralManager. Thus, you could still use a single id constant.
This ‘central’ will initially be unusable, you must wait for it to call your delegate’s centralManagerDidUpdateState with central.state == poweredOn
before you can do anything. This can be tricky, which is why I recommend using a State Machine for dealing with Core Bluetooth. I’ve written in the past about State Machines before, I recommend reading about it to get a background. In the case here, I’m not going the whole hog with an Event handler, I’m just using an enum with associated values, which I think is a good balance.
Before the poweredOn state, however, Core Bluetooth may call willRestoreState
and give you one or more CBPeripherals. This occurs when your app is relaunched into the background to handle some Bluetooth task, eg a subscribed characteristic value has changed. The given peripheral’s state
should be connected
, however I’ve seen it as connecting
only when running with Xcode’s debugger attached. The trick is to store that peripheral somewhere, then wait for the poweredOn
state, and then use it. I’ll show you later how to do this neatly with a state machine.
Once you’re reached the powered on state, there is a multitude of options ahead:
willRestoreState
was called before, and the peripheral is in connecting
state, call connect
.willRestoreState
was called before, and the peripheral is in connected
state, and its services
and their characteristics
are filled in, you’re ready to use it!willRestoreState
was called before, and the peripheral is in connected
state, but services
and/or characteristics
are not filled in, call discoverServices
then discoverCharacteristics
.central.retrievePeripherals(withIdentifiers:
to find a previously-paired peripheral, and then connect
to it.central.retrieveConnectedPeripherals(withServices:
to find your previously-paired peripheral that is connected to iOS but not your app, and connect
to it.Once you are in poweredOn
state but aren’t connected, and your user has selected to initiate pairing, you need to call central.scanForPeripherals(withServices: [CBUUID(string: "SOME-UUID")], options: nil)
.
The UUID is used as a battery-saving measure for you to tell iOS to filter the peripherals to only the ones with the service(s) you’re interested in. Your hardware team will be able to give you this ID, otherwise you can use Apple Hardware IO Tools > Bluetooth Explorer to find it.
Core Bluetooth will never timeout when scanning, so you’ll probably want to create some timeout of your own (10s is a good starting point), and call central.stopScan()
at that point. Again, the State Machine is a great way to handle these timeouts neatly, which I will explain further below.
When it finds something, it will call the didDiscover:advertisementData:rssi:
delegate with the discovered peripheral. Your hardware team may want to put some custom data in the advertising packets, which will be given to you here as advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
.
If this is the peripheral you want, you must call central.stopScan()
, then myCentral.connect(peripheral, options: [])
, and retain the CBPeripheral somewhere otherwise Core Bluetooth will drop it before the connection completes.
connect
will not timeout (which is handy for out-of-range reconnections), so you must implement your own timeout. I like to embed a timeout in the State Machine’s as an enum associated value. This way the timeout gets automatically cancelled when the state progresses. To do this, I use Countdown from an earlier post I wrote called ‘Timers without circular references’.
Creating the timeout looks like this:
let timeout = Countdown(seconds: 10, closure: {
peripheral.cancelPeripheralConnection(central: self.myCentral)
self.state = .disconnected
})
And embedding it in the state machine looks like this:
state = .connecting(peripheral, timeout)
In this way the state enum retains the peripheral for us too, which is essential to keep Core Bluetooth happy.
After a call to connect
, your delegate will be called with either didConnect:
or didFailToConnect:error:
.
Upon didConnect
, you should save the peripheral.identifier
UUID somewhere (eg UserDefaults) so it can be reused at next app launch to reconnect.
Once paired, one thing you’ll need to deal with is the peripheral coming in and out of range, and reconnecting when that happens.
Your CBCentralManagerDelegate will be told via didDisconnectPeripheral:error:
that something’s gone wrong. At this point some heuristics is involved to figure out of this is an out-of-range issue, as opposed to the peripheral deliberately unpairing from you at the user’s selection. Here’s what has worked for me:
if (error as NSError).domain == CBErrorDomain
if let code = CBError.Code(rawValue: (error as NSError).code)
let outOfRangeHeuristics: Set<CBError.Code> = [.unknown, .connectionTimeout, .peripheralDisconnected, .connectionFailed]
if outOfRangeHeuristics.contains(code)
, and if so, try to reconnect (explained below).If you’ve decided it’s probably an out-of-range and it’s worth trying to reconnect, the trick is to simply call central.connect(peripheral, options:[])
and never set your own timeout. What you’re doing here is effectively telling iOS ‘I’m interested in connecting, let me know if you ever see this peripheral again’. This works because connect
never times out.
Once paired, you’ll need to ‘discover’ the Services and Characteristics of your BT peripheral. Before you do this though, check the peripheral’s services
for the service(s) whose uuid
matches the UUID you’re interested in. If it’s already there (cached by iOS), you can skip service discovery.
To perform service discovery, set the delegate of your CBPeripheral as you deem appropriate (eg a singleton). Then call discoverServices([CBUUID(string: "UUID-OF-INTERESTING-SERVICE"), ...])
, passing in an array of the UUIDs of the services you are interested in for a speed-up.
You won’t get a callback if this fails, so again you’ll have to set some kind of timeout. You’ll receive a didDiscoverServices:
call to your delegate once the service has been discovered. Once you’ve found the service you want, progress to the characteristic discovery step.
Characteristic discovery is much the same as for services: First check the CBService.characteristics, looking for a myCharacteristic.uuid
match to see if it’s already cached. If not, call myPeripheral.discoverCharacteristics([CBUUID(string: "UUID-OF-INTERESTING-CHARACTERISTIC"), ...], for: myService)
, and wait for the didDiscoverCharacteristicsForService:error:
delegate callback.
After completing the characteristic discovery step, you’re ready to ‘listen’ to your peripheral. This is done by subscribing to changes on one (or more) of the characteristic values, by calling myPeripheral.setNotifyValue(true, for: myCharacteristic)
.
Your delegate will be called back on didUpdateNotificationStateFor:error:
when this subscription has been successful or not.
Whenever the peripheral wants to update that value and send you some new data, your delegate will be called on didUpdateValueFor:error:
, and inside that handler you can check myCharacteristic.value
to see the value.
Once you’re paired, and the services/characteristics have all been discovered, you’re ready to ‘talk’ to your BTLE peripheral. The way this works is by setting the ‘value’ of characteristics. These values are raw bytes, Data
in Swift. Locate the characteristic you’re interested in, and call myPeripheral.writeValue(someData, for: myCharacteristic, type: .withResponse (or withoutResponse))
.
If you don’t need a response, type: .withoutResponse
will presumably use less bluetooth packets / battery life.
Otherwise, type: .withResponse
will result in a didWriteValueForCharacteristic:error:
call to your delegate to let you know how that write went.
Bluetooth is a bit vague on how much big a chunk of data can be set on a bluetooth characteristic. iOS appears to negotiate a size, and if you set a data bigger than that size, it chunks the data up and sets the characteristic value one chunk at a time. This is fantastic news if you’re using characteristics as a stream for some sort of JSON messaging.
Since characteristic values are so simple, it’s common to lay some sort of JSON messaging on top of a pair of characteristics (one for reading, one for writing). Typically you can send some start-of-message byte (STX aka ‘start of text’ aka ‘\x02’ is as good as any), then your JSON, then an end-of-message byte (eg ETX aka ‘end of text’ aka \x03).
At the respective (iOS and peripheral) ends, all received values are iterated byte-by-byte thus:
if byte == 2 (STX), empty the buffer.
else if byte == 3 (ETX), try to JSON-parse the buffer and send the message to your message handler.
else append byte to the buffer.
Such an arrangement works very well with the way Core Bluetooth ‘chunks’ large data written to a characteristic value.
This is easy! When the user requests that you unpair, simply call: central.cancelPeripheralConnection(peripheral)
, then dereference the peripheral, then erase the peripheral identifier you stored in your UserDefaults.
poweredOn
Thanks for reading, and have a great week!
Photo by Joel Filipe on Unsplash
Thanks for reading! And if you want to get in touch, I'd love to hear from you: chris.hulbert at gmail.
(Comp Sci, Hons - UTS)
Software Developer (Freelancer / Contractor) in Australia.
I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Coles, Woolworths, Trust Bank, and Westpac, among others. If you're looking for help developing an iOS app, drop me a line!
Get in touch:
[email protected]
github.com/chrishulbert
linkedin