Bluetooth

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.

Important things to know

  • Bluetooth Low Energy is what you want to be using - not old-style Bluetooth. If you want the latter, this article won't be much help.
  • CoreBluetooth is the iOS framework for dealing with bluetooth. It's probably worth a read: Core Bluetooth Guide and Core Bluetooth API.
  • BLE devices have 'services', and those services have 'characteristics'.
  • A 'profile' is an informal grouping of 'services', and doesn't get a mention in Core Bluetooth.
  • BLE is all about reading and writing values (raw byte arrays) to characteristics.
  • If you want to send/receive messages (eg JSON), commonly people build something custom on top of a pair of characteristics.
  • You can subscribe to changes to a characteristic, allowing a peripheral to 'push' data to you when it wants.

High-level

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:

  1. Wait for CoreBluetooth to get to the poweredOn state.
  2. Scan for peripherals (that have the services you're interested in).
  3. Connect to a peripheral.
  4. Discover its services.
  5. Discover the services' characteristics.
  6. Subscribe to any characteristics where you want the peripheral to be able to 'push' data to you.
  7. Done!

And here's how reconnecting to that device on subsequent launches of your app works:

  1. Wait for CoreBluetooth to get to the 'poweredOn' state.
  2. Retrieve the previously-connected peripheral
  3. Connect, discover, etc.

And here's how you re-connect when the device goes out of range:

  1. You're connected normally...
  2. didDisconnect is called with error .connectionTimeout
  3. Call 'connect' and never timeout
  4. didConnect is called later on when the device comes into range.

Project Setup

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.

App Startup

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 pairing to one device, 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",
        ])

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:

  • If willRestoreState was called before, and the peripheral is in connecting state, call connect.
  • If 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!
  • If willRestoreState was called before, and the peripheral is in connected state, but services and/or characteristics are not filled in, call discoverServices then discoverCharacteristics.
  • Try central.retrievePeripherals(withIdentifiers: to find a previously-paired peripheral, and then connect to it.
  • Try central.retrieveConnectedPeripherals(withServices: to find your previously-paired peripheral that is connected to iOS but not your app, and connect to it.
  • Failing all that, you're likely in a situation where your app has simply not been paired before.

Scanning

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.

Connecting

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.

Out-of-range

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 the error is nil, it's a disconnection initiated by your own code, so simply go to 'disconnected' state.
  • Check if the error is a CB error with: if (error as NSError).domain == CBErrorDomain
  • Cast the error code to a CB enum like so: if let code = CBError.Code(rawValue: (error as NSError).code)
  • Make a set of errors that are probably out-of-range, here's what I found worked: let outOfRangeHeuristics: Set<CBError.Code> = [.unknown, .connectionTimeout, .peripheralDisconnected, .connectionFailed]
  • Check if the error is one of the above: if outOfRangeHeuristics.contains(code), and if so, try to reconnect (explained below).
  • After all those checks, at this point it's up to you if you want to try reconnect or not. I don't try, personally.

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.

Discovery

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.

Receiving

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.

Writing

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.

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.

Unpairing

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.

Gotchas / Notes

  • You can't use the central manager immediately - it must change its state to poweredOn
  • Calls to connect never timeout. This is very useful for when a device goes out of range: Simply call connect, and you'll get notified when it comes back in range, however long that may take.
  • When connecting to a peripheral, you must retain the CBPeripheral for the duration or iOS will cancel the connection.
  • It's hard to know the maximum size of a characteristic's value. As best I can tell, iOS negotiates it with the peripheral behind the scenes based on which BT version is being used, and tries to push it higher than the lowest-common-denominator that the standard officially supports. In any case, you don't really need to know the size when setting a value: just set it to as big a Data as you like (within reason), and Core Bluetooth will automatically 'chunk' it for you and set the characteristic's value to those chunks, one Bluetooth packet at a time. This is very useful for the common case where you're using characteristics for sending JSON messages.
  • Typically you'll have at least two characteristics: One for reading, one for writing.

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.

Chris Hulbert

(Comp Sci, Hons - UTS)

iOS Developer (Freelancer / Contractor) in Australia.

I have worked at places such as Google, Cochlear, Assembly Payments, News Corp, Fox Sports, NineMSN, FetchTV, Woolworths, 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
my resume



 Subscribe via RSS