Skip to Main Content
Ultimate Guide to Apple's Core Bluetooth library

The Ultimate Guide to Apple’s Core Bluetooth

Table of Contents
    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 nameAssociated Value TypeAssociated Value description
    CBAdvertisementDataManufacturerDataKeyNSData 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.
    CBAdvertisementDataTxPowerLevelKeyNSNumberThe transmitting power level of the peripheral if provided in the advertising packet.
    CBAdvertisementDataIsConnectableNSNumberA 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 parameter lets you optionally filter the returned services as described in the previous paragraph. We at Punch Through haven’t found much use for this feature of BLE, but it could be useful if there is a large GATT table and you perhaps want to stagger service discovery by groups and/or without explicitly listing all services of interest. This would, of course, require cooperation from the peripheral side to indicate included services.

    When services have been discovered, you’ll receive a CBPeripheralDelegate call peripheral(_:didDiscoverServices:), with an array of CBService objects representing the service UUIDs discovered out of the optionally provided array passed into the discover method (if the array was nil/empty, all services will be returned). You can also try unwrapping the services property of the CBPeripheral and notice it now contains an array of the services discovered so far.

    Discovering characteristics

    Characteristics are grouped under services. Once you’ve discovered a peripheral’s services, you can discover the characteristics for each of those services. Similar to the services property of CBPeripheral, you’ll notice CBService has a characteristics property of type [CBCharacteristic]? that, at first, is empty. 

    For each service, call discoverCharacteristics([CBUUID?], for: CBService) on the CBPeripheral object, optionally specifying specific characteristic UUIDs just as you did for services. You should receive a call to peripheral(_:didDiscoverCharacteristicsFor:error:) for each service for which you made the discovery call.

    Depending on your needs, you may find it useful to store references to the characteristics you’re interested in at the point of discovery, to avoid having to search through the array now populated in the characteristics property of each service. This will also make it easier to quickly identify which characteristic is being referred to whenever you receive certain CBPeripheralDelegate callbacks:

    // In main class 
    // Call after connecting to peripheral
    func discoverServices(peripheral: CBPeripheral) {
        peripheral.discoverServices(nil)
    }
     
    // Call after discovering services
    func discoverCharacteristics(peripheral: CBPeripheral) {
        guard let services = peripheral.services else {
            return
        }
        for service in services {
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }
    …
    …
    …
    // In CBPeripheralDelegate class/extension
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else {
            return
        }
        discoverCharacteristics(peripheral: peripheral)
    }
     
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        guard let characteristics = service.characteristics else {
            return
        }
        // Consider storing important characteristics internally for easy access and equivalency checks later.
        // From here, can read/write to characteristics or subscribe to notifications as desired.
    }

    Descriptors

    In Bluetooth, characteristic descriptors may optionally be provided alongside certain characteristics to provide more information about the value they hold. These could be, for example, a human-readable description string, or the intended data format for interpreting the value. In Core Bluetooth, these are represented by CBDescriptor objects. They function similarly to characteristics, in that they must first be discovered before they can be read. 

    For a given characteristic, simply call discoverDescriptors(for characteristic: CBCharacteristic) on the target peripheral, and wait for the asynchronous callback peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?). At this point, the descriptors property of the CBCharacteristic object should be non-nil and contain an array of CBDescriptor objects. 

    The different possible types of CBDescriptors are distinguished by their uuid property (of type CBUUID), which are predefined here in Core Bluetooth documentation.

    The value property of CBDescriptor will be nil until it’s explicitly read or written, using either the readValue(for descriptor: CBDescriptor) or the writeValue(_ data: Data, for descriptor: CBDescriptor) methods of CBPeripheral. These will be met with the callback methods 

    peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) 

    and 

    peripheral(_ peripheral: CBPeripheral, didWriteValueFor descriptor: CBDescriptor, error: Error?), respectively:

    // In main class
    func discoverDescriptors(peripheral: CBPeripheral, characteristic: CBCharacteristic) {
        peripheral.discoverDescriptors(for: characteristic)
    }
    …
    …
    … 
    // In CBPeripheralDelegate class/extension
    func peripheral(_ peripheral: CBPeripheral, didDiscoverDescriptorsFor characteristic: CBCharacteristic, error: Error?) {
        guard let descriptors = characteristic.descriptors else { return }
     
        // Get user description descriptor
        if let userDescriptionDescriptor = descriptors.first(where: {
            return $0.uuid.uuidString == CBUUIDCharacteristicUserDescriptionString
        }) {
            // Read user description for characteristic
            peripheral.readValue(for: userDescriptionDescriptor)
        }
    }
     
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) {
        // Get and print user description for a given characteristic
        if descriptor.uuid.uuidString == CBUUIDCharacteristicUserDescriptionString,
            let userDescription = descriptor.value as? String {
            print("Characterstic \(descriptor.characteristic.uuid.uuidString) is also known as \(userDescription)")
        }
    }

    ⚠️ In Core Bluetooth, you may not write the value of the client characteristic configuration descriptor (CBUUIDClientCharacteristicConfigurationString), which is used under the surface by iOS (and explicitly in Android BLE app development) to subscribe to or unsubscribe from notifications/indications on that characteristic. Instead, use the setNotifyValue(_:for:) method, as described in the next section.

    Subscribing to notifications and indications

    If a characteristic supports it (see section on Characteristic Properties), you may subscribe to notifications/indications by simply calling setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic). Notifications and indications, while functionally different at the BLE stack level, are not distinct in Core Bluetooth. If subscribed to a characteristic, you’ll get a call to peripheral(_:didUpdateValueFor:error:) for that characteristic any time its value changes and a notification or indication is sent from the peripheral. To unsubscribe, simply call setNotifyValue(false, for:characteristic). You will get a call to peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,  error: Error?) each time you change this setting. 

    You can check the notification subscription state at any time by checking the characteristic’s isNotifying property:

    // In main class
    func subscribeToNotifications(peripheral: CBPeripheral, characteristic: CBCharacteristic) {
        peripheral.setNotifyValue(true, for: characteristic)
     }
    …
    …
    …
    // In CBPeripheralDelegate class/extension
    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            // Handle error
            return
        }
        // Successfully subscribed to or unsubscribed from notifications/indications on a characteristic
    }

    Reading from a characteristic

    If the characteristic includes the “read” property, you can read its value by calling readValue(for:CBCharacteristic) on your CBPeripheral object. The value is returned through CBPeripheralDelegate’s peripheral(_:didUpdateValueFor:error:) method:

    // In main class
    func readValue(characteristic: CBCharacteristic) {
        self.connectedPeripheral?.readValue(for: characteristic)
    }
    … 
    …
    …
    // In CBPeripheralDelegate class/extension
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            // Handle error
            return 
        }
        guard let value = characteristic.value else {
            return
        }
        // Do something with data
    }

    Writing to a characteristic

    To write to a characteristic, call writeValue(_ data: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType) on the CBPeripheral object. The arguments to this method are, in order, the data to be written, the characteristic to be written to, and the write type.

    There are two possible write types: .withResponse and .withoutResponse. They correspond to what are known in BLE as write requests and write commands, respectively. 

    For a write request (.withResponse), the BLE layer will require the peripheral to send back an acknowledgement that the write request was received and completed successfully. At the Core Bluetooth layer, you’ll receive a call to peripheral(_:didUpdateValueFor:error:) upon completion or error of the request.

    For a write command (.withoutResponse), no acknowledgement will be sent upon receipt of the written value, and no delegate callback will occur, assuming the write was successful from an iOS viewpoint. That is to say, iOS was able to successfully carry out the write operation with internal issues. 

    Write requests are more robust because you are guaranteed delivery, or else an explicit error. They are generally preferred for one-off write operations. If, however, you’re sending large amounts of bulk data over multiple successive write operations, waiting for that acknowledgement on each operation can significantly slow down the overall process. Instead, consider building in some level of packet tracking to your FW-mobile protocol if applicable.

    // In main class
    func write(value: Data, characteristic: CBCharacteristic) {
        self.connectedPeripheral?.writeValue(value, for: characteristic, type: .withResponse)
        // OR
       self.connectedPeripheral?.writeValue(value, for: characteristic, type: .withoutResponse)
     }
    …
    …
    …
    // In CBPeripheralDelegate class/extension
    // Only called if write type was .withResponse
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            // Handle error
            return
        }
        // Successfully wrote value to characteristic
    }

    Maximizing back-to-back write commands: how fast is too fast?

    Although the nature of write commands (without response) is that you cannot be guaranteed packet delivery on the other side, you still want to make sure you are sending your responses at a reasonably speedy rate that doesn’t overwhelm iOS’s internally dedicated queueing buffers. Until iOS 11, this was simply guesswork, but they’ve since introduced an addition to the CBPeripheral and CBPeripheralDelegate APIs to mitigate this:

    CBPeripheral property canSendWriteWithoutResponse: Bool

    and 

    CBPeripheralDelegate method peripheralIsReady(toSendWriteWithoutResponse: CBPeripheral)

    Before sending a write command, you should always check canSendWriteWithoutResponse and, if it’s false, await a call to peripheralIsReady(toSendWriteWithoutResponse:) before proceeding. Note that we have observed varying degrees of reliability in circumstances under which canSendWriteWithoutResponse is set to true and the delegate method actually called, particularly in the case of restored peripherals, so you may not want to rely solely on this API to allow write commands to occur. You can also implement writes without response on a timer, though you will need to err on the conservative side, depending on the size of your data.

    // In main class
    func write(value: Data, characteristic: CBCharacteristic) {
        if connectedPeripheral?.canSendWriteWithoutResponse {
            self.connectedPeripheral?.writeValue(value, for: characteristic, type: .withoutResponse)
        }
    }
    …
    …
    …
    // In CBPeripheralDelegate class/extension
    func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
        // Called when peripheral is ready to send write without response again.
        // Write some value to some target characteristic.
        write(value: someValue, characteristic: someCharacteristic)
    }

    Maximum write length

    As of iOS 9, Core Bluetooth provides a handy method to determine the max length in bytes for a given characteristic, which may vary for writes with response vs. without:

    maximumWriteValueLength(for type: CBCharacteristicWriteType) -> Int

    The actual maximum write length your communication can support depends on the respective BLE stacks of the central and peripheral devices. This method simply returns the maximum write length the iOS device will support for that operation. Behavior when attempting a write that exceeds the known limit on one or both sides is undefined.

    Pairing and bonding

    As of iOS 9, pairing (exchange of temporary keys for a one-time secure connection) isn’t permitted without bonding (additional secure exchange and storing of long-term keys for future connections after pairing has occurred). 

    Depending on how the peripheral’s BLE security settings are configured, the pairing/bonding process can be triggered either at the point of connection or by attempting to read, write, or subscribe to an encrypted characteristic. Apple’s Accessory Design Guidelines actually encourage BLE device manufacturers to use the insufficient authentication method (i.e., reading from an encrypted characteristic) to trigger bonding, but our research has shown that while this is generally successful for Android devices as well, the method that works most reliably tends to vary by manufacturer and model.

    Again, depending on the peripheral’s configuration, the UI flow during the pairing process will be one of the following:

    • The user is presented with an alert that prompts them to enter a PIN. The user must enter the correct PIN for pairing/bonding to proceed.
    • The user is presented with a simple yes/no dialog prompting them to give permission to allow pairing to continue. 
    • The user is presented with no dialog, and pairing/bonding completes under the surface.

    Much to the chagrin of many iOS developers, Core Bluetooth provides no insight into the pairing and bonding process, nor the peripheral’s paired/bonded state. The API only informs the developer whether the device is connected. 

    In some cases, like for HID devices, once a peripheral is bonded, iOS will automatically connect to it whenever it sees the peripheral advertising. This behavior occurs independently of any app, and a peripheral can be connected to an iOS device, but not connected to the app that originally established the bond. If a bonded peripheral disconnects from an iOS device and then reconnects at the iOS level, the app will need to retrieve the peripheral object (retrieveConnectedPeripherals(with[Services/Identifiers]:) and explicitly connect again through a CBCentralManager to establish an app-level connection. To retrieve your device with this method, you must specify either the Apple-assigned device identifier from the previously-returned CBPeripheral object or at least one service it contains.

    ⚠️ iOS does not allow developer apps to clear a peripheral’s bonding status from the cache. To clear a bond, the user must go to the Bluetooth section of iOS Settings and explicitly “Forget” the peripheral. It may be helpful to include this information in your app’s UI if it’ll affect user experience, as most users will not know this.

    Core Bluetooth errors

    Nearly all of the methods in the CBCentralManagerDelegate and CBPeripheralDelegate protocols include an Error? type parameter, that’s non-nil if an error has occurred. You can expect these errors to either be of type CBError or CBATTError. Beyond that, Apple isn’t explicit about which methods might return which errors specifically, or even which type they might be, so the full range of possible behaviors surrounding individual Core Bluetooth errors remains somewhat unknown.

    Generally, CBATTErrors are returned when something goes wrong at the ATT layer. This includes access issues to encrypted characteristics, unsupported operations (e.g., a write operation to a read-only characteristic), and a host of other errors that generally only apply if you’re using the CBPeripheralManager API to set up your iOS device as a peripheral, which is usually where the ATT server lives on most Bluetooth devices.

    Thanks for reading!

    Whether you’re a seasoned BLE developer or just getting started, we hope you found this article useful and informative. While we covered just the basics of Core Bluetooth from a central role perspective, there’s so much more to discuss! Stay tuned for future posts on Core Bluetooth and BLE on iOS in general, and in the meantime, check out some of our other posts on iOS development:

    How to Handle iOS 13’s new Bluetooth Permissions
    Leveraging Background Bluetooth for a Great User Experience

    Interested in Learning More?

    iOS development is only one piece of the Punch Through puzzle. Learn about who we are, what we do, and how we can help you.