How to Discover Services and Characteristics on iOS Core Bluetooth

Pt 13 Team Collaboration Cropped

You have a connected peripheral and nothing is happening. The connection succeeded, but the device is just sitting there.

That’s because Core Bluetooth doesn’t automatically expose a peripheral’s services and characteristics after connecting. You have to explicitly ask for them. And the work for that lives on a different side of Core Bluetooth than the connection setup you just did, which is where most of the small gotchas come from.

Here’s how to set it up end to end, from assigning the peripheral’s delegate through the didDiscoverCharacteristics callback.

If you still need scanning or connection in place first, start with the iOS Core Bluetooth Ultimate Guide.


Assigning the CBPeripheralDelegate

Up to this point in your Core Bluetooth implementation, you have been working with CBCentralManager and CBCentralManagerDelegate. Service and characteristic discovery is where that changes. From here, you will be working with CBPeripheral and CBPeripheralDelegate methods instead.

The first thing to do when a connection is established is assign the peripheral’s delegate so you can receive the callbacks that follow. Do this inside your centralManager(_:didConnect:) implementation before making any discovery calls:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    self.connectedPeripheral = peripheral
    peripheral.delegate = self
}

If your CBPeripheralDelegate callbacks are not firing, this is the most common reason. The delegate must be assigned inside didConnect before any discovery calls are made.


Discovering Services

When you first get a connected CBPeripheral object, its services property is nil. You need to explicitly ask Core Bluetooth to discover them by calling discoverServices([CBUUID]?) on the peripheral.

The method takes an optional array of CBUUID values. If you pass in an optional array of service UUIDs, Core Bluetooth will restrict discovery to only those services. This is more efficient than discovering everything, particularly for peripherals with large GATT tables that contain services your app has no use for. Filtering here saves time and overhead. If you pass nil, all services will be discovered.

When discovery completes, you will receive a call to peripheral(_:didDiscoverServices:). At that point, the services property of the CBPeripheral object will be populated and you can work through what was found:

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    guard let services = peripheral.services else {
        return
    }
    discoverCharacteristics(peripheral: peripheral)
}

discoverIncludedServices(_:for:) is a related method that lets you discover services a given service explicitly indicates it is related to. We have not found much practical use for it in most implementations, but it could be useful for navigating a large GATT table where the peripheral firmware has grouped related services together.


Discovering Characteristics

Characteristics are grouped under services. Once services have been discovered, call discoverCharacteristics([CBUUID]?, for: CBService) on the peripheral for each service you want to explore. As with service discovery, you can optionally pass in a filtered array of characteristic UUIDs or nil to discover all characteristics under that service.

You will receive a separate peripheral(_:didDiscoverCharacteristicsFor:error:) callback for each service on which you made the discovery call:

func discoverServices(peripheral: CBPeripheral) {
    peripheral.discoverServices(nil)
}

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, you can read, write, or subscribe
    // to notifications as needed.
}

One practical note on storing characteristics: once didDiscoverCharacteristicsFor fires, the characteristics property of each CBService is populated. Rather than searching through that array every time you need a specific characteristic later, it is worth storing references to the ones you care about at this point in the flow. This makes it considerably easier to identify which characteristic is being referred to when subsequent CBPeripheralDelegate callbacks come in.


Discovering Descriptors with discoverDescriptors

Descriptors are optional metadata that can accompany certain characteristics. They might contain a human-readable description of the characteristic’s value or information about its intended data format. In Core Bluetooth they are represented by CBDescriptor objects and, like services and characteristics, they must be discovered before they can be read.

For a given characteristic, call discoverDescriptors(for: CBCharacteristic) on the peripheral and wait for the peripheral(_:didDiscoverDescriptorsFor:error:) callback. At that point, the descriptors property of the CBCharacteristic will contain an array of CBDescriptor objects:

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)
    }
}

One important constraint to be aware of: Core Bluetooth does not allow you to write the value of the client characteristic configuration descriptor directly. This descriptor is what controls notification and indication subscriptions at the BLE layer. In Core Bluetooth, that is handled through setNotifyValue(_:for:) instead. Attempting to write the descriptor directly will not work as expected.


Where to Go Next

With services and characteristics discovered, you can start interacting with them. Reading characteristic values, writing to them, and subscribing to notifications and indications are all covered in different standalone sections in our Ultimate Guide to iOS Core Bluetooth. Pick the one you need next:

Share:

Punch Through
Punch Through
We’re a team of engineers who obsess over making connected things actually work — reliably, securely, and without the handwaving. From BLE to backend, we build the software and systems behind connected medical devices and custom connected products that can’t afford to fail.

Subscribe to stay up-to-date with our latest articles and resources.