Image by Ceptari Tyas

How to Create an App for Fitness Devices (FTMS, Fitshow, Delightech) in React Native

Nicolay Polotnianko

Nicolay Polotnianko

Tech Lead @ Stormotion

The COVID-19 pandemic significantly changed our fitness habits. So now, a lot of people don’t really want to go out to the gym and prefer to exercise in their houses with some equipment of their choice.

Sometimes, this equipment may have Bluetooth in place. Thus, you might be tempted to create an app to enable users to connect to those devices, track their progress, and control their fitness devices using a smartphone.

In this article, we’re going to take a look at how to initiate such a Bluetooth connection, find out the differences between major protocols, the ways to identify the protocols & the device itself, and, of course, to control it. Lastly, we’ll take a look at the potential pitfalls each protocol has and how we can overcome them.

We’re not going to have all the code written in this article because it’d take hours to read, and the actual ways you overcome the pitfalls might differ, depending on your tech stack and personal wishes.

This is a detailed guide for all of you working on connecting fitness devices to your app or simply willing to learn more. Even though we write our code in React Native, most of the article code should be platform-agnostic, so you can skip the React Native part and get to the protocols themselves.

 
 

⚙️ BLE Hardware Details

We've already described how Bluetooth works in our blog. Please take a look to revise your knowledge.

To keep it simple, we have a lot of different BLE devices. Each of them has unique services, descriptors, and characteristics to work with. Let’s take a closer look at this structure:

BLE device structure

 
 

Device

The device has a few important fields, which could help us working with it:

Name

You can show the name somewhere on your UI for the users. You might also be tempted to filter all the devices by name only, but it's not the best way, especially for fitness devices. In this industry, the devices are made by a lot of different manufacturers, and they might give them any name they want. Thus, be careful with it.

ID

It is a unique ID between the phone and the fitness device. It doesn't match between two different phones. However, you can save it locally so that the next time you see a device with this ID, you understand it's the device you need.

 
 

Characteristics

What is important for us to correctly manage the Bluetooth state is understanding how to work with Characteristic — the main gateway for communication between Client (our app) and Server (BLE device). The device might have a lot of different characteristics, and they contain a few important fields for us:

 

  • UUID

That's the stable UUID, that you can use to distinguish characteristics among others. It always has the very same form, and only 4 symbols ({XXXX}) differ in every characteristic: 0000{XXXX}-0000-1000-8000-00805f9b34fb

One of the most widely-used UUID is 0000fff1-0000-1000-8000-00805f9b34fb. However, keep in mind that if you find a BLE device, which has this characteristic UUID — it doesn't always mean it's the device you’re looking for. Thus, you might end up connecting to unnecessary devices just because they have the very same UUID.

 

  • isWritableWithoutResponse

This field determines whether we can send data without the response (characteristic wouldn't let us know when it processed the command). If true, we can call writeWithoutResponse.

 

  • isWritableWithResponse

This field determines whether we can send data with the response(characteristic would let us know when it processed the command). If true, we can call writeWithResponse.

In React Native, .writeWithResponse(data) returns Promise<Characteristic>

 

  • isNotifiable

This field determines when we can receive notifications from the characteristic (for instance, for some devices, we receive a real-time update that the workout is in progress). If isNotifying is true, we can subscribe to the monitor function and pass our handler to it:

  • isReadable

This field determines if we can read the characteristic's value. Some characteristics don’t notify us of any changes, however, we still want to get data. In this case, if isReadable is true, we can at some point call .read()

 

  • Value

It's probably the most important field for characteristics. It contains a base64 string, which could be parsed. Whenever we call any .write, underneath the characteristic changes the value. Whenever we call the .read, we receive the value itself.

 
 

Characteristic value

It doesn't matter whether we write or read data, we work with Characteric's value which is a base64-string representation of Uint8Array.

To make transformations we use base64-js:

In case we need to write data for Characteristic, we need to send base64 string — we use base64.fromByteArray ([0, 12, 30, 50, ...])

In case we received a notification (or called a read) and we need to work with the Characteristic value — we’ll use base64.toByteArray (characteristic.value)

What byte array we send in, and how we parse the response — heavily depends on the exact device and protocol it works with.

 
 

🛠 How to Setup Bluetooth/BLE in React Native

What about Expo? As of summer 2022, Expo doesn’t offer you to work with BLE. To make sure your app can communicate with Bluetooth, you need to eject your app and continue as a bare react native app.

As for now, we suggest using react-native-ble-plx as the best solution for making the connection between a React Native app and a BLE device. Please note that later on, the library might differ or we might have another competitor. Though, it shouldn’t change the logic of how we approach the task.

Please follow the installation guide for react-native-ble-plx:

  • For iOS: don’t forget to add BLE capabilities to your Xcode project;
  • For Android as of May 2022: react-native-ble-plx don’t point out the differences in your AndroidManifest since Android 12. Still, you need to add a few permissions and explicitly require them.

 
 

Our requirements

When working with Bluetooth, we have a lot of things to cope with:

  • listeners (device connection/disconnection/subscription to data change);
  • scanning (finding new devices, having the ability to connect to them);
  • state management, etc.

Due to the hardware limitations, we want scanning to run only once for our app. Because if we stop-start scanning, we end up in a situation where we break the Bluetooth connection.

Additionally, we want our listeners to always be up-to-date. We also want to avoid possible race-conditions and issues with state, when updating it and having a new value only in the next render, which could break the app in this render.

Having these 2 ideas in mind, we understand that React doesn't really work well with it. States would lead to endless re-runs of the scanning process. Our listeners wouldn't be up-to-date, and there may be a lot of other possible issues.

Technical requirements for fitness device integration

We had some strict requirements for fitness device integration (image by Jordon Cheung)

Of course, we could "hack" React: either use the refs all the way or just make all the dependencies empty. However, such manipulations make the code very hard to read and difficult to maintain since any possible change could break the way we work with it.

The best solution we see is having a special class, which handles all the hardware computation and inner management, and takes a few dependencies to update React state. We also create a helper hook, which wraps the class, and then, we make sure we can work with the dependencies and have state changes.

Let's take a look at how we can do it:

We'll use basically the same approach across all modules we have in our app.

Now, as we've talked about some theoretical structural things, let's get to handling different important cases.

 
 

Enabling Permissions

Permission request differs for Android and iOS:

  • For iOS, the permissions are automatically asked. There’s nothing extra you need to do on your side.
  • For Android, instead, you need to ask for a few permissions explicitly. Until Android 12, it was only ACCESS_FINE_LOCATION. Right now, they also need BLUETOOTH_SCAN and BLUETOOTH_CONNECT. For instance:

 
 

Permissions: validating if users turned on Bluetooth

Sometimes it might be important to understand if the users actually have Bluetooth/location on, so you could prompt them to do so.

You can use the react-native-connectivity-status module for it.

Thus, our useBle code would look something like this:

 
 

Permissions: react to the user turning on Bluetooth or location

Imagine a user got to your app without location/Bluetooth turned on. You show them an alert, saying they need to turn it on. Then we need to enable the scanning back and "unlock" the Bluetooth from the user.

We can use react-native-connectivity-status for it as well:

Now that we've covered all the possible permissions and hardware issues, related to Bluetooth or location (on Android) being unavailable, let's move on to the next topic — device scanning.

 
 

Scanning

To perform scanning, we first need to create a react-native-ble-plx manager:

We need to create it only once, right at the start of the app development, because if we created it multiple times — Bluetooth wouldn't work well.

We've already discussed how we want to structure our scanning — we do it inside a separate class. The scanning will start only once, and all the outer dependencies will be replaced using refs. We believe it’s the best approach, however, if you'd like to keep it all inside the React hook (which we don't recommend doing), please take care of the following aspects:

  1. Before calling the next startDeviceScan (because, for instance, dependencies changed), we need to call .stopDeviceScan. If we don't do it, we fall into a memory leak.
  2. If we call multiple start-stop-start-stop device scanning, your app might end up not being able to scan at all.

If you're not careful with the dependencies, you might have too many dependencies and .startDeviceScan would be excessively triggered. You can't afford it, and you need to be careful with it in your app to make sure your Bluetooth connection is stable.

Scanning can be tricky but we know how to handle it

Scanning can be tricky but we know how to handle it (image by Murat Gursoy)

What can you do?

  1. Reduce the number of outer dependencies.
  2. If you have a state and you need to update it — consider using setState(prevValue => newValue) syntax because in this case, you don't need extra dependencies with useEffect.
  3. In case you have a state and you really need a previous value, consider using useRef. It doesn't lead to rerenders, and you don't need to add it to your useEffect dependencies.

Now, let's get back to how we could handle it as a separate class. We need to keep a few things in mind:

  1. We need to let our app know something has gone wrong.
  2. We need to update our state from our class.

addItemToList in the example is a custom function, which makes sure we have no duplicates.

Please note that these 2 functions (changeOnSyncDevices, changeOnBleError) are custom changeOn* functions, which are updated within useEffect. We need it because the dependencies might change, and it won't affect the constructor. We need custom useEffects to make sure we can update the handlers and listeners as we progress inside the app.

You might've noticed that we pass the allowDuplicates here. We need it for a corner case. If we don't pass it, iOS wouldn't scan the same device if, for instance, you connected it, then disconnected, and then connected once again. iOS would consider it as the same device ➡️ it wouldn't let us know it's reconnected.

However, with this code, we might fall into the case when we connect to the same device a lot of times. To make sure it doesn't happen, we can add a field to the class, responsible for storing, whether a device is connected with it or not. Also, we need to handle device disconnections as well to make sure we don't store any non-available devices.

You can apply any additional logic you want regarding scanning/connection. Just one thing to keep in mind — avoid extending your dependencies.

Looks great!

 
 

Navigating between screens

Inside your app, you'll often see a few screens which require Bluetooth connection and work with devices — and a few ones that don't. Thus, you might be tempted to start all the scanning operations on specific screens because it eases the process of retrieving/showing the data.

We wouldn't advise doing it because, once again, multiple scan-stops might break scanning at all. Therefore, it'd be better if you wrapped all your code inside a context. Yet, you should somehow understand whether you should actively scan right now or not. You can use another isActive field inside a class. To make sure it's up to date, you can use the listener updates within our hook.

Using this code above, on every screen you call useBleContext, active would be set to true, and all the scanning would process automatically. Once you leave this screen or go to another tab, active is set back to false, and no scanning happens.

 
 

🗂 Major Fitness Protocols: Similarities & Differences

You can treat protocol as a special language for communicating between your app and BLE device. It determines a few things:

  1. What you can do with the device.
  2. How you can accomplish your goals.
  3. Characteristics you’ll need to work with.
  4. When we need to send commands (basically call .write() with some data).

In our guide, we'll talk about 3 different protocols used when working with fitness devices:

Let’s first talk about each of them in general, and then learn how we can seamlessly integrate them, avoiding extra pitfalls and headaches.

 
 

FTMS

As for summer 2022, it seems to be a protocol with the biggest number of available features. Here are some of the most important ones:

Few characteristics to write to

Change level characteristic(2ad9)
Change pause/resume characteristic, connect characteristic (2ad3)

Receives workout changes via notification

We monitor device characteristics and receive real-time updates. We don't need to do anything to receive a notification

Has a characteristic to read from the device’s general data

For example, whether it has an incline, resistance management, and how many levels are available (2acc)

Enabled subscriptions

Using a few characteristics you can subscribe to receive info about device/current resistance/whether the workout is started

Big number of situations when we can send a request:

1️⃣ Establish a connection
2️⃣ Pause the workout
3️⃣ Resume workout
4️⃣ Change resistance/target speed
5️⃣ Send user info

Has a lot of characteristics to subscribe to receive workout info — each for every type of device

Rower (2ad1)
Treadmill (2acd)
Bike (2ad2)
Crosstrainer (2ace)
Stepper (2acf)

Last but not least — how we parse the received characteristic value. Imagine we received a notification from ROWER (2ad1). As always, it's a base64 string which we can then parse to an array of numbers. However, what we receive in an array depends on the first two bytes:

[CONTROL_BYTE_0, CONTROL_BYTE_1, VALUE_BYTE_0, VALUE_BYTE_1, ....]

That's how it could look: [12, 0, 13, 6, 5, 3];

The important question for us is: how do we understand what [13, 6, 5, 3] stands for?

It depends on the values we see in the first two control bytes. To work with it properly, we need to convert CONTROL_BYTE_0 and CONTROL_BYTE_1 to the binary system — [1100, 0]. Then, we need to take a look at each bit and check its value. The way you can do it is described in the documentation. Here’s a quick example!

For instance, our protocol says that if on bit 0 we have value 1 — we have 2 bytes of speed:

  • In case we have value 1 on bit 1 — we have 2 bytes of distance.
  • If we have value 1 on bit 2 — we have 2 bytes of calories.
  • Finally, in case we have value 1 on bit 3 — we have 2 bytes of energy.

Remember that we take a look at the binary string in reverse. So when we want to know bit 1 - we need to do "0011"[1] instead of "1100"[1]

Let's sum up what we have for now:

"0011"[0] == 0

➡️

we don't have speed

"0011"[1] == 0

we don't have distance

"0011"[2] == 1

we have 2 bytes of calories

"0011"[3] == 1

we have 2 bytes of energy

Then we go step by step and take a look at our values — [13, 6, 5, 3]. Because the first two bytes to take would be the "calorie" bytes — the calories are 13 + 6. Then, we have 2 bytes of energy, and we start not from the beginning, but from the end of the previous take. It'd be 5+3. Simply speaking, we shift our values.

RESPONSE: speed - not set, distance - not set, calories - 19, power - 8

However, if on CONTROL_BYTE_0 the value is 10 instead of 12 — we wouldn't have calories. If we turn 10 to the binary we receive 0101, which means that instead of calories we have distance:

RESPONSE: speed - not set, distance - 19, calories - not set, power - 8

Note: that’s just a basic example. Most of the time, the output value is not a sum. You’d rather have bytes summed with some multipliers. Therefore, this example is simplified on purpose.

 

To sum up:

So what functionality do we expect from our protocol?

  1. We need to be able to .write() to different characteristics under different circumstances.
  2. Read from one characteristic under some circumstances.
  3. Subscribe to different characteristics, and correctly parse the response, shifting the values.

 
 

Delightech

As for now, it seems to be a very niche protocol. You can access its documentation here. Despite offering a range of important features, their number is quite limited, yet, different from the FTMS one.

Let's take a look at the main capabilities of the protocol:

  • It has a single characteristic for both writes and monitors (fff1). All the requests should be correctly prefixed so that we understand we work with Delightech.

  • Even though we have one characteristic for all Delightech operations, we have different goals for writing. We distinguish the needed goal by our payload:

    • When working with the info command, we pass 64 as the first byte of the payload. This command includes information on whether the workout has started; connect, pause workout; user info (like weight), etc.
    • When it comes to workout command, we need this one to "ask" our characteristic to send us a notification with an updated user payload — we pass 32 as the first byte of the payload. We usually run this command every second — it means that every single second we’ll receive a notification. Yet, we can change this number to any other. For example, we can send this command every 10 seconds, and, thus, receive a notification every 10 seconds.
  • Notification is sent only when we send the write command. Basically, instead of subscribing to a real notification, we ping our BLE device every second.

  • To sum up, there are only 3 possible situations when we send requests to a BLE device:

    1. Establish a connection.
    2. Send user info.
    3. Every second send request with information about the workout (within this request we also control whether the workout is active or not, and change the resistance/target speed).

Let’s talk a bit about parsing the values. Instead of shifting our values, depending on the first bytes, we ALWAYS expect them in the same positions. For instance, if we expect calories — they'll always be sent as 6th byte, no matter whether we have distance or speed or any other field.

However, we have some exceptions here. Some fields might be empty for particular devices (for instance, when we run — we don't receive any rpm; or we won’t receive heart rate if we don’t use a heart rate sensor). We understand what fields are sent by taking a look at the "CONTROL_BYTE". Imagine the following notification:

[CONTROL_BYTE, SPEED_HIGH, SPEED_LOW, CALORIES_HIGH, CALORIES_LOW, DISTANCE_HIGH, DISTANCE_LOW]

As you can see, we always receive the speed, calories, distance, etc. in the same position. However, if, for instance, bit 5 of CONTROL_BYTE is 1 — we omit CALORIES_HIGH, CALORIES_LOW. We don't take a look at them, at all.

The last thing — we can take a look at the notification we receive, and based on it, understand what type of device we work with (bike/treadmill/rower/etc). Some fields also depend on what type of device we work with.

 

To sum up:

  1. We need to be able to .write() to the same characteristic under different circumstances (with different values).
  2. We don't .read() from any fields.
  3. We need to subscribe to a single characteristic, and every second trigger .write() to receive a notification. We don't shift any values, but read them, depending on CONTROL_BYTE.

 
 

Yeekang/Fitshow/Thinkfit

Documentation on this protocol can be found here. However, it’s in Chinese so there might be some issues understanding it. English simplified version can be found here. As before, let’s review some key points of the protocol.

First thing first, we have 2 different characteristics to work with. All the values we send/receive from characteristic should be prefixed with 2 first bytes — [0x02, 0x42-0x45]. The second byte marks the exact command we want to send/parse.

Also, we have a special characteristic to write to (fff2). Even though for all the writes we have a single characteristic, we have multiple possible occasions to send write:

  1. Connect.
  2. Write a command to receive data about the device (available fields, max resistance, etc).
  3. Send user's data (weight, height, etc.).
  4. Start workout.
  5. Pause workout.
  6. Resume workout.
  7. Stop workout.
  8. Change resistance/target speed.
  9. Every single second send 2 requests to receive a notification about the workout.

When using this protocol, we don’t have any characteristics to read and have 1 to subscribe to notifications.

Workout notification is sent only when we send the write command, just like in Delightech. Basically, instead of subscribing to a real notification, we ping our BLE device every second.

Value parsing also works the same way as Delightech: instead of shifting our values, depending on the first bytes, we ALWAYS expect them in the same positions. For instance, if we expect calories, they'll always be sent as the 6th byte, not taking a look at whether we have distanceб speed or any other field.

Yet, there are a few differences worth mentioning:

  1. As you remember, we send 2 requests, and we receive 2 notifications regarding the workout. They're prefixed with different bytes. For instance, we'll always have speed if the notification is prefixed with [0x02, 0x42, 0x02]. No need to look at the specific bits.

  2. We can't know the exact device. And the exact device doesn't change the response we receive.

 

To sum up:

  1. We need to be able to write() to the **same(fff2) **characteristic a few times a second while the workout is in progress. Also, we have quite a few other occasions where we call this characteristic.
  2. We don’t .read() from any fields.
  3. We need to subscribe to a single characteristic, and every time we trigger .write() to receive a notification. We don't shift any values, but read them, depending on a few first bytes.

 
 

📱 Top Things to Keep in Mind when Enabling Multiple Protocol Support

Since now we know key points about each of the protocols, we want our app to efficiently work with any of them. To make it happen, we need to work on a few crucial things:

  1. We need to understand what kind of protocol we work with right now.

    1. We can't understand it by device's name (because each manufacturer might come up with its own name).
    2. We can't rely on users knowing the protocol. 99.9% of the users don't know their protocol, and the app shouldn't break if the user misclicks.
    3. Therefore, we should somehow understand the protocol ourselves.
  2. We need to send specific write commands in specific situations, attaching maybe dynamic values (for instance, resistance, weight, or something else). These commands are mostly different for each protocol.

  3. We need to parse the response and correctly understand what exactly our device has sent to us. It depends on the protocol because the responses have nothing in common.

Let’s take a closer look at these steps!

 
 

Step 1: Understanding what protocol we work with

Each protocol has something unique, which could help us highlight it among the devices.

 

  • FTMS

The protocol uses very unique identifiers. For instance, its bike- or rower-specific identifiers aren’t used anywhere else. Thus, if we receive a notification from a characteristic or read a characteristic with some FTMS identifiers — we can be sure it's FTMS.

 

  • Delightech & Yeekang/Fitshow/Thinkfit

Both of these protocols use the same identifier. Moreover, this UUID (fff1) is widely used among other Bluetooth devices. Since we can't rely on the identifier itself, we should look at the value we receive — protocols’ responses are always prefixed with the same fields.

We can then basically write a protocol interface and basic implementation:

Here we state that the protocol might have an array of possible characteristic UUIDs, using which we can determine the specific protocol we work with. Also, the protocol might have an array of possible characteristic UUIDs or an array of prefixes for the protocol. Using either of them, we also can determine the protocol.

Also, we can specify protocol.helper.ts, which would take a Protocol, and provide us with a way to understand the device is of Protocol:

Later on, we can use the ProtocolHelpers inside our AppDevice to know if the device is of Protocol:

Having done this, we'll always have an available #currentProtocolHelper inside our AppDevice, which would make all the data parsing / .write() sends. If we don't know the #currentProtocolHelper — it means we weren't able to understand what protocol we work with. In this case, we can show some errors with a timeout.

 
 

Step 2: Sending some particular write commands in specific moments

First thing first, let's agree on what we mean by “particular commands”. In fact, it's just a write command:

  • Sent to a specific characteristic.
  • Sent with specific values (might be dynamic).
  • Sent at a specific moment.
  • Some commands can be sent only for some protocols.

A list of possible commands we might send consists of:

Each of these CommandType is used at least in one or more protocols. Inside our AppDevice, we specify our intention to send some information to the protocol.

However, that’s where the tricky part happens. To send a command, we need to understand what protocol to send it to. At the same time, some protocols (Delightech and Fitshow) don't respond until we send the CONNECT command. How do we handle this chicken and egg situation?

Well, we could send the commands to all the possible protocols if their identifiers are available. So if we send the FTMS-like data to Delightech, they’d just ignore it. However, if we send Delightech data to Delightech — we receive a Delightech response and, thus, can use ProtocolHelper afterward. Simple as that!

Let's firstly update our protocol to let us know what command it can handle:

Here, for a Protocol, we determine commands by creating a record, that contains all the CommandTypes we want to send to the protocol. We may not want to send one of the commands, that’s why it's Partial. We also may send a few requests within one command (for instance, for Yeekang/Fitshow/Thinkfit every second we send 2 write requests to receive a workout notification).

Command consists of the UUID of characteristic to send to (for instance, fff1), and an array of values. Each value can have a few different fields:

Hardcoded fields

For instance, within the write command, we want to have 0x02 at the first byte, and 0x40 at the second byte

Dynamic fields

Can be target speed, resistance, weight, height, whether a workout has started, etc. We'll talk a bit later on how to pass them

Checksum fields

For some protocols, we want to have at some byte a sum of all the bytes before. That's why we have a separate field. We also pass the starting index because the checksum might not always start from the first byte (depends on the protocol)

Here's an example of how it can look:

Then, we need to map these commands for every protocol to a definite array of numbers, which we could later use with Bluetooth:

And, finally, we can use it inside the AppDevice. Remember that if we don’t know the protocol for sure and send a command — we basically send it to every protocol.

You should be careful and send a lot of events with a delay. Android doesn't handle multiple .writes at a time well. The delay should be at least 200ms.

At this point, we have everything we need to work with different devices. We learned how to:

  • send data to all the possible protocols;
  • based on the response, understand what protocol we work with;
  • correctly send data to the protocol we work with.

For now, there’s one last piece left to make it work (except for UI, state management, and all the unnecessary things 😉). Let’s figure out how to correctly process the workout response!

 
 

✅ How to Process Workout Responses

First of all, let's agree on our goals. The idea of this article is to teach you:

  1. Receive a notification with the workout data — done ✅
  2. Parse notification with workout data (burned calories, distance, speed, RPM, heart rate) — we're here 🙂
  3. Store the workout data somewhere and maybe show the UI changes to the user.

It's important to correctly parse notifications, considering all the differences and similarities (image by fajr fitr)

To correctly parse notifications, we need to understand what distinctions and similarities notifications of different protocols have:

  • Since we receive data under different characteristics, they have different IDs. Thus, we need to have it configured somehow.

  • Each field (speed, calories, distance, etc.) might take up to 4 bytes. The output value would be a sum of all these bytes with some multipliers. For instance, we can have 2 bytes for the distance — [1, 250]. The first one will have a *1000 multiplier, and the second byte — a *1 multiplier. So to find how far we’ve got, we need to sum 1 * 1000 + 250 * 1 => 1250meters. We need to be able to mark multipliers inside our code.

  • Different protocols can send data in either decimal or hexadecimal system. We need to tell our app how to handle the field.

  • The last important thing — where and when you should find the exact byte. As you remember, it differs a lot between Fitshow+Delightech and FTMS. In Fitshow+Delightech, we can always find the value on the same byte(s), if we have the required bytes/bits on the first few indices. In FTMS, the position of, for instance, distance, not only depends on the required bytes/bits for the distance, but also on the bytes/bits of the previous fields.

Talking about Fitshow/Delightech, the solution is straightforward here. We need to add an additional field, telling which bytes should satisfy the response before we save the new value. For instance, we can add a separate BytePosition interface. It might also take the array of bits within the byte we need to satisfy the rules:

The field might look something like this:

Here we state that we want to save a new speed, if we received a notification:

  • from characteristic 'fff1';
  • when on the 0 index the value is 0x20, and on the 1 index the bits are 1 on 2 index, and 0 on 3 index.

Also, we want to have this field:

  • in a decimal format;
  • take it from the index 4th byte;
  • take 2 bytes, and multiply them with [10, 1].

Let's take a look at the example of how it could look:

{value: [0x20, 0x8, X, X, 10, 10, X, X], uuid: 'fff1'}

We wouldn't save it because the 0x8 has the 0 on 2 index and 1 on 3 index

{value: [0x20, 0x4, X, X, 10, 10, X, X], uuid: 'fff1'}

We would save it because all the rules match. Using the multipliers, the new speed would be 110 km/h. Pretty fast, huh?

{value: [0x20, 0x4, X, X, 10, 10, X, X], uuid: 'fff2'}

We wouldn't save it because of the UUID mismatch

It works just fine for Delightech and Fitshow as it allows to correctly understand the values. However, it has a few flaws with the FTMS. As you might remember, we have value shifts for the FTMS. It means that, for instance, if we have speed, we will have the distance at byte 4. However, if we don't have speed, we will have the distance at byte 2. And it goes crazier the more combinations we have.

There are 2 possible solutions here:

  1. We can set up a special class/function to handle the offsets here.

For instance, we pass the characteristic array, the UUID, and all the fields we have, and it goes one by one in an array (starting with the first value), increasing the startingByte for all the next ones. Thus, we'll add a separate field sholudShift or something like this. It's a working solution, that doesn’t have any serious downsides. However, what I don’t like about it is that we lose the flexibility and divide our parsing into 2 different pipelines, with only 1 flag changing the behavior. In case we have another protocol with something in between — it might not work that well.

  1. Alternatively, we can have a special mapper function that makes all the offsets in the build-time and leaves us with a ton of different matchRules, depending on what we have.

For instance:

  • If we have no other values, look at the speed at byte 2.
  • If we have only distance, look at the speed at byte 4.
  • If we have distance and calories, look at the speed at byte 6.
  • If we have distance, calories, and RPM, look at the speed at byte 8.

And we'll have several rules like these for each field. Then, during the parsing, we'd just find the appropriate matchRule, and if we do find any — look at the startingByte and make the computations.

With this implementation, our code for Fitshow, Delightech, and FTMS looks basically the same. The only difference is that FTMS is much bigger 🙂

You can choose either of these solutions, but we used the second one, and didn't see any issues with it so far.

Let's take a look at how our mapper could look:

Here we have a simple switch on whether the field should be offset or not. If yes, we go through the positions, adding 1 rule to the existing matchRules and increasing the startingByte. Basically, the situation when we have ONLY this field (for instance, the speed at position 2) changes to the situation where we have every possible combination of fields. For instance, speed, distance, calories, speed, and RPM. In this case, speed is at position 10.

Let's take a look at how our Protocol might look:

Using the deviceFields, you can understand what fields you can work with and save them once you understand the protocol. Let's finalize and see how it could work:

Here we introduce 2 new functions — getNewDeviceField and getNewValue. We use them to:

  • return the updated device field;
  • understand what matchRule to use;
  • make all the necessary math to find the value.

At this point, I think we have everything to make sure your app correctly processes all the protocols, saves the values, understands the protocols, and has no issues adding another protocol as it won’t change any line of the code. In fact, we could even bring the protocols to some outer API and load it on demand to lower our app bundle.

Still, there are quite a few protocol-related issues, which we'll briefly discuss in the next part.

 
 

Delightech

  • When you disconnect the device from your app — the device turns off. Yet, even though the device triggers all the "disconnect" events inside our code, it still has Bluetooth turned on for around 7 seconds. Moreover, the device behaves with our Bluetooth as if it was turned on — it means that it's again available for scanning. Default behavior would be as follows: we connect the device, disconnect it, receive the listener's calls, and then get notified once again that the device is available. However, the disconnection process is already in progress. It means that the device will anyway NOT respond to our calls and won’t let us know it's off. Instead, we'll just behave with the device as if it's turned on, meanwhile it's not. The solution here is to have a delay between disconnecting and accepting the same scanned device.

  • It’s a normal thing for many users to pause a workout. During the pause, all the values are sent as 0. So when we resume the workout, it goes back from 0. It means that if we were working out for some time, for example, rode 1 km and then paused, after resuming we'll have 0. The solution here is to catch the paused behavior and store the values which need to be added up in a special state. Later on, we sum up the values before and after the pause. You can add a custom isAccumulative field to DeviceField to make it reusable.

  • Don't send too many write requests to receive more real-time updates. If you send requests more often than once a second — your device will end up disconnected. One second is a preferred interval.

 
 

FTMS

  • You can automatically detect if the workout is paused. When the workout is paused, all the data is passed as 0. However, if in case of Delightech, when the workout resumes it stays at 0 and increases over time, for FTMS it’s brought back to the initial value when unpaused. It means that you should avoid these 0 values only when paused. As always, you can add an additional field to DeviceField.

  • Even though we have a separate characteristic for each device type, not all the manufacturers follow this rule. You might have a few notifications from different characteristics within one device. For instance, if you exercise on a bike, you might receive values from bike, rower, and cross-trainer characteristics.

  • FTMS doc doesn't really share the characteristic UUID with us. You can take a look at them here.

There are many more interesting things regarding FTMS. If you want to dig into it, take a look at this article and check the comment section as well. It helped us a lot to sort out a lot of questions.

 

Yeekang / Fitshow / Thinkfit

Don't send too many write requests to receive more real-time updates. If you send requests more often than once a second — your device ends up disconnected. One second is a preferred interval.

The only important difference from what we expected to see based on the docs — we noticed was the wrong data inside the protocol PDF. To "obtain movement" (distance/calories/count/time) the protocol says the first bytes are [0x02, 0x43, 0x01]. However, it was [0x2c, 0x0b]. We're not sure if it's true for all the Fitshow/Thinkfit devices, but it might be useful to take a look at both prefixes and work with them.

 
 

🔄 Reverse Engineering: Getting BLE Logs Based on the Existing Apps

Right now you have all the information you might need regarding the architecture, setup, and protocols when creating an app for fitness devices using React Native. The last thing to cope with is making the correct mapping for the devices. You can use the protocol, although it might not always be easy for you to understand what command follows what response. If you have a device in place, you can connect using any existing fitness-related app (or using Sportplus 🙂) to your device, and record all the Bluetooth logs. Here's a short guide on how to do it.

You can, later on, go through the logs using Wireshark and understand all the Bluetooth commands you need to send and receive. You need to have a fitness machine for that and an Android device (not available on iOS).

In case you don't have either of that, we would be glad to share with you a list of our logs here.

If you’re looking for a Tech Partner to integrate your application with a fitness device of your choice, drop us a line and we’ll see how we can help you!

Contact us

Read also

React Native: a Comprehensive Guide for Startups

React Native: a Comprehensive Guide for Startups

What to Consider when Integrating BLE in your React Native App

What to Consider when Integrating BLE in your React Native App

How to Integrate BLE-Based Fitness Devices into your App

How can we help you?

Our clients say

Stormotion client Pietro Saccomani, Founder from [object Object]

They make the whole business work for us, and their improvements are fundamental to our operations. They’re reliable, honest, and willing to try new things that will help us. We appreciate how flexible and easygoing they are.

Pietro Saccomani, Founder

MobiLoud