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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Core Bluetooth Tutorial for iOS: Heart Rate Monitor
30 mins
- Centrals and Peripherals
- Advertising Packets
- Services and Characteristics
- Getting Started
- Preparing for Core Bluetooth
- Scanning for Peripherals
- Scanning for Peripherals with Specific Services
- Connecting to a Peripheral
- Discovering a Peripheral’s Services
- Discovering a Service’s Characteristics
- Checking a Characteristic’s Properties
- Obtaining the Body Sensor Location
- Interpreting the Binary Data of a Characteristic’s Value
- Obtaining the Heart Rate Measurement
- Where to Go From Here?
Discovering a Service’s Characteristics
The heart rate measurement is a characteristic of the heart rate service. Add the following statement right below the print(service)
line in peripheral(_:didDiscoverServices:)
:
print(service.characteristics ?? "characteristics are nil")
Build and run to see what is printed to the console:
characteristics are nil
To obtain the characteristics of a service, you’ll need to explicitly request the discovery of the service’s characteristics:
Replace the print
statement you just added with the following:
peripheral.discoverCharacteristics(nil, for: service)
Build and run, and check the console for some API MISUSE
guidance on what should be done next:
API MISUSE: Discovering characteristics on peripheral <CBPeripheral: 0x1c0119110, ...> while delegate is either nil or does not implement peripheral:didDiscoverCharacteristicsForService:error:
You need to implement peripheral(_:didDiscoverCharacteristicsFor:error:)
. Add the following after peripheral(_:didDiscoverServices:)
to print out the characteristic objects:
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard let characteristics = service.characteristics else { return }
for characteristic in characteristics {
print(characteristic)
}
}
Build and run. You should see the following printed to the console:
<CBCharacteristic: 0x1c00b0920, UUID = 2A37, properties = 0x10, value = (null), notifying = NO>
<CBCharacteristic: 0x1c00af300, UUID = 2A38, properties = 0x2, value = (null), notifying = NO>
This shows you that the heart rate service has two characteristics. If you are using a sensor other than the Polar H7, you may see additional characteristics. One with UUID 2A37, and the other with 2A38. Which one of these is the heart rate measurement characteristic? You can find out by searching for both numbers in the characteristics section of the Bluetooth specification.
On the Bluetooth specification page, you’ll see that 2A37 represents Heart Rate Measurement and 2A38 represents Body Sensor Location.
Add constants for these at the top of the file, below the line for heartRateServiceCBUUID
. Adding the 0x prefix to the UUID is optional:
let heartRateMeasurementCharacteristicCBUUID = CBUUID(string: "2A37")
let bodySensorLocationCharacteristicCBUUID = CBUUID(string: "2A38")
Each characteristic has a property called properties
of type CBCharacteristicProperties
and is an OptionSet
. You can view the different types of properties in the documentation for CBCharacteristicProperties, but here you’ll only focus on two: .read
and .notify
. You’ll need to obtain each characteristic’s value in a different manner.
Checking a Characteristic’s Properties
And the following code in peripheral(_:didDiscoverCharacteristicsFor:error:)
after the print(characteristic)
to see the characteristic’s properties:
if characteristic.properties.contains(.read) {
print("\(characteristic.uuid): properties contains .read")
}
if characteristic.properties.contains(.notify) {
print("\(characteristic.uuid): properties contains .notify")
}
Build and run. In the console you’ll see:
2A37: properties contains .notify
2A38: properties contains .read
The 2A37 characteristic — the heart rate measurement — will notify you when its value updates, so you’ll need to subscribe to receive updates from it. The 2A38 characteristic — the body sensor location — lets you read from it directly…although not quite that directly. You’ll see what I mean in the next section.
Obtaining the Body Sensor Location
Since getting the body sensor location is easier than getting the heart rate, you’ll do that first.
In the code you just added, after print("\(characteristic.uuid): properties contains .read")
, add the following:
peripheral.readValue(for: characteristic)
So where is the value read to? Build and run for some further guidance from the Xcode console:
API MISUSE: Reading characteristic value for peripheral <CBPeripheral: 0x1c410b760, ...> while delegate is either nil or does not implement peripheral:didUpdateValueForCharacteristic:error:
The Core Bluetooth framework is telling you that you’ve asked to read a characteristic’s value, but haven’t implemented peripheral(_:didUpdateValueFor:error:)
. At first glance, this seems like a method that you’d need to implement only for characteristics that would notify you of an update, such as the heart rate. However, you also need to implement it for values that you read. The read operation is asynchronous: You request a read, and are then notified when the value has been read.
Add the method to the CBPeripheralDelegate
extension:
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
switch characteristic.uuid {
case bodySensorLocationCharacteristicCBUUID:
print(characteristic.value ?? "no value")
default:
print("Unhandled Characteristic UUID: \(characteristic.uuid)")
}
}
Build and run; you should see a “1 bytes” message printed to the console, which is the type of message you’d see when you print a Data
object directly.
Interpreting the Binary Data of a Characteristic’s Value
To understand how to interpret the data from a characteristic, you have to refer to the Bluetooth specification for the characteristic. Click on the Body Sensor Location link on the Bluetooth characteristics page which will take you to the following page:
The specification shows you that Body Sensor Location is represented by an 8-bit value, so there are 255 possibilities, and only 0 – 6 are used at present. Based on the specification, add the following helper method to the end of the CBPeripheralDelegate
extension:
private func bodyLocation(from characteristic: CBCharacteristic) -> String {
guard let characteristicData = characteristic.value,
let byte = characteristicData.first else { return "Error" }
switch byte {
case 0: return "Other"
case 1: return "Chest"
case 2: return "Wrist"
case 3: return "Finger"
case 4: return "Hand"
case 5: return "Ear Lobe"
case 6: return "Foot"
default:
return "Reserved for future use"
}
}
Since the specification indicates the data consists of a single byte, you can call first
on a Data
object to get its first byte.
Replace peripheral(_:didUpdateValueFor:error:)
with the following:
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
switch characteristic.uuid {
case bodySensorLocationCharacteristicCBUUID:
let bodySensorLocation = bodyLocation(from: characteristic)
bodySensorLocationLabel.text = bodySensorLocation
default:
print("Unhandled Characteristic UUID: \(characteristic.uuid)")
}
}
This uses your new helper function to update the label on the UI. Build and run, and you’ll see the body sensor location displayed:
Obtaining the Heart Rate Measurement
Finally, the moment you’ve been waiting for!
The heart rate measurement characteristic’s properties
contained .notify
, so you would need to subscribe to receive updates from it. The method you’ll need to call looks a bit weird: it’s setNotifyValue(_:for:)
.
Add the following to peripheral(_:didDiscoverCharacteristicsFor:error:)
after print("\(characteristic.uuid): properties contains .notify")
:
peripheral.setNotifyValue(true, for: characteristic)
Build and run, and you’ll see a number of “Unhandled Characteristic UUID: 2A37” messages printed out:
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Unhandled Characteristic UUID: 2A37
Congratulations! Within that characteristic’s value is your heart rate. The specification for the heart rate measurement is a bit more complex than that for the body sensor location. Take a look: heart rate measurement characteristic:
The first byte contains a number of flags, and the first bit within the first byte indicates if the heart rate measurement is an 8-bit value or a 16-bit value. If the first bit is a 0 then the heart rate value format is UINT8, i.e. an 8-bit number, and if the first byte is set to 1, the heart rate value format is UINT16, i.e. a 16-bit number.
The reason for this is that in most cases, your heart rate hopefully won’t go above 255 beats per minute, which can be represented in 8 bits. In the exceptional case that your heart rate does go over 255 bpm, then you’d need an additional byte to represent the heart rate. Although you’d then be covered for up to 65,535 bpm!
So now you can determine if the heart rate is represented by one or two bytes. The first byte is reserved for various flags, so the heart rate will be found in either the second byte or the second and third bytes. You can tell that the flags are contained in one byte since the Format column shows 8bit for it.
Note that the very last column, with the title Requires, shows C1 when the value of the bit is 0, and a C2 when the value of the bit is 1.
Scroll down to the C1 and C2 fields, which you’ll see immediately after the specification for the first byte:
Add the following helper method to the end of the CBPeripheralDelegate
extension to obtain the heart rate value from the characteristic:
private func heartRate(from characteristic: CBCharacteristic) -> Int {
guard let characteristicData = characteristic.value else { return -1 }
let byteArray = [UInt8](characteristicData)
let firstBitValue = byteArray[0] & 0x01
if firstBitValue == 0 {
// Heart Rate Value Format is in the 2nd byte
return Int(byteArray[1])
} else {
// Heart Rate Value Format is in the 2nd and 3rd bytes
return (Int(byteArray[1]) << 8) + Int(byteArray[2])
}
}
From characteristic.value
, which is an object of type Data
, you create an array of bytes. Depending on the value of the first bit in the first byte, you either look at the second byte, i.e. byteArray[1]
, or you determine what the value would be by combining the second and third bytes. The second byte is shifted by 8 bits, which is equivalent to multiplying by 256. So the value in this case is (second byte value * 256) + (third byte value).
Finally, add another case
statement above the default
case in peripheral(_:didUpdateValueFor:error:)
to read the heart rate from the characteristic.
case heartRateMeasurementCharacteristicCBUUID:
let bpm = heartRate(from: characteristic)
onHeartRateReceived(bpm)
onHeartRateReceived(_:)
updates the UI with your heart rate.
Build and run your app, and you should finally see your heart rate appear. Try some light exercise and watch your heart rate rise!