Zwift Ride protocol

      Comments Off on Zwift Ride protocol

It’s quite incredible to me how a company like Zwift wavers in its commitment to hardware manufacturing. One moment they’re in, the next they’re out, and then they’re back in again. What’s even more perplexing is how, with every iteration, they drastically change their protocols between products that should, in principle, have the same behavior and features. Their developers must be fairly unhappy having to support so many variants.

Today, I’m talking about the new “Zwift Ride,” a bike frame for indoor training that pairs with a trainer to become a substitute for an indoor smart bike at a fraction of the price. The only special feature of this bike frame is its ability to be quickly configured to fit different riders/users, and it includes a pair of Bluetooth controllers very similar to the Zwift Play. The new controllers have a few more buttons but retain most of the same functions. To my surprise, Zwift got rid of the Bluetooth communication encryption they were using for the Play and the Click.

This leads me to believe that the encryption’s only purpose was not to secure the protocol but to obscure it. Do they no longer need security?

However, there is a smart move regarding these new controllers. All key presses are tunneled through the left controller, using just one Bluetooth connection. This is a nice improvement compared to Zwift Play, where you had to pair two different devices.

BLE advertising

This information is a follow-up to my previous article about Zwift Play controllers. The advertising packets are exactly the same, with just one difference: the two new controllers, Ride Right and Ride Left, have their own manufacturer device IDs, 7 and 8, respectively. However, we only need to connect to the left-side controller. Somehow, the controllers find each other and negotiate a link to transmit right-side key presses to the left-side controller.

The advertised device name is Zwift SF2 for both controllers.

This is again the manufacturer information data contained in the advertising packet. Notice the new device IDs for the Ride (and extra ball, the one for the Hub)

Manufacturer ID (2 byte)Device ID (1 byte)Short Address(2 byte)
0x094A1 = Zwift Hub
2 = Play Right
3 = Play Left
7 = Ride Right
8 = Ride Left
9 = Click
Last 2 bytes of the BLE address

The Protocol

With the encryption out of the picture, I’m not going to explain again the BLE characteristics used for communication, as they are exactly the same. The only difference when establishing the connection is the handshake.

No encryption means no need to exchange encryption keys. The handshake has been simplified to writing “RideOn” in the write characteristic, to which the controller replies with another “RideOn” in the indication characteristic. That’s it. After that, we start receiving plain serialized protocol buffers messages in the notification characteristic of the Zwift BLE Service.

Key press messages

The protocol buffers message transmitting the keystrokes has a message id 0x23. This is always the first byte in all the keypad status transmission messages. When the controller is idle you’ll get a periodic 0x19 or 0x15. In 0x23 protocol buffers message you’ll find first a 32bit bit map (finally a smart move, much better encoding than the one found in the Play controllers) showing, with inverse logic, which buttons are pressed -in both controllers-. A bit set to zero means the corresponding button is pressed. The next part of the message contains the analog values of up to 4 joysticks, but actually only 2 are used, the orange steering / braking levers. So let’s move to the examples.

23 08 feffffff0f 12 180a04080010000a04080110000a04080210000a0408031000

This looks more complicated than the Play messages… because it is. We already know 0x23 is the message id.

Byte RangeField NumberTypeContent
0-61 (0x08)varintKeystroke bitmap FFFFFFFE = 11111111111111111111111111111110
6-322 (0x12)protobufNested protocol buffer message for the Analog buttons

The first field is the 32 bit bitmap, little endian. We can see the first bit is set to zero. That means the left arrow button of the left controller is being pressed (button group 1 in the image). The bit positions for every button are listed later in the deducted .proto definitions.

The next field contains a nested protocol buffer message that looks like this

Byte RangeField NumberTypeContent
0-61protobufVarint with the Analog button ID = 0 (LEFT)
Signed Int with the value of analog button ID 0 = 0
6-121protobufVarint with the Analog button ID = 1 (RIGHT)
Signed int with the value of analog button ID 1 = 0
12-181protobufVarint with the Analog button ID = 2 (???)
Signed int with the value of analog button ID 2 = 0
18-241protobufVarint with the Analog button ID = 3 (???)
Signed int with the value of analog button ID 3 = 0

I know it takes a leap of faith to believe the byte stream shown above translates into this, but you can use https://protobuf-decoder.netlify.app/ yourself and get a more detailed explanation. In this case we have a repeated field that contains the identifier of each of four analog buttons and their analog values. The values are signed integers from -100 to 100 indicating the direction and amount of pressure in each button. The first two buttons are the left and right orange analog buttons used for steering and braking (number 5 in the image). The other two must be reserved for future updates because they are always zero no matter what buttons you press.

Protocol buffers definitions

Decoding protocol buffers without the original .proto definition file is very challenging. There are some helpful tools, like https://protobuf-decoder.netlify.app/ by Pawit Pornkitprasan, but eventually, decoding the messages byte by byte becomes a grind. So, I succumbed and added a Google protobuf library dependency to my code and worked my way backward into creating the .proto message definition file from the wire messages received via Bluetooth.

This is the incomplete result. All messages are in proto2 syntax.

// The command code prepending this message is 0x23
message RideKeyPadStatus {
optional uint32 ButtonMap = 1;
optional RideAnalogKeyGroup AnalogButtons = 2;
}
enum RideButtonMask {
LEFT_BTN = 1;
UP_BTN = 2;
RIGHT_BTN = 4;
DOWN_BTN = 8;
A_BTN = 0x10;
B_BTN = 0x20;
Y_BTN = 0x40;
Z_BTN = 0x100;
SHFT_UP_L_BTN = 0x200;
SHFT_DN_L_BTN = 0x400;
POWERUP_L_BTN = 0x800;
ONOFF_L_BTN = 0x1000;
SHFT_UP_R_BTN = 0x2000;
SHFT_DN_R_BTN = 0x4000;
POWERUP_R_BTN = 0x10000;
ONOFF_R_BTN = 0x20000;
}
enum RideAnalogLocation {
LEFT = 0;
RIGHT = 1;
UNK1 = 2;
UNK2 = 3;
}
message RideAnalogKeyPress {
optional RideAnalogLocation Location = 1;
optional sint32 AnalogValue = 2;
}
message RideAnalogKeyGroup {
repeated RideAnalogKeyPress GroupStatus = 1;
}

Control Point Commands

Apart from the command for sending haptic feedback to the controller, which is the same one found in the Play controllers, there is a new one picked up in the wireshark captures. This new command appears to be just a request for information. Its command code is 0, and the parameter is just an empty protocol buffers message with just one varint field set to zero. In wire format it looks like this

00 08 00

The response comes in the indications characteristic and looks like this

3c 08 00 12 2d 0a 2b 12 04 00 00 00 01 1a 09 5a 77 69 66 74 20 53 46 32 32 0f 30 38 2d 46 39 36 43 36 32 46 33 33 41 41 43 3a 03 42 2e 30 48 01 50 08

This one has a few nested messages but in summary, it contains information about the controller, among other things: the name “Zwift SF2”, another string containing the BLE address and what appears to be a hardware version number. None of this information is required for anything as far as I have experienced, but there it is.

If the command parameter is 770 instead of zero, like so

00 08 82 06

Then the response is an array of bytes of unknown nature. It can’t be decoded as a protocol buffers but contains pieces of familiar strings “sf2” and “nrf52”. Maybe one day someone can enlighten us.

Wrapping up

A new piece of hardware, with a new set of messages and no encryption. Even though the research this time was way easier than the encrypted protocol of the Play, the funny part is that… I don’t even own a Zwift Ride!. I haven’t seen one yet. Thanks to one of the GT Bike V users, Daniel Melikyan, who not only has a Zwift Ride but also knows how to launch a Bluetooth sniffer and filter the traffic through Wireshark, I was able to get a complete communication dump between the Zwift Ride and the Zwift app. This provided most of the information needed to decode the protocol.

I had to code blindly without testing, but most of the Bluetooth infrastructure is already present in my BLE libraries for GT Bike V, so it wasn’t too difficult, and the collaboration was very fun. It’s not every day that someone on Facebook introduces themselves with a .pcapng Wireshark capture file.