Core Bluetooth Tutorial for iOS: Heart Rate Monitor

In this Core Bluetooth tutorial, you’ll learn how to discover, connect to, and retrieve data from compatible devices like a chest-worn heart rate sensor. By Jawwad Ahmad.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Scanning for Peripherals

For many of the methods you’ll be adding, instead of giving you the method name outright, I’ll give you a hint on how to find the method that you would need. In this case, you want to see if there is a method on centralManager with which you can scan.

On the line after initializing centralManager, start typing centralManager.scan and see if you can find a method you can use:

The scanForPeripherals(withServices: [CBUUID]?, options: [String: Any]?) method looks promising. Select it, use nil for the withServices: parameter and remove the options: parameter since you won’t be using it. You should end up with the following code:

centralManager.scanForPeripherals(withServices: nil)

Build and run. Take a look at the console and note the API MISUSE message:

API MISUSE: <CBCentralManager: 0x1c4462180> can only accept this command while in the powered on state

Well, that certainly makes sense right? You’ll want to scan after central.state has been set to .poweredOn.

Move the scanForPeripherals line out of viewDidLoad() and into centralManagerDidUpdateState(_:), right under the .poweredOn case. You should now have the following for the .poweredOn case:

case .poweredOn:
  print("central.state is .poweredOn")
  centralManager.scanForPeripherals(withServices: nil)
}

Build and run, and then check the console. The API MISUSE message is no longer there. Great! But has it found the heart rate sensor?

It probably has; you simply need to implement a delegate method to confirm that it has found the peripheral. In Bluetooth-speak, finding a peripheral is known as discovering, so the delegate method you’ll want to use will have the word discover in it.

Below the end of the centralManagerDidUpdateState(_:) method, start typing the word discover. The method is too long to read fully, but the method starting with centralManager will be the correct one:

Select that method and replace the code placeholder with print(peripheral).

You should now have the following:

func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
                    advertisementData: [String: Any], rssi RSSI: NSNumber) {
  print(peripheral)
}

Build and run; you should see a variety of Bluetooth devices depending on how many gadgets you have in your vicinity:

<CBPeripheral: 0x1c4105fa0, identifier = D69A9892-...21E4, name = Your Computer Name, state = disconnected>
<CBPeripheral: 0x1c010a710, identifier = CBE94B09-...0C8A, name = Tile, state = disconnected>
<CBPeripheral: 0x1c010ab00, identifier = FCA1F687-...DC19, name = Your Apple Watch, state = disconnected>
<CBPeripheral: 0x1c010ab00, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>

One of them should be your heart rate monitor, as long as you are wearing it and have a valid heart rate.

Scanning for Peripherals with Specific Services

Wouldn’t it be better if you could only scan for heart rate monitors, since that is the only kind of peripheral you are currently interested in? In Bluetooth-speak, you only want to scan for peripherals that provide the Heart Rate service. To do that, you’ll need the UUID for the Heart Rate service. Search for heart rate in the list of services on the Bluetooth services specification page and note the UUID for it; 0x180D.

From the UUID, you’ll create a CBUUID object and pass it to scanForPeripherals(withServices:), which actually takes an array. So, in this case, it will be an array with a single CBUUID object, since you’re only interested in the heart rate service.

Add the following to the top of the file, right below the import statements:

let heartRateServiceCBUUID = CBUUID(string: "0x180D")

Update the scanForPeripherals(withServices: nil) line to the following:

centralManager.scanForPeripherals(withServices: [heartRateServiceCBUUID])

Build and run, and you should now only see your heart rate sensor being discovered:

<CBPeripheral: 0x1c0117220, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>
<CBPeripheral: 0x1c0117190, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>

Next you’ll store a reference to the heart rate peripheral and then can stop scanning for further peripherals.

Add a heartRatePeripheral instance variable of type CBPeripheral at the top, right after the centralManager variable:

var heartRatePeripheral: CBPeripheral!

Once the peripheral is found, store a reference to it and stop scanning. In centralManager(_:didDiscover:advertisementData:rssi:), add the following after print(peripheral) :

heartRatePeripheral = peripheral
centralManager.stopScan()

Build and run; you should now see the peripheral printed just once.

<CBPeripheral: 0x1c010ccc0, identifier = BB8A7450-...A69B, name = Polar H7 DCB69F17, state = disconnected>

Connecting to a Peripheral

To obtain data from a peripheral you’ll need to connect to it. Right below centralManager.stopScan(), start typing centralManager.connect and you should see connect(peripheral: CBPeripheral, options: [String: Any]?) appear:

Select it, use heartRatePeripheral for the first parameter and delete the options: parameter so that you end up with the following:

centralManager.connect(heartRatePeripheral)

Great! Not only have you discovered your heart rate sensor, but you have connected to it as well! But how can you confirm that you are actually connected? There must be a delegate method for this with the word connect in it. Right after the centralManager(_:didDiscover:advertisementData:rssi:) delegate method, type connect and select centralManager(_:didConnect:):

Replace the code placeholder as follows:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  print("Connected!")
}

Build and run; you should see Connected! printed to the console confirming that you are indeed connected to it.

Connected!

Discovering a Peripheral’s Services

Now that you’re connected, the next step is to discover the services of the peripheral. Yes, even though you specifically requested a peripheral with the heart rate service and you know that this particular peripheral supports this, you still need to discover the service to use it.

After connecting, call discoverServices(nil) on the peripheral to discover its services:

heartRatePeripheral.discoverServices(nil)

You can pass in UUIDs for the services here, but for now you’ll discover all available services to see what else the heart rate monitor can do.

Build and run and note the two API MISUSE messages in the console:

API MISUSE: Discovering services for peripheral <CBPeripheral: 0x1c010f6f0, ...> while delegate is either nil or does not implement peripheral:didDiscoverServices:
API MISUSE: <CBPeripheral: 0x1c010f6f0, ...> can only accept commands while in the connected state

The second message indicates that the peripheral can only accept commands while it’s connected. The issue is that you initiated a connection to the peripheral, but didn’t wait for it to finish connecting before you called discoverServices(_:)!

Move heartRatePeripheral.discoverServices(nil) into centralManager(_:didConnect:) right below print("Connected!"). centralManager(_:didConnect:) should now look like this:

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
  print("Connected!")
  heartRatePeripheral.discoverServices(nil)
}

Build and run. Now you should only see the other API MISUSE message which is:

API MISUSE: Discovering services for peripheral <CBPeripheral: ...> while delegate is either nil or does not implement peripheral:didDiscoverServices:

The Core Bluetooth framework is indicating that you’ve asked to discover services, but you haven’t implemented the peripheral(_:didDiscoverServices:) delegate method.

The name of the method tells you that this is a delegate method for the peripheral, so you’ll need to conform to CBPeripheralDelegate to implement it.

Add the following extension to the end of the file:

extension HRMViewController: CBPeripheralDelegate {

}

Xcode doesn’t offer to add method stubs for this since there are no required delegate methods.

Within the extension, type discover and select peripheral(_:didDiscoverServices:):

Note that this method doesn’t provide you a list of discovered services, only that one or more services has been discovered by the peripheral. This is because the peripheral object has a property which gives you a list of services. Add the following code to the newly added method:

guard let services = peripheral.services else { return }

for service in services {
  print(service)
}

Build and run, and check the console. You won’t see anything printed and, in fact, you’ll still see the API MISUSE method. Can you guess why?

It’s because you haven’t yet pointed heartRatePeripheral at its delegate. Add the following after heartRatePeripheral = peripheral in centralManager(_:didDiscover:advertisementData:rssi:):

heartRatePeripheral.delegate = self

Build and run, and you’ll see the peripheral’s services printed to the console:

<CBService: 0x1c046f280, isPrimary = YES, UUID = Heart Rate>
<CBService: 0x1c046f5c0, isPrimary = YES, UUID = Device Information>
<CBService: 0x1c046f600, isPrimary = YES, UUID = Battery>
<CBService: 0x1c046f680, isPrimary = YES, UUID = 6217FF4B-FB31-1140-AD5A-A45545D7ECF3>

To get just the services you’re interested in, you can pass the CBUUIDs of those services into discoverServices(_:). Since you only need the Heart Rate service, update the discoverServices(nil) call in centralManager(_:didConnect:) as follows:

heartRatePeripheral.discoverServices([heartRateServiceCBUUID])

Build and run, and you should only see the Heart Rate service printed to the console.

<CBService: 0x1c046f280, isPrimary = YES, UUID = Heart Rate>