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

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

Finding a pattern

Now that we have some data, let’s look for sequences that contain commands (e.g. increase the speed) and ignore everything else. First, there are two repetitive lines (which usually alternated); these may be a heartbeat/keep-alive, but they definitely don’t contain our data:

fe021403 001202040210041002000a1b9430000040500080 ff02182700000000000000000000000000000000
fe021903 001202040215041502000f800a41000000000000 ff07000000810010860000000000000000000000

Removing those cuts out a lot of noise:

0100
fe020802 ff08020402040204818700000000000000000000
fe020802 ff08020402040404808800000000000000000000
fe020802 ff08020402040404889000000000000000000000
fe020a02 ff0a0204020602068200008a0000000000000000
fe020a02 ff0a0204020602068400008c0000000000000000
fe020802 ff08020402040204959b00000000000000000000
fe022c04 0012020402280428900701cec4b0aaa2a8949696 0112aca8a2bad0dccefe14003a52786486a6fc18 ff08324aa0880200004400000000000000000000
fe021903 001202040215041502000f001000d81c480000e0 ff070000001000086e0000000000000000000000
fe021903 0012020402150415020e00000000000000000000 ff070000001001003a0000000000000000000000
fe021703 0012020402130413020c00000000000000000000 ff0500800000a500000000000000000000000000
fe021703 0012020402130413020c00000000000000000000 ff0500800000a500000000000000000000000000
fe021703 0012020402130413020c00000000000000000000 ff0500800000a500000000000000000000000000
fe021703 0012020402130413020c00000000000000000000 ff0500800000a500000000000000000000000000
fe022c04 0012020402280428900701cec4b0aaa2a8949696 0112aca8a2bad0dccefe14003a52786486a6fc18 ff08324aa0880200004400000000000000000000
fe022003 00120204021c041c020900004002184000008030 ff0e2a0000c720580200b400580200ee00000000
fe021102 ff110204020d040d02020310a00000000a00d200
fe021102 ff110204020d040d02020310a00000000200ca00
fe020d02 ff0d020402090409020101b10000c20000000000
fe020d02 ff0d020402090409020101c10000d20000000000
fe020d02 ff0d020402090409020101d10000e20000000000
fe020d02 ff0d020402090409020101c10000d20000000000
fe020d02 ff0d020402090409020101b10000c20000000000
fe020d02 ff0d020402090409020101a00000b10000000000
fe020d02 ff0d020402090409020102320000440000000000
fe020d02 ff0d020402090409020102640000760000000000
fe020d02 ff0d020402090409020102960000a80000000000
fe020d02 ff0d020402090409020102c80000da0000000000
fe020d02 ff0d020402090409020102fa00000c0000000000
fe020d02 ff0d0204020904090201022c01003f0000000000
fe020d02 ff0d020402090409020200100400250000000000
fe020f02 ff0f0204020b040b020202100000010026000000
fe021703 0012020402130413020c00000000000000000000 ff0500800100a600000000000000000000000000
fe021703 0012020402130413020c00000000000000000000 ff0500800100a600000000000000000000000000

The data doesn’t make sense yet, but we do know that we issued ~10 commands. That makes the chunk in green interesting - the majority of the lines (13) start with fe020d02, followed by data that looks roughly similar. Here it is broken into byte-sized chunks:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 b1 00 00 c2 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 c1 00 00 d2 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 d1 00 00 e2 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 c1 00 00 d2 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 b1 00 00 c2 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 32 00 00 44 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 64 00 00 76 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 96 00 00 a8 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 c8 00 00 da 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 fa 00 00 0c 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 2c 01 00 3f 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 02 00 10 04 00 25 0000000000

Randomly staring at those numbers surfaces the pattern in green - two of the values increase and then decrease. This must be ‘the data’, so let’s try to figure out what it means.

Unpacking the packets

Speed

Looking at just the first value:

In [1]: for b in 'b1', 'c1', 'd1', 'c1', 'b1', 'a0':
   ...:     print(int(b, 16))
   ...:
177
193
209
193
177
160

These numbers aren’t immediately familiar. However thinking a bit, the numbers on the treadmill have decimals, so multiplying by 100 would make it easier to store as integers. Let’s try dividing by 100:

In [2]: for b in 'b1', 'c1', 'd1', 'c1', 'b1', 'a0':
   ...:     print(int(b, 16) / 100)
   ...:
1.77
1.93
2.09
1.93
1.77
1.6

This looks a little more in the ballpark but still not right. However in any domain with Metric vs. Imperial units, why not try the other? Multiplying by 0.621 converts kilometers to miles, and with some rounding:

In [3]: for b in 'b1', 'c1', 'd1', 'c1', 'b1', 'a0':
   ...:     print(round((int(b, 16) / 100) * 0.621, 1))
   ...:
1.1
1.2
1.3
1.2
1.1
1.0

Alright, granted our sample is not great, but that exactly matches what we did (in increments of 0.1 MPH we went from 1.0 -> 1.3 -> 1.0).

As for the other values, unclear - they mirror the pattern but are always 0x10 larger. Trying to convert them to/from Imperial/Metric doesn’t yield anything interesting. Definitely seems related, but rather than spin our wheels let’s keep going - we seem to have found the speed and that other value is always 0x10 higher, so we can just blindly include that when we make our own command.

Incline

Let’s try the incline. We increased the incline from 0.0 to 3.0 in increments of 0.5, so let’s look for that in the next sequence:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 32 00 00 44 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 64 00 00 76 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 96 00 00 a8 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 c8 00 00 da 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 fa 00 00 0c 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 2c 01 00 3f 0000000000

The highlighted bytes are in the same position as those we found for the speed, so let’s try parsing in the same way:

In [10]: for b in '32', '64', '96', 'c8', 'fa', '2c':
    ...:     print(int(b, 16))
    ...:
50
100
150
200
250
44

Close! After dividing by 100 that would match the 0.5 increases, except for the last one. Thinking about it, a byte can only represent 0-255, so how would you represent an incline of 3.0? One way is to use two bytes - if we assume the 01 in red above is also part of the value (little endian):

In [11]: for b in '32', '64', '96', 'c8', 'fa', '012c':
    ...:     print(int(b, 16))
    ...:
50
100
150
200
250
300

Splendid - it looks like we can now identify increases/decreases in speed/incline. We can also assume that the byte just before the value indicates the command (0x01 == SPEED, 0x02 == INCLINE, etc.):

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
fe020d02 ff 0d 02 04 02 09 04 09 02 01 02 32 00 00 44 0000000000

Naturally we have no way of knowing if this is right, but based on what we’ve seen it is hopefully not wrong? Who knows, let’s keep going.

Understanding what’s left

Now…the rest. We now know how to identify some of the data within a packet, so let’s see if we can figure out the rest using the advanced ‘make random guesses’ approach. Let’s go back to one of the commands we just deciphered and mark the bytes we know:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
-------- -- -- -- -- -- -- -- -- -- -- CC DD DD DD DD DDDDDDDDDD

Where:

CC: command
DD: data

For the others, we can start at the beginning. First thing that pops out - the first two bytes (fe02) are always the same for every packet in the file. Let’s mark this as the Beginning of a sequence (and while we’re at it, we can use the same color to indicate the beginning of the End packet):

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBB---- EE -- -- -- -- -- -- -- -- -- CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
CC: command
DD: data

Next we can start to think about the protocol. Let’s make a few assumptions:

  • Serial communication: only one command/sequence at a time is sent, and the packets are always in order; in other words, the treadmill would probably be befuddled if you sent two commands in parallel and interwove their packets
  • Commands require no previous knowledge: a command can be issued at any time, without any knowledge of the existing state (maybe not 100% true but makes starting the analysis easier)
  • The first packet contains info about what follows: the first packet must contain some basic info like ‘how many packets follow this one’

Armed with these assumptions, let’s go spelunking for the number of packets. Fortunately, the first packet is pretty small and we already identified half of the bytes. Grabbing a few samples of varying length and just looking at the unknown bytes of the first packet, we may have a winner:

fe020802 ff08020402040204818700000000000000000000
fe020a02 ff0a0204020602068200008a0000000000000000

fe022c04 0012020402280428900701cec4b0aaa2a8949696 0112aca8a2bad0dccefe14003a52786486a6fc18 ff08324aa0880200004400000000000000000000

fe021903 001202040215041502000f001000d81c480000e0 ff070000001000086e0000000000000000000000

Scanning the rest of the file, this byte always matches the number of packets in the sequence, so let add the Number of packets to our collection:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBB--NN EE -- -- -- -- -- -- -- -- -- CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
NN: number of packets
CC: command
DD: data

Now for that last byte. Thinking about things from the treadmill side, how does it know which bytes contain the data to process? It knows how many packets to expect, but within those packets which data is useful? For example, here is the command to set the speed to 1.0:

fe020d02 ff0d020402090409020101a00000b10000000000

Look at the end - those 0x00’s are very likely just padding used to make the packet length consistent. Why? One reason is it makes processing simpler - you can allocate (or reuse) a fixed amount of memory for a packet since you know they will all be the same length. This means the treadmill can receive a packet and dump the entire thing into fixed-length buffer for further processing. You may reasonably be thinking ‘But why can’t it just stop at the first NULL byte? There are plenty of them!’. However note the packet above - there are multiple NULL bytes between a0 and b1, which means NULL bytes can be present in the actual data.

Therefore there must be a way for the treadmill firmware to know exactly how many ‘useful’ bytes are in a sequence/packet. Looking at a few sequences, that remaining unknown byte in the first packet looks promising:

fe020802 ff 08 020402040204818700000000000000000000
fe020802 ff 08 0204020602068200008a0000000000000000
fe021903 00 12 02040215041502000f001000d81c480000e0
         ff 07 0000001000086e0000000000000000000000

It also looks like the number reappears in the last packet, which makes sense - packets arrive independently, so each one needs to tell the firmware how many bytes it contains.

Note the last packets - the number in the first packet (0x19) is the sum of the sizes of the following packets (0x12 and 0x07). This pattern holds for the rest of the sequences in the file, so we can now identify the Size of a sequence/its packets:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBBSSNN EE SS -- -- -- -- -- -- -- -- CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
SS: size of a sequence/packet
NN: number of packets
CC: command
DD: data

Moving on! There is another pattern that appears in every single second packet in a sequence, regardless of how many packets there are:

fe020802 ff08020402040204959b00000000000000000000
fe022c04 0012020402280428900701cec4b0aaa2a8949696 0112aca8a2bad0dccefe14003a52786486a6fc18 ff08324aa0880200004400000000000000000000
...
fe021903 001202040215041502000f001000d81c480000e0 ff070000001000086e0000000000000000000000
fe022003 00120204021c041c020900004002184000008030 ff0e2a0000c720580200b400580200ee00000000
fe021102 ff110204020d040d02020310a00000000a00d200
...
fe021703 0012020402130413020c00000000000000000000 ff0500800100a600000000000000000000000000
fe021703 0012020402130413020c00000000000000000000 ff0500800100a600000000000000000000000000

Not too sure about that one, so let’s just consider it a Constant for now:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBBSSNN EE SS CC CC CC -- -- -- -- -- CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
SS: size of a sequence/packet
CC: some constant value
NN: number of packets
CC: command
DD: data

At this point we’re still in ‘Look for simple patterns without thinking too deeply’ mode. Fortunately, some of the remaining five bytes appear to have such a pattern:

fe022c04 0012 020402 28 04 28 900701cec4b0aaa2a8949696 0112aca8a2bad0dccefe14003a52786486a6fc18 ff08324aa0880200004400000000000000000000
fe022003 0012 020402 1c 04 1c 020900004002184000008030 ff0e2a0000c720580200b400580200ee00000000
fe021102 ff11 020402 0d 04 0d 02020310a00000000a00d200

With little squinting (and applying some logic/guessing from earlier), this looks like it describes the size of the ‘data’. For example, look at these two commands:

fe021102 ff11 020402 0d 04 0d 02 02 03 10 a0 00 00 00 0a 00 d2 00
fe020d02 ff0d 020402 09 04 09 02 01 01 c1 00 00 d2 00 00 00 00 00

Notice how the highlighted value matches the number of bytes that follow. This seems to apply to three-request sequences as well (note the header bytes of the third packet are ignored - they already have a byte count):

fe021903 0012 020402 15 04 15 02 00 0f 00 10 00 d8 1c 48 00 00 e0
         ff07 00 00 00 10 00 08 6e 00 00 00 00 00 00 00 00 00 00 00

For a four-request sequence, looks to be the same:

fe022c04 0012 020402 28 04 28 900701cec4b0aaa2a8949696
0112aca8a2bad0dccefe14003a52786486a6fc18
ff08 324aa0880200004400000000000000000000

So let’s call that the data siZe (running out of letters):

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBBSSNN EE SS CC CC CC ZZ -- ZZ -- -- CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
SS: size of a sequence/packet
CC: some constant value
ZZ: size of data
NN: number of packets
CC: command
DD: data

Now for the last three. The 0x04 is interesting because it is constant throuought the capture, except for a few packets at the beginning of the conversation:

fe020802 ff08 020402 040204 818700000000000000000000
fe020802 ff08 020402 040404 808800000000000000000000
fe020802 ff08 020402 040404 889000000000000000000000
fe020a02 ff0a 020402 060206 8200008a0000000000000000
fe020a02 ff0a 020402 060206 8400008c0000000000000000
fe020802 ff08 020402 040204 959b00000000000000000000

Since those packets happen at the beginning, maybe it is part of the initial handshake protocol, and afterwards everything happens with the value 0x04? Unclear, but let’s just mark it as a constant for now - when we try to create a session later we’ll just replay the initial packets we’ve captured and hope it all works.

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBBSSNN EE SS CC CC CC ZZ CC ZZ -- -- CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
SS: size of a sequence/packet
CC: some constant value
ZZ: size of data
NN: number of packets
CC: command
DD: data

And now the last two. Scanning the other packets, maybe this is some sort of high-level indicator about what kind of data to expect? For example, the two bytes are:

  • 0201 when changing the speed/incline
  • 0200 for the bulk of the capture (best guess: heartbeat/keep-alives)
  • 020c for a few random packets
  • A mixture of values at the beginning (8187, 8088, 8890, etc.)

Although we could try to determine exactly what these packets meant, it may not be worth our time - we know that we can send commands with 0201 and a keep-alive/something with 0200 (the bytes of the whole sequence never change). For 020c, let’s just skip it and sort it out later - it may not even be important (famous last words…).

Finally, the initial bytes (8187, etc.) are probably important, but if we assume those are some sort of handshake, perhaps we can just replay them at the beginning of a session? There are multiple reasons this may not work (unique session IDs, some kind of SYN, SYN-ACK, ACK flow that requires a specific/changing response, etc.), but trying to sort it out now is a little pointless since we can’t yet verify anything; plus, it would risk stymied progress and a reduction in interest, which we can’t have! So let’s mark those bytes as Operation and move on:

fe020d02 ff 0d 02 04 02 09 04 09 02 01 01 a0 00 00 b1 0000000000
BBBBSSNN EE SS CC CC CC ZZ CC ZZ OO OO CC DD DD DD DD DDDDDDDDDD

BB/EE: beginning/end of a packet
SS: size of a sequence/packet
CC: some constant value
ZZ: size of data
NN: number of packets
OO: operation
CC: command
DD: data

Note this conveniently ignores all of the three- and four-byte sequences, but hopefully this will get us started. Remember, we’re not trying to fully reverse engineer everything - all we want to do is make a dirt-simple program that can send some basic commands to the treadmill and read the current state.

So assuming we’re not totally off-base, let’s put it to the test!