After a solid iOS developer?

I am an iOS developer / freelancer / contractor based in Australia.
I specialise in building well-structured app platforms for companies to build their long-term app strategies upon.
I have a broad array of experience from my work at Google, News Corp, Fox Sports, NineMSN, FetchTV, Woolworths, and Westpac.
Please see my portfolio, then get in touch if I can be of service!

See my Portfolio »

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.

Important things to know

  • Bluetooth Low Energy is basically 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 something you may have read about. It's basically an informal grouping of 'services'. Don't worry too much about profiles, especially for your own devices.
  • 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. 1 Scan for peripherals (that have the services you're interested in). 1 Connect to a peripheral. 1 Discover its services. 1 Discover the services' characteristics. 1 Done!

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

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

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

1 You're connected normally... 1 didDisconnect is called with error .connectionTimeout 1 Call 'connect' and never timeout 1 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 basically 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.

Sending

TODO

Receiving

TODO

Messaging

TODO

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

  • 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.

Framework

TODO

Thanks for reading, and have a great week!

Photo by Joel Filipe on Unsplash


You can read more of my blog here in my blog archive.

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