Development
January 10, 2018

Introduction to Bluetooth LE on iOS: Mi Band 2 Case Study

Mikołaj Chmielewski
iOS Developer

The market of wearable devices is growing fast and thus the knowledge of the Bluetooth Low Energy protocol, which is currently used for most of the short-ranged inter-device communication, is becoming essential for a mobile apps developer.

Recently, I got my hands on Xiaomi Mi Band 2 and, although the device has a dedicated app — Mi Fit, I couldn’t resist the urge to create my own one. In this article, I would like to walk you through the basics of creating it. Code snippets, which I’ll show you here, are simplified, but you can check out my full project here. Let’s get started!

Setup

When you want to do anything Bluetooth-related on iOS — CoreBluetooth framework is your best friend. Its central element is the CBCentralManager class, which is your entry point to discovering nearby peripherals — that is — Bluetooth devices that can be connected to an iOS device.

import CoreBluetooth

class MiService: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    lazy var manager = CBCentralManager(delegate: self, queue: DispatchQueue.main, options: nil)
}

Note: MiService class will serve as a container for all Bluetooth-related logic and all methods and properties listed in snippets below should be added to it.

CBCentralManager is designed to communicate through delegate methods listed in the CBCentralManagerDelegate protocol. It has one non-optional method — centralManagerDidUpdateState(_:) — and that’s where we need to start. It’s called whenever the Bluetooth module in an iOS device changes its state — e.g. when you turn on or off Bluetooth in the Settings app. Additionally, it’s called just after the manager has been initialized. The state value that we want — in order to proceed — is .powerOn (so make sure to have your Bluetooth activated). When a manager is in that state, it can discover peripherals and connect to them.

Discovering nearby devices

Once in the correct state, we can scan for nearby peripherals with scanForPeripherals(withServices: options:) method. To receive its results we need to implement the next delegate method — centralManager(_: didDiscover: advertisementData: rssi:), which is called every time the manager discovers a new peripheral. Its second argument is an instance of CBPeripheral class, which corresponds to a certain peripheral. We can store all discovered peripherals in an array and e.g. display all of them in a table view to let the user choose the one he wants to connect to.

Note: If you’re having problems discovering your Mi Band, make sure it’s paired with your iOS device, then open Mi Fit app and navigate to ProfileMi Band 2 (in My devices section) → Discoverable and make sure the Discoverable mode is on.

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    if central.state == .poweredOn {
        manager.scanForPeripherals(withServices: nil, options: nil)
    }
}

var discoveredPeripherals: [CBPeripheral] = []

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

Connecting to a device

Assuming the user has chosen one of the discovered peripherals, we can use the manager’s connect(_: options:) method to connect to the chosen device bypassing the corresponding CBPeripheral instance as the first argument.

var miBand: CBPeripheral?

func connectToPeripheral(at index: Int) {
    manager.connect(discoveredPeripherals[index], options: nil)
}

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    manager.stopScan()
    miBand = peripheral
    peripheral.delegate = self
    peripheral.discoverServices(nil)
}

func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
    print(error)
}

The result of the connect operation is returned through one of the two methods from CBCentralManagerDelegate protocol: centralManager(_: didConnect:), in case of success, or centralManager(_: didFailToConnect: error:), in case of failure. Assuming everything went well, we can stop scanning for nearby devices and save the connected peripheral in a variable for convenience.

Next, we should make our MiService implement CBPeripheralDelegate protocol, so it can become a delegate of the chosen peripheral and receive notifications about its state changes.

Bluetooth LE interface introduction

Bluetooth LE interfaces are arranged in services and characteristics. You can think about services as classes and characteristics as their properties/methods. Services encapsulate characteristics that have something in common. Both services and characteristics can be identified by their UUIDs. Bluetooth standard defines a specification called Generic Attributes (GATT), which contains definitions of some commonly used services, like Heart Rate service. Later you may notice, that for some of those standard services, Xcode will print their names in object descriptions, instead of UUIDs.

Unfortunately, Xiaomi didn’t document the BLE interface they’re using in the Mi Band 2. This means that, apart from GATT services, we have no reference to how provided services can be used. This means it all comes down to guessing and sniffing network communication of the official Mi Fit app. Fortunately, someone has already done that tedious work for us: people involved in the Gadgetbridge project. Thanks to their outstanding commitment, we have access to most features of Mi Band 2 (and also other wearable devices).

Discovering device interface

To be able to interact with a peripheral, you have to first discover its services with the peripheral’s discoverServices(_) method. All discovered services are available under the peripheral’s services property, once peripheral(_: didDiscoverServices: error:) method is called.

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

Once we have a list of available services we can discover their characteristics. Discovered characteristics are available under services’ characteristics property, after receiving a call to peripheral(_: didDiscoverCharacteristicsFor: error:) method.

Reading device data

So far, we’ve just set things up, but this is where, finally, the fun begins. Once we have discovered a characteristic, we can read its value and/or register for notifications, which will be sent every time this value changes. But before you do either, it’s good to check what kinds of interactions (reading, notifying, writing, etc.) are available for a given characteristic — this information is stored in a characteristic’s properties property, which is an OptionSet of CBCharacteristicProperties.

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    print(error ?? service.characteristics)
    service.characteristics?.forEach { characteristic in	
        if characteristic.properties.contains(.read) {
            peripheral.readValue(for: characteristic)
        }
        if characteristic.properties.contains(.notify) {
            peripheral.setNotifyValue(true, for: characteristic)
        }
    }
}

There is, however, one last setup step we need to perform. Reading a characteristic’s value is an asynchronous operation, so to receive the actual value we need to implement peripheral(_: didUpdateValueFor: error:) method from CBPeripheralDelegate protocol.

func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    let valueBytes: [UInt8] = value?.map { $0 }
    print("New value for: \(characteristic)")
}

When you run your application and connect to your band, you should see these kinds of logs:

New value for: <CBCharacteristic: 0x1c40bf620, UUID = 00000007-0000-3512-2118-0009AF100700, properties = 0x12,
value = <0c0b0000 00070000 00010000 00>, notifying = YES>

The bolded part is the new value of a given characteristic. This particular log entry describes the characteristic, which holds information about the number of steps and meters covered and kilocalories burned today. However, when analyzing these bytes, it’s important to keep in mind that the majority of today’s digital devices use little-endian byte ordering, which means that the least significant bytes of a number are on its left side. Each of the aforementioned values is stored on 4 bytes: steps count on bytes 1–4, meters on bytes 5–8, and kilocalories on bytes 9–12. So e.g. to read a number of steps we take bytes 0b, 00, 00, 00 as UInt8s.

<0c 0b000000 07000000 01000000>

Then we cast each to UInt32, shift numbers left bitwise by subsequent multiples of 8, starting from 0 and finally sum everything. I’ve created a simple extension, which does just that:

extension UInt32 {
    static func from(bytes: [UInt8]) -> UInt32? {
        guard bytes.count <= 4 else { return nil }
        return bytes
            .enumerated()
            .map { UInt32($0.element) << UInt32($0.offset * 8) }
            .reduce(0, +)
    }
}

So if you want to extract steps count inperipheral(_: didUpdateValueFor: error:) method you can do it like this:

let stepsCount = UInt32.from(bytes: Array(valueBytes[1...4]))

Controlling a device

As for controlling the behavior of the band, we can e.g. start a heart rate measurement. This can be done through the standard Heart Rate GATT service and its Heart Rate Control Point characteristic. First, we need to find that service and characteristic in our band’s peripheral object.

After that, it’s just a matter of calling writeValue(_ : for: type:) method peripheral object, with a sequence of 3 bytes, where the second byte indicates the single measurement and third acts as an “on” flag. The last parameter indicates the writing type — with or without a response. To pick the right type, you should check properties property of given characteristics, as sometimes only one of them is accepted.

func measureHeartRate() {
    guard let miBand = miBand, 
          let hrControlPoint = miBand.services?.first(where: { $0.uuid.uuidString == "180D" })?
                                     .characteristics?.first(where: { $0.uuid.uuidString == "2A39" }) else { return }
    miBand.writeValue(Data(bytes: [0x15, 0x2, 0x1]), for: hrControlPoint, type: .withResponse)
}

After that, your band should start blinking with a green light on its backside, which means, it’s actually measuring your heart rate. The measurement usually takes a few seconds and after that, you should receive its value in Heart Rate Measurement characteristic update notification (assuming that, you have registered for its notifications).

Wrap up

And that’s it! You now know how to create Bluetooth-enabled apps for iOS With just a bit of additional work, you can come up with something like this:

Screenshot from the inFullBand app

For more Mi Band 2 characteristics’ usage examples you can check out my full project or Gadgetbridge’s Mi Band 2 support class. If you have any questions, feel free to drop us an email.

Design Sprint
From a bold idea to prototype
Learn more
Written by
Mikołaj Chmielewski
iOS Developer

You may also like

Like what you read?
Get monthly business and technology insights straight to your inbox.