Let’s have some fun and get to understand two concepts. The first is bluetooth devices and the second is live activities. We’ll be walking through some of the interesting parts of a demo app which is available on GitHub and seeing just what makes bluetooth devices behave and send data.

What is Bluetooth?

Bluetooth is a wireless communication method defined by the Bluetooth SIG and is a great way for talking to peripherals that are mounted on a bike such as power meters and cadence sensors. We’ll be talking to a heart rate monitor in this example.

On apple platforms, the framework used is CoreBluetooth. As well as providing abstractions around Peripherals, Services, and Characteristics it uses a Central Manager for discovering all of the peripherals that are around.

To start making use of bluetooth peripherals, you need to have an instance of CBCentralManager and a class (yeah, needs to be an NSObject subclass) that conforms to the CBCentralManagerDelegate.

Discovering bluetooth devices

Before we start looking for peripherals, we want to make sure that the required hardware is available for use. Your app will be told about the state of the hardware via the delegate method centralManagerDidUpdateState(_:).

  public func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch central.state {
    case .poweredOn:
      startScanningForPeripherals()
    default: break
    }
  }

When the manager is asked to discover services we need to tell it what services it should be looking for. If we pass nil to scanForPeripherals(withServices: options:) we will see all peripherals. To limit these to just devices that support heart rate measurements, we use the service UUID of 180D that is defined by the heart rate service.

  public func startScanningForPeripherals() {
    guard manager.state == .poweredOn else {
      return
    }

    manager.scanForPeripherals(withServices: [CBUUID(string: "180D")])
  }

Now that we have asked the manager to scan for peripherals, we need to listen for what is found. As this happens at the discretion of the manager, we make use of the delegate function centralManager(_:didDiscover:advertisementData:rssi:) to perform actions on the peripherals that we find.

  public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    guard peripherals.contains(where: { $0.id == peripheral.identifier }) == false else {
      return
    }

    peripherals.append(peripheral)
  }

As the peripherals are discovered, we want to be able to connect to them so we can start receiving values from them. To do this, we ask the manager to connect to a device. We then get the details of the connected peripheral in the delegate function centralManager(_:didConnect:).

  public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    details = peripheral
  }

Once the peripheral is connected, we then want to discover the services that are available on it. Doing this is requires that we first set the delegate property on the peripheral to a type that conforms to CBPeripheralDelegate which again needs to be a NSObject subclass. Once we have set the delegate we then need to call discoverServices(_:) on the peripheral using the 180D UUID for heart rate monitors.

    peripheral.discoverServices([CBUUID(string: "180D")])

The delegate method peripheral(_:didDiscoverServices:) will then be called with the peripheral having its services property populated. We can then ask the peripheral to discover the characteristics for each of the services. When scanning for characteristics, we use the function on the peripheral instance passing it an instance of CBService

  func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
    peripheral.services?.forEach { service in
      peripheral.discoverCharacteristics(nil, for: service)
    }
  }

We now have the required data and connection to start receiving values for our peripherals.

Receiving data

As you can guess, our app is told about the discovered characteristics in the delegate method peripheral(:didDiscoverCharacteristicsFor:error:)](https://developer.apple.com/documentation/corebluetooth/cbperipheraldelegate/1518821-peripheral). There are two types of values which are available. We can either get a value if it is supported and this is great for static values on a device. We can also listen for notifications of when a value changes. This is what we want for heart rate monitors and other characteristics whose values change over time. To listen for a value update notification, we call the [setNotifyValue(:for:) function on the peripheral.

  func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    guard service.uuid.uuidString == "180D" else {
      return
    }

    guard let measurementCharacteristic = service.characteristics?.first(where: { $0.uuid.uuidString == "2A37" }) else {
      return
    }

    peripheral.setNotifyValue(true, for: measurementCharacteristic)
  }

The UUID for the characteristic representing heart rate measurements is 2A37 and is defined as part of the heart rate service specification. The format of the bytes we get in the peripheral(_:didUpdateValueFor:error:) delegate function are defined in the same specification. Being familiar with this dance is important so that we can parse the values.

  func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    guard characteristic.uuid.uuidString == "2A37" else {
      return
    }

    guard let data = characteristic.value else {
      return
    }

    heartRate = formatter.valueForData(data)
  }

The formatter is a helper type defined to extract the appropriate values from the raw Data instance that we get in the delegate function. This is defined as the following:

  public func valueForData(_ data: Data) -> Int? {
    let byteArray = [UInt8](data)
    let firstBitValue = byteArray[0] & 0x01

    if firstBitValue == 0 {
      return Int(byteArray[1])
    }

    return (Int(byteArray[1]) << 8) + Int(byteArray[2])
  }

Live Activities

A lot of fun comes when we background the app and can show the current heart rate as either a live activity or using the dynamic island on supported devices.

Handling background updates

To allow bluetooth to function in the background, we need to add some values to the info.plist file. These values are:

If you’ve used background fetches before, you would think that we need to handle background tasks. This isn’t the case with CoreBluetooth and your CBCentralManagerDelegate conforming type will get called. When those delegate methods get called, it is possible to update the live activity.

Sending data to the live activity

Both Live Activities and the Dynamic Island exist as widgets and make use of configurations to update the data that gets shown. These configurations are value types that conform to the ActivityAttributes protocol like the following:

public struct WidgetsAttributes: ActivityAttributes {
  public typealias Content = ContentState

  public struct ContentState: Codable, Hashable {
    // Dynamic stateful properties about your activity go here!
    public var heartRate: Int

    public init(heartRate: Int) {
      self.heartRate = heartRate
    }
  }

  public init() {}
}

There are two parts to the attributes being the data which doesn’t change regularly and then the frequently updated content. For showing the heart rate values we want to be setting the current value in the content. We do this when we receive a value in the CBPeripheralDelegate.peripheral(_:didUpdateValueFor:error:) function. We also want to check that live activities are supported which we do using the areActivitiesEnabled property on ActivityAuthorizationInfo class. When updating the data for a live activity, we create a new content and then ask the activity to update using the content.

    if ActivityAuthorizationInfo().areActivitiesEnabled {
      let content = ActivityContent(state: WidgetsAttributes.ContentState(heartRate: heartRate ?? 0), staleDate: nil)

      if activity == nil {
        do
        {
          let attributes = WidgetsAttributes()
          activity = try Activity.request(attributes: attributes, content: content)
        } catch {

        }
      } else {
        Task {
          await activity?.update(content)
        }
      }
    }

When we are finished displaying the heart rate, we want to end the activity so that it’s not being displayed any longer.

    Task {
      await activity?.end(dismissalPolicy: .immediate)
    }