MiBand 3 and React Native (Part Two)
See this developer's journey creating a React Native app with MiBand 3.
Join the DZone community and get the full member experience.
Join For FreeSome time has passed since my last article when I was trying to establish communication between a mobile application and MiBand 3. The latest comments in the first article showed me plenty issues I left behind the scenes.
In the last couple months, I've been working on improving my skills in order to understand BLE standard better, and I can't say I learned nothing! That minor experience helped me to solve most significant mistakes related to the authorization process and heart rate measurement. Besides, it helped me to extend functionality of my application.
Found Issues
When I read the comments concerning my application's inability to path authorization normally, some big doubts settled in my mind. I was really surprised that my first prototype could not even do simple and mandatory things. Moreover, people were testing it on different versions of Android that brought additional difficulties to light — some were using the MiFit app for device pairing, and some were not. Day-by-day situations became more chaotic, making me nervous enough to start looking back through my errors.
The first problem was found almost immediately. My findings pointed me to fact that my solution can not work properly without MiFit. Once auth is passed and device is paired, mobile app begins receiving permissions on usage of all available services, inner characteristics, and descriptors. My prototype was not aware of it and, therefore, could not grant permissions to use more advanced services associated with MiBand.
The second problem was found a bit later. MiBand 3 has a bit different map of data which can be generated during pair, auth, heart rate scenarios. That is obvious essentially since MiBand 3 is a next generation of Xiaomi bands. New features, improvements of already supported features influence directly communication layer. No doubts more differences will be found later when I will be capable to work with advanced set of MiBand features.
The third problem appeared because of my lack of experience with BLE standard. One of fundamental aspects every BLE developer must keep in mind: Android's API is based on an event-driven approach. Since communication between peripheral devices creates an unstable environment where messages sometimes can just disappear, any sequentual and rapid data sent between devices cannot guarantee successful package delivery to a receiver. To prevent similar issues further, I need to be sure that previous commands were sent properly; only after that I could begin sending next commands to my peripheral.
I had all my cards in place to make sure the solution worked:
All comments about found bugs are checked and re-produced locally
Main issues are designated, their nature is explained
Possible ways to tacle bugs have been developed
Improvements
My plan was based on following requirements:
Make the solution independent. It needed to work even if a hard reset had been done on the MiBand side.
Remove any hand-made pauses in the code that make my solution wait for data from a peripheral device.
Make logic capable to use an event-driven approach at scale.
Add steps and battery level measurement.
Re-test everything that was written
Reworking Authorization
To make things simple and get better control on possible feature extensions, I focused on my class that extends BluetoothGattCallback
one. This time, we will focus our attension on the following methods:
onCharacteristicChanged(...)
.onCharacteristicRead(...)
.onCharacteristicWrite(...)
.onDescriptorWrite(...)
.onDescriptorRead(...)
.
The methods above are neccessary, as they will contain our "sequential" commands to communicate with a device properly. What about React related classes we have in Android? They already contain some char read/write commands.
Well, I decided to leave only the first command, which will generate a "spark." The spark initiates an appropriate conversation between the mobile app and peripheral device. The next commands were placed into methods I specified before. So, all responsibility is put on the Gatt API, which starts working on 100%. Let's take a look on renewed auth scenario closely. In the beginning, we make an ignition in a React method like this:
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
...
authoriseMiBand(gatt);
}
private void authoriseMiBand(BluetoothGatt gatt) {
// 1) enable notification mode for auth charcteristic
gatt.setCharacteristicNotification(authChar, true);
// 2) activate auth charcteristic's descriptor
authDesc.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(authDesc);
}
Next, we intercept a descriptor write event. We can do it in the onDescriptorWrite
method:
@Override
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
switch (descriptor.getCharacteristic().getUuid().toString()){
case CHAR_AUTH:{
// 3) since our device has activated auth descriptor we are ready to send 16-bytes encryption key to peripheral.
// Before it we put 2 bytes [0x01, 0x00] to receive a notification from device with
// randomly generated 16-byte key.
byte[] authKey = ArrayUtils.addAll(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE, AUTH_CHAR_KEY);
authChar.setValue(authKey);
gatt.writeCharacteristic(authChar);
break;
}
...
}
}
Next, we go to the onCharacteristicChanged(...)
method.
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
Log.d("INFO", "onCharacteristicChanged char uuid: " + characteristic.getUuid().toString()
+ " value: " + Arrays.toString(characteristic.getValue()));
byte[] charValue = Arrays.copyOfRange(characteristic.getValue(), 0, 3);
switch (characteristic.getUuid().toString()){
case CHAR_AUTH:{
switch (Arrays.toString(charValue)){
// 4) Requesting for 16-byte key that will be randomly generated by device
case "[16, 1, 1]":{
authChar.setValue(new byte[]{0x02, 0x00});
gatt.writeCharacteristic(authChar);
break;
}
...
// 5) once device has given a responce we begin encrypting a received key
case "[16, 2, 1]": {
executeAuthorisationSequence(gatt, characteristic);
break;
}
}
break;
}
...
}
}
Encryption is based on the AES algorithm.
private void executeAuthorisationSequence(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
byte[] value = characteristic.getValue();
...
if (value[0] == 16 && value[1] == 2 && value[2] == 1) {
try {
// taking 16 random key
byte[] tmpValue = Arrays.copyOfRange(value, 3, 19);
String CIPHER_TYPE = "AES/ECB/NoPadding";
Cipher cipher = Cipher.getInstance(CIPHER_TYPE);
String CIPHER_NAME = "AES";
SecretKeySpec key = new SecretKeySpec(AUTH_CHAR_KEY, CIPHER_NAME);
cipher.init(Cipher.ENCRYPT_MODE, key);
// encrypting key...
byte[] bytes = cipher.doFinal(tmpValue);
// adding [0x03m 0x00] before encrypted key
byte[] rq = ArrayUtils.addAll(new byte[]{0x03, 0x00}, bytes);
// updating of char's value with ready to send data
characteristic.setValue(rq);
gatt.writeCharacteristic(characteristic);
} catch (Exception e) {
e.printStackTrace();
}
}
...
}
Finally, we must receive a positive reply from device.
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
Log.d("INFO", "onCharacteristicChanged char uuid: " + characteristic.getUuid().toString()
+ " value: " + Arrays.toString(characteristic.getValue()));
byte[] charValue = Arrays.copyOfRange(characteristic.getValue(), 0, 3);
switch (characteristic.getUuid().toString()){
case CHAR_AUTH:{
switch (Arrays.toString(charValue)){
...
// 7) auth is passed and we have access to main device services
case "[16, 3, 1]":{
Log.d("INFO", "Authentication has been passed successfully");
ModuleStorage moduleStorage = getModuleStorage();
heartBeatMeasurer = moduleStorage.getHeartBeatMeasurerPackage().getHeartBeatMeasurer();
heartBeatMeasurer.updateHrChars(gatt);
infoReceiver = moduleStorage.getInfoPackage().getInfoReceiver();
infoReceiver.updateInfoChars(gatt);
}
}
break;
}
...
}
}
The updated algorithm was tested in two modes:
MiBand has hard reset
MiBand has been already paired and passed auth procedure
It works fine in both cases.
BLE Pause Eradication
Previously, I admitted that the artificial pauses left in code is something that goes against the event-driven approach that we're looking for. Ideally, we must not have them, so I had to remove them. Let me show how heart rate measurement in real time logic looks now:
@ReactMethod
private void startHrCalculation(Callback successCallback) {
// 1) We handle a request from React and activate sensor characteristic for measure procedure
sensorChar.setValue(new byte[]{0x01, 0x03, 0x19});
btGatt.writeCharacteristic(sensorChar);
successCallback.invoke(null, heartRateValue);
}
Next move is activation of descriptor that locates inside of hr char:
@Override
public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status){
switch (characteristic.getUuid().toString()) {
case CHAR_SENSOR: {
switch (Arrays.toString(characteristic.getValue())){
// for real time HR measurement [1, 3, 19] was sent actually but [1, 3, 25]
// is written. Magic?
case "[1, 3, 25]":{
// 2) activation of descriptor to receive notifications from device with valuable data
hrDescChar.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(hrDescChar);
}
}
}
...
}
}
When descriptor is written, we can send a command to start heart rate measurement now:
@Override
public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
switch (descriptor.getCharacteristic().getUuid().toString()){
...
case CHAR_HEART_RATE_MEASURE:{
switch (Arrays.toString(descriptor.getValue())){
// 3) activate hr control point char and start hr measurement now
case "[1, 0]":{
hrCtrlChar.setValue(new byte[]{0x15, 0x01, 0x01});
Log.d("INFO","hrCtrlChar: " + gatt.writeCharacteristic(hrCtrlChar));
}
default:
Log.d("INFO", "onDescriptorWrite uuid: " + descriptor.getUuid().toString()
+ " value: " + Arrays.toString(descriptor.getValue()));
}
}
}
}
The second to last step is to get data with heart rate measurements from the device:
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
Log.d("INFO", "onCharacteristicChanged char uuid: " + characteristic.getUuid().toString()
+ " value: " + Arrays.toString(characteristic.getValue()));
byte[] charValue = Arrays.copyOfRange(characteristic.getValue(), 0, 3);
switch (characteristic.getUuid().toString()){
...
// 4) we have hr related info from device. Let's check it
case CHAR_HEART_RATE_MEASURE:{
heartBeatMeasurer.handleHeartRateData(characteristic);
break;
}
}
}
The most intriguing and final step is getting the user's current heart rate value:
public void handleHeartRateData(final BluetoothGattCharacteristic characteristic) {
// 5) hr value contains in second byte of value array we have in hr char.
byte currentHrValue = characteristic.getValue()[1];
heartRateValue = String.valueOf(currentHrValue);
}
What about disabling of hr measurement in real-time? Fortunately for us, the procedure for disabling looks much easier:
@ReactMethod
private void stopHrCalculation() {
hrCtrlChar.setValue(new byte[]{0x15, 0x01, 0x00});
btGatt.writeCharacteristic(hrCtrlChar);
}
And that's all. The sensor will stop blinking immidietly, and our app won't get any notifications from the device :)
Step and Battery Level Measurement
Here, I decided to improvise a bit and combined data from different chars in one. I think it can be useful when you want to get complex data structure from device. So started from a typical method marked with @ReactMethod annotation:
@ReactMethod
private void getInfo(Callback successCallback) {
...
// 1) let's read some data from step characteristic
btGatt.readCharacteristic(stepsChar);
...
successCallback.invoke(null, steps, battery);
}
And when we want to read data from chars "onCharacteristicRead" goes on scene:
@Override
public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
Log.i("INFO", "onCharacteristicRead uuid: " + characteristic.getUuid().toString()
+ " value: " + Arrays.toString(characteristic.getValue()) + " status: " + status);
switch (characteristic.getUuid().toString()) {
// 2) We read data about steps and ask to read data concerning battery level
case CHAR_STEPS: {
infoReceiver.handleInfoData(characteristic.getValue());
gatt.readCharacteristic(batteryChar);
break;
}
// 3) It's time to read some data from battery char
case CHAR_BATTERY: {
infoReceiver.handleBatteryData(characteristic.getValue());
}
}
}
That were all most important changes I made.To display steps and battery level UI has been changed as well.
UI changes
Frontend was not changed significantly. I just added couple new fields to display battery level and steps. Plus one method was added to get info (steps passed and current battery level):
import React from 'react'
import {Text, View, NativeModules, TouchableOpacity} from 'react-native';
import styles from "./styles.jsx";
export default class Dashboard extends React.Component {
constructor(props) {
super(props);
this.state = {
deviceBondLevel: 0,
heartBeatRate: 0,
steps: 0,
battery: 0,
isConnectedWithMiBand: false,
isHeartRateCalculating: false,
bluetoothSearchInterval: null,
hrRateInterval: null
};
}
searchBluetoothDevices = () => {
this.setState({ isConnectedWithMiBand: true})
NativeModules.DeviceConnector.enableBTAndDiscover( (error, deviceBondLevel) => {
this.setState({ deviceBondLevel: deviceBondLevel})
})
this.setState({ bluetoothSearchInterval: setInterval(this.getDeviceInfo, 5000) })
}
unlinkBluetoothDevice = () => {
this.deactivateHeartRateCalculation()
NativeModules.DeviceConnector.disconnectDevice( (error, deviceBondLevel) => {
this.setState({ deviceBondLevel: deviceBondLevel});
})
clearInterval(this.state.bluetoothSearchInterval)
this.setState({ bluetoothSearchInterval: null})
this.setState({ deviceBondLevel: 0})
this.setState({ steps: 0})
this.setState({ battery: 0})
this.setState({ isConnectedWithMiBand: false})
}
getDeviceInfo = () => {
NativeModules.DeviceConnector.getDeviceBondLevel( (error, deviceBondLevel) => {
this.setState({ deviceBondLevel: deviceBondLevel}, () => {
this.getDeviceBondLevel
});
})
NativeModules.InfoReceiver.getInfo((error, steps, battery) => {
this.setState({ steps: steps})
this.setState({ battery: battery})
})
}
activateHeartRateCalculation = () => {
NativeModules.HeartBeatMeasurer.startHrCalculation((error, heartBeatRate) => {
this.setState({ isHeartRateCalculating: true})
this.setState({ heartBeatRate: heartBeatRate})
})
this.setState({ hrRateInterval: setInterval(this.getHeartRate, 5000)})
}
deactivateHeartRateCalculation = () => {
NativeModules.HeartBeatMeasurer.stopHrCalculation()
this.setState({ isHeartRateCalculating: false})
this.setState({ hrRateInterval: null})
this.setState({ heartBeatRate: 0})
clearInterval(this.state.hrRateInterval)
}
getHeartRate = () => {
NativeModules.HeartBeatMeasurer.getHeartRate( this.state.heartBeatRate, (error, heartBeatRate) => {
this.setState({ heartBeatRate: heartBeatRate})
})
}
render() {
return (
<View style={styles.container}>
<View style={styles.package}>
<Text style={styles.sensorField}>Heart Beat:</Text>
<Text style={styles.sensorField}>{this.state.heartBeatRate + ' Bpm'}</Text>
</View>
<View style={styles.package}>
<Text style={styles.sensorField}>Steps:</Text>
<Text style={styles.sensorField}>{this.state.steps}</Text>
</View>
<View style={styles.package}>
<Text style={styles.sensorField}>Battery:</Text>
<Text style={styles.sensorField}>{this.state.battery + ' %'}</Text>
</View>
<View style={styles.package}>
<Text style={styles.sensorField}>Device Bond Level:</Text>
<Text style={styles.sensorField}>{this.state.deviceBondLevel}</Text>
</View>
<View style={styles.buttonContainer}>
{this.state.isConnectedWithMiBand ? (
<TouchableOpacity style={styles.buttonEnabled} onPress={this.unlinkBluetoothDevice}>
<Text style={styles.buttonText}>Unlink With MiBand</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.buttonEnabled} onPress={this.searchBluetoothDevices}>
<Text style={styles.buttonText}>Link With MiBand</Text>
</TouchableOpacity>
)}
<View style={styles.spacing}/>
{this.state.isConnectedWithMiBand ? (
this.state.isHeartRateCalculating ? (
<TouchableOpacity style={styles.buttonEnabled} onPress={this.deactivateHeartRateCalculation} disabled={false}>
<Text style={styles.buttonText}>Stop HR Measurement</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.buttonEnabled} onPress={this.activateHeartRateCalculation} disabled={false}>
<Text style={styles.buttonText}>Start HR Measurement</Text>
</TouchableOpacity>
)
) : (
<TouchableOpacity style={styles.buttonDisabled} disabled={true}>
<Text style={styles.buttonText}>Start HR Measurement</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
}
Guess it's time to refer our attention to a small demonstration :)
Show time
Just to demonstrate how this solution can work, I have prepared a small demo video.
It does not contain steps checking, but it works anyway. You just need to pass auth process and start walking. Once step's counter becomes above zero on band, app will display current value in couple seconds.
What About iOS?
Swift and iOs has become a real challenge for me. I have never written code for Apple devices, so almost everything was new. A couple weeks were spent to prepare a stable environment, and then, I began practicing with Swift. Two objectives were set:
setup API to handle requests from React UI
setup Bluetooth API to communicate with MiBand peripheral
Fortunately for me, the React project setup has provided everything for me to begin working with the first objective. The project structure reminds one you could see before in Android.
As you remember React-Native projects have two main folders: iOS and Android. In the first folder, I created one header file and imported React/RCTBridgeModule.h:
#ifndef DeviceConnector_Bridging_Header_h
#define DeviceConnector_Bridging_Header_h
#import "React/RCTBridgeModule.h"
#endif
With the following instructions provided by official documentation of React-Native, I created a macros file:
#import <Foundation/Foundation.h>
#import "React/RCTBridgeModule.h"
@interface RCT_EXTERN_MODULE(DeviceConnector, NSObject)
RCT_EXTERN_METHOD(enableBTAndDiscover: (RCTResponseSenderBlock)callback)
RCT_EXTERN_METHOD(getDeviceBondLevel: (RCTResponseSenderBlock)callback)
@end
Its main purpose: declare methods that must be called once by the React-Native UI. For now, it contains two methods. Then, I created a new DeviceConnector
class. In the beginning, the view was almost empty:
import Foundation
import CoreBluetooth
@objc(DeviceConnector) class DeviceConnector: NSObject {
...
@objc func enableBTAndDiscover(_ callback: RCTResponseSenderBlock) {
centralManager = CBCentralManager(delegate: self, queue: nil)
callback([NSNull(), 0])
}
@objc func getDeviceBondLevel(_ callback: RCTResponseSenderBlock){
callback([NSNull(), miBandPeripheral.state.rawValue])
}
...
}
Then, I declared two extensions for it:
// works with a device connection mainly. Once it's done invokes service discovery.
extension DeviceConnector: CBCentralManagerDelegate {
...
}
// contains implementation of peripheral methods that work in listener mode. They are pretty similar
// to those android methods I mentioned in the beginning of the article.
extension DeviceConnector: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
...
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
...
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
...
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
...
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
...
}
}
The device connector serves to establish a connection with a peripheral device, returns bond level by request, and passes auth process. It reminds our bluetooth package in Android where we have the same implemented logic.
So, does it work now? Short answer: not yet. To be honest, I faced a restriction that blocks me from writing data into descriptors. Such operations were blocked in the BLE API provided by Apple. My investigations did not show any explanations why it was done.
Official docs say I should use the "setNotifyValue(true, for: characteristic)" method instead. Unfortunately, it does not help at all. When I am trying to send 16-bytes encryption key to peripheral, the last one does not send anything to me. I do not have error, notifications or warnings. Simply nothing...
The blocker above is a main reason why I can not finish business on this frontline. I have a couple ideas about this matter. One of them is to use for testing js based ble library. In theory js code will generate a proper variant of native code, and I will see my mistakes. Hope it will work till completion of third chapter...
What next?
iOs and server side implementations are among top priority goals I have currently. When server's prototype will be implemented much faster, Swift and its API keeps presenting me new surprizes I did not expect to meet. If someone has rich experience with obj-C and iOS API of BLE standard, please contact me. I will listen to you with pleasure :)
Git repo is updated. "iOS" folder has basic logic related to connection with a peripheral device. Do not hesitate to comment my progress. Many things I do not know still but keep trying to solve it. Take care! ;)
Further Reading
Opinions expressed by DZone contributors are their own.
Trending
-
Multi-Stream Joins With SQL
-
Testing, Monitoring, and Data Observability: What’s the Difference?
-
Auto-Scaling Kinesis Data Streams Applications on Kubernetes
-
[DZone Survey] Share Your Expertise for Our Database Research, 2023 Edition
Comments