This is part of a few posts on trying to understand my treadmill’s Bluetooth.

  1. Getting Data
  2. Analyzing Writes
  3. Sending Data
  4. Analyzing Reads (this post)
  5. Wrapping Up

Understanding the reads

We can now write arbitrary speed/incline values to the treadmill. However that’s only half the battle - we also need to understand how to read/interpret the current state. This will allow us to pull out the info and do whatever we please.

Back to the analysis phase we go, this time with an advantage! When analyzing the writes we were limited to what we logged in our subpar capture. Now that we know how to control the treadmill, we can also subscribe to notifications and get back the current state. This means we can apply the ‘press random buttons’ approach to try to zero in on the corresponding bytes.

Packet capture

Before generating our own values, let’s take another look at our capture. Although using the ‘live’ state is useful, having a static data set is much easier/faster to explore since it doesn’t require having the treadmill around.

This time we’ll look at packets from the treadmill to the phone:

$ tshark \
    -r ./btsnoop_hci.log \
    -Y '(btl2cap.cid == 0x0004) && (bluetooth.src == <MAC>) && (bluetooth.dst == <MAC>)' \
    -2 \
    -R btatt.handle \
    -T fields \
     -e btatt.value

Running this through the same ‘concat multiple packets into a single line’ approach yields 453 multi-packet lines. Assuming the same packet structure as the writes, there are multiple four-packet responses, usually followed by a single two-packet response.

Identifying control values

Before identifying the values we want (speed, incline, etc.), let’s see if we can simplify what we’re looking at.

First, here are two consecutive packet sequences with some minor formatting:

fe02 28 0400580200000000000000003100b40000
  00 12 01040224042402020000ffffffffffffffff
  01 12 0000000046160000000000302a0000000000
  ff 04 000000da46160000000000302a0000000000

fe02 32 0400da46160000000000302a0000000000
  00 12 0104022e042e0202c1000000690009000000
  01 12 0000000002170017000000948c0100b40000
  ff 0e 006700580200000000000000002f00b40000

Similar to the writes, let’s assume that the dark red values are packet markers/counts.

Similarly, assume the size of the data is in yellow (first packet: total size, each subsequent packet: size of that packet). The only packet that doesn’t match is the last one, but we’ll get to that in a second.

The values in green are used as both data (when at the end of a sequence) and as a sequence number (when at the beginning) - note how the beginning of a sequence always contains the last 16 bytes of the previous ‘end’ packet. We’re calling this a ‘sequence number’ when it appears in the first packet because it cannot contain ‘interesting’ data (it always matches the data in the previous packet), but it does ensure a link with the previous packets.

Finally, the sequence number in the first packet is fixed at 16 bytes, but what if the last packet of the last sequence only had 4 bytes of real data? Simple - pad it! The dark yellow bytes are the ‘real’ data, and the rest are just pulled from the previous packet as needed.

With all of that stripped, the remaining bytes must be the ‘real’ data.

Automating

At this point, let’s start applying some Computer Programming™. This will allow us to read our file dump (and eventually, real data), strip out/verify the control data and print just the bytes containing the useful stuff.

The final script(s) are here , and the gist is:

  • Create a PacketProducer that just yields packets; test version reads from our file, ‘real’ version talks to the treadmill
  • Create a ResponseReader that reads the stream of packets from the producer and assembles them into a bytearray of raw data
  • Create Response objects that can parse raw data into structured views

Some hours later

Okay, great - now rather than stare at a bunch of extraneous hex bytes, we can stare at potentially useful ones! For example:

0104022e0202a000c8006d00100000000000000002290029000000b6c70200b4001d
0104022e0202a000c8006d001100000000000000022a002a000000b8da0200b40021

This may not seem like a huge improvement, but it makes exploration a lot easier.

Identifying bytes

First, every response starts with 0x010402:

$ python3 response_reader.py | cut -c-6 | sort | uniq -c | sort -r
    454 010402

I suspect this is some sort of device identifier. iFit is used on multiple devices (treadmills, watches, bikes, etc.), so it makes sense that the protocol has a way to identify the device so clients can adjust their features accordingly (e.g., do you really need incline percentage on a smart mirror?).

Now that we have a script, we can store/prune this and keep moving. For example, let’s look for a state where the pace was 1.0 and the incline was 2.5:

$ python3 response_reader.py | grep a000fa00
2e042e02a000fa006e001200000000000000022d002d00000061140300b4002d
2e042e02a000fa006e001300000000000000022e002e00000006280300b40032
2e042e02a000fa006e00130000000000000002300030000000ab3b0300b40036

That 8-byte prefix 2e042e02 appears on about half of the responses:

$ python3 response_reader.py | cut -c-6 | sort | uniq -c | sort -r
    211 2e042e02
    210 24042402
     24 05040502
      2 06040690
      1 23042302
      1 21042182
      1 1d041d81
      1 1c041c84
      1 18041895
      1 12041280
      1 0d040d88

The 210 ‘other’ responses look like heartbeats (similar patterns, none of our data of interest), and the rest appear at the beginning / are probably startup stuff). Let’s use this prefix as the response type. Why? The client has to know how to handle the bytes it received, and it seems brittle to make it stateful (e.g. if you sent request A and request B, there is no guarantee that response A would be received before response B). We’ll call 2e042e02 the TreadmillStateResponse.

Incline / Speed

Now that we have some structure, let’s start identifying things. We can follow a simple pattern: every time we identify a new byte, add it to the structured output and mask it from the raw data, leaving only unidentified bytes.

For example, we know incline/speed, so let’s add them. Here is a packet where the speed was 1.0 and the incline was 3.0:

1.0 mph    3.0% incline    02        71001600000000000000023600360000000cc90300b4005500690058026c0700006c070000

Distance

Next, let’s look for the distance, which should be an always-increasing value measured in meters. One sequence looks interesting:

02        6e00 1300 00000000000002300030000000ab3b0300b4003600680058021a0400001a040000
02        7100 1400 000000000000023100310000009a630300b4003f00680058021405000014050000
02        7100 1400 00000000000002320032000000e4770300b4004400680058021405000014050000
02        7100 1500 000000000000023300330000002e8c0300b4004800690058024006000040060000
02        7100 1500 0000000000000234003400000078a00300b4004c00690058024006000040060000

This does not increase fast enough to be the timer. The pace above is 1.0 MPH, and 1.0 MPH == 1.6 KPH == 0.44 meters per second. This means we could only register a value change every ~2.5 seconds, which lines up with the data above.

02 a000 2c01 6e00 1300 00000000000002300030000000ab3b0300b4003600680058021a0400001a040000
02 a000 2c01 7100 1400 000000000000023100310000009a630300b4003f00680058021405000014050000
02 a000 2c01 7100 1400 00000000000002320032000000e4770300b4004400680058021405000014050000
02 a000 2c01 7100 1500 000000000000023300330000002e8c0300b4004800690058024006000040060000
02 a000 2c01 7100 1500 0000000000000234003400000078a00300b4004c00690058024006000040060000

Speed: pace in kilometers/hour * 100
Incline: incline percentage * 100
Distance: kilometers * 1000

Timer

To wrap this up, let’s find the timer. We know the distance lags a little, is there something that much more closely follows the time? A short bit of snooping around yields a hit in the next packet:

02 a000 2c01 6e00 1300 000000000000023000 3000 0000ab3b0300b4003600680058021a0400001a040000
02 a000 2c01 7100 1400 000000000000023100 3100 00009a630300b4003f00680058021405000014050000
02 a000 2c01 7100 1400 000000000000023200 3200 0000e4770300b4004400680058021405000014050000
02 a000 2c01 7100 1500 000000000000023300 3300 00002e8c0300b4004800690058024006000040060000
02 a000 2c01 7100 1500 000000000000023400 3400 000078a00300b4004c00690058024006000040060000

Speed: pace in kilometers/hour * 100
Incline: incline percentage * 100
Distance: kilometers * 1000
Timer: number of seconds elapsed

This value lines up very well with the tshark output, and why get bashful now? This is officially the timer. One problem with this being two bytes is that it only supports up to 18 hours (0xFFFF == 65536 seconds == 18.2 hours). It is definitely possible the next byte is also storage for the hour, but I’m not leaving this thing on for 18 hours to find out. Plus, if I’m ever on the treadmill for more than 18 hours I’ve got other problems.

Let’s do it live!

Finally, let’s see if we can get some real data interactively.

Using gatttool

Although we will be using a BluetoothPacketProducer in prod (see below), let’s do this with gatttool just for completeness. In the post on sending commands , one thing we omitted was the full output when replaying the session. Remember how we subscribed to notifications on handle 0x000b?

[MAC][LE]> char-write-req 0x000C 0100

Well we also saw a lot of this:

[MAC][LE]> char-write-req 0x000e fe021403
[MAC][LE]> char-write-req 0x000e 001202040210041002000a1b9430000040500080
[MAC][LE]> char-write-req 0x000e ff02182700000000000000000000000000000000
Characteristic value was written successfully
Characteristic value was written successfully
Characteristic value was written successfully
Notification handle = 0x000b value: fe 02 32 04 02 06 04 06 90 02 08 a4 9d 0e 00 7c 02 b4 00 2b
Notification handle = 0x000b value: 00 12 01 04 02 2e 04 2e 02 02 a0 00 2c 01 71 00 8b 0c 00 00
Notification handle = 0x000b value: 01 12 00 00 00 01 02 19 00 ad 17 00 00 68 df 27 02 b4 00 2b
Notification handle = 0x000b value: ff 0e 01 79 00 58 02 ca ae 0e 00 ca ae 0e 00 1a 02 b4 00 2b

Note the highlghted bytes - this definitely looks like our state. Interestingly, this only appears when those packets above are written. Issuing a command to change the state does not respond with the state - instead, it responds with this:

[MAC][LE]> char-write-req 0x000e fe020d02
[MAC][LE]> char-write-req 0x000e ff0d020402090409020101250300000000000000
Characteristic value was written successfully
Characteristic value was written successfully
Notification handle = 0x000b value: fe 02 09 02 02 05 04 05 02 02 0d 00 00 30 2a 00 00 00 00 00
Notification handle = 0x000b value: ff 09 01 04 02 05 04 05 02 02 0d 00 00 30 2a 00 00 00 00 00

Note this is still on handle 0x000b, but it contains something else. It looks like turning on notifications just tells the treadmill we’re ready to receive whatever it gives us, but what it gives us depends on what we send over. Seems reasonable - this means clients don’t get notifications out of the blue (which would be hard to interpret since there can apparently be multiple types) and instead always know what to expect.

Well this is cool then! Now we can now write another simple script that just dumps the state of the treadmill every second:

# Start a socket with and connect to gatttool:
#
#   nc -lkU /tmp/treadmill.sock | gatttool -I -t random -b MAC
#
# Then run this.
echo "**** NordicTrack T6.5S Listener ****"

echo -n "Connecting..."
echo connect | nc -U /tmp/treadmill.sock -N
sleep 2
# Yeah yeah, this doesn't check whether the connection succeeded, lay off.
echo "done"

# Subscribe to notifications.
echo -n "Subscribing to notifications..."
echo char-write-req 0x000C 0100 | nc -U /tmp/treadmill.sock -N
echo "done"

echo -n "Writing magic incantation..."
echo char-write-req 0x000e fe022c04 | nc -U /tmp/treadmill.sock -N
echo char-write-req 0x000e 0012020402280428900701cec4b0aaa2a8949696 | nc -U /tmp/treadmill.sock -N
echo char-write-req 0x000e 0112aca8a2bad0dccefe14003a52786486a6fc18 | nc -U /tmp/treadmill.sock -N
echo char-write-req 0x000e ff08324aa0880200004400000000000000000000 | nc -U /tmp/treadmill.sock -N
sleep 4
echo "done"

echo
echo "Initializing polling"
echo

while :
do
  echo char-write-req 0x000e fe021403 | nc -U /tmp/treadmill.sock -N
  echo char-write-req 0x000e 001202040210041002000a1b9430000040500080 | nc -U /tmp/treadmill.sock -N
  echo char-write-req 0x000e ff02182700000000000000000000000000000000 | nc -U /tmp/treadmill.sock -N
  sleep 1
done

However this doesn’t make use of our fancy new objects - let’s do that.

Using a Bluetooth PacketProducer

Using our automation, we can swap out our file-based PacketProducer with one that reads from the treadmill. Glossing over plenty of details, we’ll use pygatt (which uses gatttool under the hood, just like we did earlier) to connect and then plug everything in. Fortunately, everything Just Works and we can get structured data in a much more readable format:

1.1 mph    0.0% incline    0.003 miles      12 seconds
1.1 mph    0.0% incline    0.003 miles      13 seconds
1.2 mph    0.0% incline    0.004 miles      15 seconds
1.2 mph    0.0% incline    0.004 miles      16 seconds
1.2 mph    0.0% incline    0.004 miles      17 seconds
1.3 mph    0.0% incline    0.004 miles      18 seconds
1.3 mph    0.0% incline    0.005 miles      19 seconds

This is…great! Although we are missing some stuff (calories, etc.), this is enough to at least start logging info so we can do all kind of advanced analysis/workout planning (read: nod head, do nothing and continue just running slow 5Ks).

It’s finally time to wrap this thing up .

Sidebar:Calories and vertical feet

My white whale, my Eleanor (Gone in 60 Seconds references are still cool, yeah?). I really wanted to find the calorie count/vertical feet but came up short. No amount of fuzzing/experimenting/guessing yielded anything useful. Although it is possible this is calculated client-side, it doesn’t seem likely - calories/vertical feet depend on previous state (since changes in pace/incline affect both values), meaning each client would have to recalculate it themselves.

Therefore there must either be some sort of post-calculation generating those values (e.g. converting to joules or something), or they are returned in a separate response. In either case, we can come back to this.

For reference here is a capture where the display showed 368±1 calories and 314±1 vertical feet:

________7100____000000000001027b03____0000942d2502b4002b0179005802369d0e00369d0e007c