The Ultimate Guide to Apple’s Core Bluetooth | Punch Through

https://punchthrough.com/core-bluetooth-basics/


Table of Contents

    App permissions
    Initializing the central manager (CBCentralManager)
    Scanning for peripherals
    Connecting and disconnecting
    Discovering services and characteristics
    Subscribing to notifications and indications
    Reading from a characteristic
    Writing to a characteristic
    Pairing and bonding
    Core Bluetooth errors
    Thanks for reading!

17 minute read

This article assumes you know the very basics of Bluetooth Low Energy (BLE) and iOS programming (including the delegation pattern for asynchronous calls common to many iOS native APIs), and is meant as a comprehensive guide to the ins and outs of iOS’s Core Bluetooth library. We will walk you through the major components of the API, including the basic steps for scanning, connecting to, and interacting with a BLE peripheral, plus common pitfalls and things to know about BLE on iOS.
App permissions

Before you dive into code writing, you’ll need to configure certain permissions to allow your app to use Bluetooth.  As of the time this article was written, Apple requires developers to include a couple of different keys in their apps’ Info.plist depending on their Bluetooth usage:

Key: Privacy – Bluetooth Always Usage Description
Value: User-facing description of why your app uses Bluetooth.
Required for any app that targets iOS 13 or later.

Provided description will be presented to the user upon initial launch of the app, prompting them to allow your app access to Bluetooth. Be clear and honest, e.g., “This app uses Bluetooth to find and maintain connections to your [proprietary device].” Discluding this key will cause your app to crash upon launch if running iOS 13 or later, and your app will be rejected from the App Store.

Key: Privacy – Bluetooth Peripheral Usage Description
Value: User-facing description of why your app uses Bluetooth.
Required for any app that uses Bluetooth and targets a minimum of iOS 12 or earlier.

Same rules as above. Devices running iOS 12 or earlier will look for this key and present the user with the provided message, while devices running iOS 13 or later will use the first key listed above. Apps that target a minimum of 12 or earlier should provide both keys in their Info.plist.

Key: Required background modes
Value: Array that includes item “App communicates using Core Bluetooth”
Required for any app that uses Bluetooth in background, including for scanning or even just maintaining a connection.
Initializing the central manager (CBCentralManager)

The central manager is the first object you’ll need to instantiate to set up a Bluetooth connection. It handles monitoring the Bluetooth state of the device, scanning for Bluetooth peripherals, and connecting to and disconnecting from them.

When you initialize your CBCentralManager, you’ll need to include the intended delegate to receive the asynchronous method calls of the CBCentralManagerDelegate protocol. You may also specify the queue in which your central manager activity should be scheduled. In practice, it’s best to specify a separate queue for Bluetooth activity, but that’s beyond the scope of this article so we’ll let the queue default to main in our code example:
class BluetoothViewController: UIViewController {
    private var centralManager: CBCentralManager!
    override func viewDidLoad() {
        super.viewDidLoad()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }
}

In this code example, for simplicity, the delegate is set to self, i.e., the same class storing the central manager object.
Monitoring the central manager’s state

Simply instantiating your CBCentralManager object isn’t enough to start using it. In fact, if you try to call scanForPeripherals(withServices:options:) right after your initialization code, you’ll likely see a warning in the Xcode debugger. Your CBCentralManagerDelegate must implement the centralManagerDidUpdateState() method, and it’s from there you can proceed with your flow.

The centralManagerDidUpdateState() method is called by Core Bluetooth whenever the state of Bluetooth on the phone and within the app is updated. Under normal circumstances, you should receive a didUpdateState() call to the delegate object almost immediately after initializing the central manager, with the included state being .poweredOn.

As of iOS 10, the possible states include the following:

poweredOn – Bluetooth is enabled, authorized, and ready for app use.
poweredOff – The user has toggled Bluetooth off and will need to turn it back on from Settings or the Control Center.
resetting – The connection with the Bluetooth service was interrupted.
unauthorized – The user has refused the app permission to use Bluetooth. The user must re-enable it from the app’s Settings menu.
unsupported – The iOS device does not support Bluetooth.
unknown – The state of the manager and the app’s connection to the Bluetooth service is unknown.

iOS has built-in prompts that will appear to notify the user that an app requires Bluetooth and to request access, and as is the case with most of iOS’s system-level prompts and permission settings, the app has essentially no control. If a user denies your app Bluetooth access, you’ll receive a CBState of .unauthorized, at which point it’s up to you to offer your user some kind of directive to enable Bluetooth permissions through your app’s page in Settings. You may even provide a deep link to open your app’s Settings page directly.

The same goes for directing a user who has disabled Bluetooth. Unfortunately, at the time of this article, there are no longer any Apple-approved APIs for deep-linking to non-app specific pages of Settings, like Bluetooth.

⚠️ You should assume the behavior, UI, and messaging of Apple’s prompts may not stay consistent across versions of iOS, and avoid referencing them too specifically in your own directives or attempting to predict their behavior.
extension BluetoothViewController: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
            case .poweredOn:
                startScan()
            case .poweredOff:
                // Alert user to turn on Bluetooth
            case .resetting:
                // Wait for next state update and consider logging interruption of Bluetooth service
            case .unauthorized:
                // Alert user to enable Bluetooth permission in app Settings
            case .unsupported:
                // Alert user their device does not support Bluetooth and app will not work as expected
            case .unknown:
               // Wait for next state update
        }
    }
}
Scanning for peripherals

Once you’ve received the didUpdateState() call and .poweredOn flag, you may proceed to start scanning. Call scanForPeripherals(withServices:options:) on your central manager object. You may pass in an array of CBUUIDs (see definitions above) that represent specific services you want to filter for. The central manager will then only return devices that advertise* to have one or more of these services, via the centralManager(_:didDiscover:advertisementData:rssi:) delegate method. You may pass in a couple of options to this method, using a dictionary containing zero or more of the following keys:

CBCentralManagerScanOptionAllowDuplicatesKey

This key takes a Bool as a value and, if true, makes a delegate call for every detected advertising packet of a given device, rather than just the first received advertising packet that scan session. The default value (at the time this post was written) is false, and Apple recommends keeping this setting off if possible because it uses much less power and memory than the alternative. However, we often find it necessary to turn this setting on to receive updated RSSI values throughout a scan session.

CBCentralManagerScanOptionSolicitedServiceUUIDKey

Not commonly used, but would be useful in the case of a GAP peripheral advertising to a GAP central where the central device acts as the GATT server instead of the client (usually it’s the other way around). The peripheral can advertise (solicit for) specific services it expects to see in the central’s GATT table. In turn, the central can use the CBCentralManagerScanOptionSolicitedServiceUUIDKey to include peripherals soliciting for specific services in its scan.

*A peripheral will not usually advertise all or even most of the services it contains; instead, a device will usually advertise a particular custom service that a central should know to look for if it’s only interested in a specific type of BLE device.
Peripheral identifiers

Unlike Android, iOS obscures the MAC address of peripheral objects from app developers for security purposes. Peripherals are instead assigned a randomly generated UUID found in the identifier property of CBPeripheral objects. This UUID isn’t guaranteed to stay the same across scanning sessions and should not be 100% relied upon for peripheral re-identification. That said, we have observed it to be relatively stable and reliable over the long term assuming a major device settings reset has not occurred. As long as there is an alternative in place, we’ve been able to rely on it for things like connection requests when the device is out of sight.
Scan results

Each call to the delegate method centralManager(_:didDiscover:advertisementData:rssi:) reflects a detected advertisement packet of a BLE peripheral in range. As discussed above, the number of calls per device in a given scanning session depends on the provided scanning options, as well as the range and advertising status of the peripheral itself.

The method signature looks like this:
optional func centralManager(_ central: CBCentralManager,
                didDiscover peripheral: CBPeripheral,
                    advertisementData: [String : Any],
                            rssi RSSI: NSNumber)

Let’s break down the above method’s parameters:

central: CBCentralManager

The central manager object that discovered the device while scanning.

peripheral: CBPeripheral

A CBPeripheral object representing the BLE peripheral that was discovered. We’ll go into more detail on this type in a later section.

advertisementData: [String: Any]

A dictionary representation of the data included in the detected advertisement packet. Core Bluetooth does a nice job of parsing and organizing this data for us with a set of built-in keys.
Advertisement Key Name & Associated Value Type
CBAdvertisementDataManufacturerDataKey
NSData
Custom data provided by peripheral manufacturers. Can be used by peripherals for many things, like storing a device serial number or other identifying information.
CBAdvertisementDataServiceDataKey
[CBUUID : NSData]
Dictionary with CBUUID keys representing services, and custom data associated with those services. This is usually the best place for peripherals to store custom identifying data for pre-connection use.
CBAdvertisementDataServiceUUIDsKey
[CBUUID]
An array of service UUIDs, usually reflecting one or more of the services contained in the device’s GATT table.
CBAdvertisementDataOverflowServiceUUIDsKey
[CBUUID]
An array of service UUIDs from the overflow area (scan response packet) of the advertisement data. For advertised services that did not fit in the main advertising packet.
CBAdvertisementDataTxPowerLevelKey
NSNumber
The transmitting power level of the peripheral if provided in the advertising packet.
CBAdvertisementDataIsConnectable
NSNumber
A Boolean value in NSNumber form (0 or 1) that is 1 if the peripheral is currently connectable.
CBAdvertisementDataSolicitedServiceUUIDsKey
[CBUUID]
An array of solicited service UUIDs. See discussion of solicited services in CBCentralManagerScanOptionSolicitedServiceUUIDKey section.

rssi: NSNumber

The relative signal quality in decibels of the peripheral at the time of the received advertisement packet. Because RSSI is a relative measure, the interpreted value by a central can vary by chipset. As returned by most iOS devices through Core Bluetooth, it generally ranges from -30 to -99, with -30 being the strongest.
// In main class
var discoveredPeripherals = [CBPeripheral]()
func startScan() {
    centralManager.scanForPeripherals(withServices: nil, options: nil)
}



// In CBCentralManagerDelegate class/extension
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    self.discoveredPeripherals.append(peripheral)
}

In the above example, we simply store discovered peripherals in an internal array, but doing this loses the RSSI and advertisement data returned in the delegate method call. It’s often useful to create a wrapper class or struct for the CBPeripheral object that includes storage for these items if you want to access them later, since CBPeripheral does not support this on its own.
Connecting and disconnecting
Connecting to a peripheral

Once you’ve obtained a reference to the desired CBPeripheral object, you may attempt to connect to it by simply calling the connect(_:options:) method on your central manager and passing in the peripheral. There are also a number of connection options that you can read about here, but we won’t go into them in this post.

On a successful connection, you’ll receive a centralManager(_:didConnect:) delegate call, or on connection failure, you’ll receive centralManager(_:didFailToConnect:error:), which includes both the peripheral and the specific error that occured.

You may call connect for a specific peripheral object that has gone out of range. If you do this, you’ll establish a “connection request” and iOS will wait indefinitely (unless Bluetooth is interrupted or the app is manually killed by the user) until it sees the device to make the connection and call the didConnect delegate method.
// In main class
var connectedPeripheral: CBPeripheral?
func connect(peripheral: CBPeripheral) {
    centralManager.connect(peripheral, options: nil)
}



// In CBCentralManagerDelegate class/extension
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    // Successfully connected. Store reference to peripheral if not already done.
    self.connectedPeripheral = peripheral
}

func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
    // Handle error
}

⚠️ Once the scan callback returns a CBPeripheral object, you must retain a strong reference to it in your code. If you simply call connect immediately from the didDiscover delegate method and let that function block complete without strongly storing the peripheral elsewhere, the peripheral object will be deallocated and any connection or pending connection broken. The central manager does not internally retain strong connections to its connected peripherals.

⚠️ Note that in iOS, the didConnect delegate method is called immediately after a basic connection is established, and before any pairing or bonding is attempted. To the disappointment of many iOS developers, Core Bluetooth gives no real public API-level insight or control over the bonding process of the peripheral, other than what you can infer and trigger through encrypted services and characteristics, which we’ll discuss later in this post.

⚠️ In Core Bluetooth, for a CBPeripheral object’s state to be .connected, it must be connected both at the iOS BLE level and at the app level. A peripheral may be connected to the iOS device through another app or because it contains a profile like HID that triggers automatic reconnection. However, you’ll still need to obtain a reference to the peripheral and call connect() from your app to be able to interact with it. See bonding/pairing discussion below for more information.
Identifying and referencing CBPeripherals

Another security-driven choice Apple made in Core Bluetooth that sets it apart from Android Bluetooth APIs (for better or for worse) was to obscure a BLE peripheral’s unique MAC address. It’s simply not possible to access this from Core Bluetooth unless it’s hidden elsewhere by the peripheral’s firmware, e.g., in custom advertisement data, the device name, or a characteristic. Instead, Apple assigns a unique UUID that’s meaningless outside of your app’s context, but that can be used to scan and initiate a connection to that particular device (see background processing section). Apple states explicitly that this UUID isn’t guaranteed to remain the same and should not be the only method for identifying a peripheral. Keeping that in mind, we have found in our experience that the Apple-assigned UUID does appear to remain pretty reliable over the long term, with the understandable exception of a user resetting network or other factory settings.

Other options for identifying peripherals during the advertising phase are by name or custom advertisement service data. As discussed above, advertisement data can include custom service UUIDs to identify a particular brand, or even custom data linked to those services in the advertisement packet to further identify a particular device or set of devices.
Disconnecting from a peripheral

To disconnect, simply call cancelPeripheralConnection(_:), or remove all strong references to the peripheral to implicitly call the cancel method.  You should receive a centralManager(_:didDisconnectPeripheral:error:) delegate call in response:
// In main class
func disconnect(peripheral: CBPeripheral) {
    centralManager.cancelPeripheralConnection(peripheral)
}



// In CBCentralManagerDelegate class/extension
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
    if let error = error {
        // Handle error
        return
    }
    // Successfully disconnected
}
iOS-initiated disconnection

iOS may disconnect after some interval of no communication from the peripheral (said to be 30 seconds, but behavior isn’t guaranteed). This is usually taken care of by the peripheral with some kind of heartbeat that may not even be visible at the iOS app layer.
Discovering services and characteristics

Once you’ve successfully connected to a peripheral, you may discover its services and then its characteristics. At this point in the process, we move from using CBCentralManager and CBCentralManagerDelegate methods to CBPeripheral and CBPeripheralDelegate methods. At this point, you’ll want to assign the peripheral object’s delegate property so you can receive those delegate callbacks:
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    self.connectedPeripheral = peripheral
    peripheral.delegate = self
}

(Again, for simplicity, we are using a single class to handle all delegate calls, but this isn’t the best design practice for larger codebases.)
Discovering services

When you’ve first discovered and connected to a CBPeripheral object, you’ll notice it has a services property of type [CBService]?. At the moment, it’ll be nil. You need to discover a peripheral’s services by simply calling discoverServices([CBUUID]?). You may optionally pass in an array of service UUIDs, which will restrict the services discovered. This can be handy for peripherals that contain a lot of services your app doesn’t care about, as it’s much more efficient (particularly in terms of time) to ignore these.

There’s also a similar method, discoverIncludedServices([CBUUID]?, for: CBService). A service may indicate other related services it “includes”, which doesn’t mean much other than that the peripheral firmware wants to indicate they’re somehow related. The second parameter should be a service that has already been discovered, and the first paramet

Leave a comment

Design a site like this with WordPress.com
Get started