9 minutes
Zhiyun Weebil-S BLE Protocol
Note: Source code for an example implementation is available here
Introduction
The Weebill-S is a mirrorless/DSLR camera gimbal made by Zhyiun that retails for around £389. It can be controlled either from the device itself or with a companion app on your phone via bluetooth. I purchased mine from ebay at a very reasonable price but with a defect, the onboard controls don’t work so you are required to use the app to control it.
The App
The ZY Play app is available for iOS or Android, via the Play Store or via a direct APK download from the site.
The app is quite disappointing. Whilst it allows for all the controls available onboard and even adds some timelapse functionality, the interface is difficult to use and the joystick control is essentially unusable because of how small it is. I felt like there was missing potential here, and after reading this blog post about BLE reverse engineering I decided to give it a try myself.
My first step was to attempt to decompile the app using the APK they helpfully provide on their site in the hopes that I could extract the bluetooth protocol from there.
But when I loaded up the decompiled app, I didn’t see the format I would expect. Instead, I saw a sparsely populated “WrapperProxyApplication” and references to a libtosprotection
:
java.lang.StringBuilder r9 = new java.lang.StringBuilder
r9.<init>()
java.lang.String r10 = "libtosprotection."
r9.append(r10)
java.lang.String r10 = CPUABI
r9.append(r10)
java.lang.String r10 = ".so"
r9.append(r10)
java.lang.String r9 = r9.toString()
Googling libtosprotection
seems to point to it being an APK protection system created by Tencent.
There doesn’t seem to be much research out there about this with most links being completely in Chinese and
I certainly am not skilled enough to figure this out, so it was back to the drawing board.
Bluetooth HCI Snoop
I returned to the blog post from earlier and noticed the references to the Bluetooth HCI snoop log on Android. I enabled the logging in the developer options and started the app, connected to the gimbal and did some movements. I downloaded the log via ADB and opened it up in Wireshark.
I have barely any experience with Wireshark, especially not with Bluetooth so opening the file was quite intimidating. I scrolled until I saw the first communications with the gimbal and attempted to understand what I was looking at. After sifting through dozens of lines of communication setup, I finally got to a section where a clear command/response pattern was emerging.
I started to copy the values into a text file to see if I could deduce a pattern:
Sent: 243c080018120101020000006f76
Recv: 243e0800181201100200330507d8
Sent: 243c08001812020104000000169f
Recv: 243e0800181202100400cf007737
Sent: 243c0800181203010500000002ac
Recv: 243e08001812031005000800a08b
Sent: 243c080018120401020000006e35
Recv: 243e08001812041002003305069b
Immediately it was clear that there is a fixed start, a byte which increments by 1 each time, then some data:
Fixed Inc 01 Cmd ??????????
243c08001812 03 01 05 00000002ac
243c08001812 04 01 02 0000006e35
After the increment, 01
seemed to indicate a command, and 10
a response,
the next byte I hypothesised would be a command ID since it was the same in both the command and response.
At this point, I thought I may have enough to attempt to send some commands to the gimbal using nRF Connect. Naively picking out commands and sending them to the gimbal didn’t seem to have any effect, at which point I went back to the Wireshark and saw data which complicated things:
Value: 243e0c001815080001805010c2010000984b
Value: 243c08001812370101100008b58d243c080018123801021000086ad3
This data broke multiple rules of my hypothesised format, it was clear that this was not going to be easy to understand without knowing more information about when the commands are sent.
Making a fake gimbal
If I couldn’t fake the app, then maybe I could fake the gimbal and get the app to connect to me.
This would allow me to read commands as buttons are pressed and send data back to validate the format.
For this, I chose to use Node.js with bleno to act as a BLE peripheral.
I opened nRF Connect to attempt to replicate the services and characteristics from the gimbal and quickly setup a fake version of the gimbal in bleno:
const bleno = require('bleno');
const userDescription = new bleno.Descriptor({
uuid: '2901'
});
bleno.on('stateChange', (state)=>{
console.log(state);
if(state === "poweredOn"){
bleno.startAdvertising("WEEBIL_S_9E9F");
bleno.setServices(new bleno.PrimaryService({
uuid: 'fee9',
characteristics: [
new bleno.Characteristic({
uuid: 'd44bc439abfd45a2b575925416129600',
properties: ['writeWithoutResponse'],
onWriteRequest,
descriptors: [userDescription]
}),
new bleno.Characteristic({
uuid: 'd44bc439abfd45a2b575925416129601',
properties: ['notify'],
onReadRequest,
onSubscribe,
descriptors: [userDescription]
}),
]
}))
}
});
But when I opened the ZY Play app, it wouldn’t detect the “gimbal”. Puzzled, I returned to nRF connect to spot the difference:
The characteristics and services checked out completely, but the fake gimbal was missing the “Manufacturer Data” and “Complete Local Name” fields.
Bleno did not seem to support adding either of those fields so I dug into the bleno code to see if it was possible to add.
Internally, startAdvertising
created a buffer containing the device name and other advertising data and passed it to startAdvertisingWithEIRData
.
Reading the docs I had initially dismissed this function as the docs state it is only available on Linux and I was running this on Windows.
Since startAdvertising
just passed directly into the function, it must be supported on Windows so I copied the raw data from the fake gimbal
and added the manufacturer data field from the real gimbal and tried advertising again.
This time, I could see the app attempt to connect to my fake gimbal, repeat the same message 5 times and then disconnect with an error message on the app. This was good, as it meant I had a starting point. The app was clearly looking for a response and timed out when it didn’t get it after a number of retries. All I had to do was find the response to that command from the Wireshark and return the same responses.
Deciphering commands
At this point, I had a fake gimbal that the app could connect to, I just had to figure out the format for commands, then map each function on the app to a command.
After much tribulation, I came up with this rough format for commands:
Magic Len ???? Inc 01 Cmd Data CRC
243c 0800 1812 01 01 02 000000 6f76
Each packet starts with the magic bytes 24 3c
. A single bluetooth transmission can sometimes contain more or less than one whole packet,
which is where the length argument is useful. The length is 2 bytes long and is Big-Endian, meaning that this packet has 0x0008
more bytes,
followed by 2 more bytes for the CRC.
The next 2 bytes still remain a mystery to me, but for almost all packets I have seen this has been 0x1812
, except for the heartbeat message which is 0x1815
.
I believe this may be some kind of format identifier as the heartbeat message does not follow the format described here.
Next up is a single byte increment value, which is incremented independently for each command and response. The app does not seem to require validate these, but the gimbal will ignore commands with duplicate increments.
The next byte is always 0x01
when sent from the app to the gimbal, and 0x10
when sent from the gimbal to the app.
The next byte is the command ID, followed by (usually) 3 bytes of arbitrary data. The length of this data is dictated by the remaining length from the length argument earlier. See the table below for known commands and their data argument structure.
The last 2 bytes contains a XMODEM CRC-16 which is calculated from after the end of the length argument to the end of the data argument.
Both the gimbal and app generate CRCs for commands/responses, however only the gimbal will ignore messages with an incorrect CRC.
Command IDs
These are the commands I have figured out so far, some of these I am certain about, some I’m not sure.
ID | Meaning | Input Arg | Output Arg | Notes |
---|---|---|---|---|
01 |
Tilt | 10 c2 followed by 01 for forward, 11 for reverse |
No Response | Moves the Tilt axis a tiny bit |
02 |
Pan | 10 c2 followed by 01 for forward, 11 for reverse |
No Response | Moves the Pan axis a tiny bit |
03 |
Roll | 10 c2 followed by 01 for forward, 11 for reverse |
No Response | Moves the Roll axis a tiny bit |
04 |
Get Software Version | Zeroes | 00 followed by the software version multiplied by 100. |
|
06 |
Get Battery Percentage/Set Tilt Pos | Zeroes / 10 followed by a 2 byte position position value` |
00 followed by the battery level. |
For some reason both battery level and tilt pos are the same command. This doesn’t happen anywhere else |
07 |
Set Roll Pos | 10 followed by a 2 byte position position value` |
No Response | Sets Roll axis to an exact angle. |
08 |
Set Pan Pos | 10 followed by a 2 byte position position value` |
No Response | Sets Pan axis to an exact angle. |
20 |
Press Button | c0 3c 00 |
No Response | Takes a photo/starts recording, other buttons may use the same format. |
22 |
Read Tilt Position | Zeroes | 00 followed by the Tilt position. |
Representation may be degrees/65535 but seems inconsistent. |
23 |
Read Roll Position | Zeroes | 00 followed by the Roll position. |
Representation may be degrees/65535 but seems inconsistent. |
24 |
Read Pan Position | Zeroes | 00 followed by the Pan position. |
Representation may be degrees/65535 but seems inconsistent. |
27 |
Set Mode | 80 followed by mode ID (see notes), followed by 00 |
No Response | PF = 00 , L = 01 , F = 02 , POV = 03 , GO = 04 |
7c |
Get Serial 1 | Zeroes | 00 followed by part of the serial number. |
|
7f |
Get Serial 2 | Zeroes | 00 followed by part of the serial number. |
|
7d |
Get Serial 3 | Zeroes | 00 followed by part of the serial number. |
|
7e |
Get Serial 4 | Zeroes | 00 followed by part of the serial number. |
|
61 |
Set Pan Smoothing | ??? | No Response | Something to do with smoothing settings |
62 |
Set Tilt Smoothing | ??? | No Response | Something to do with smoothing settings |
63 |
Set Roll Smoothing | ??? | No Response | Something to do with smoothing settings |
68 |
Get Camera Brand | Zeroes | 00 followed by the camera brand ID, see below table. |
|
70 |
Sync Motion Tilt Amount | ??? | No Response | Used to set Tilt sensitivity for Sync Motion |
71 |
Sync Motion Roll Amount | ??? | No Response | Used to set Roll sensitivity for Sync Motion |
72 |
Sync Motion Pan Amount | ??? | No Response | Used to set Pan sensitivity for Sync Motion |
Camera Brands
ID | Brand |
---|---|
00 |
None |
01 |
Canon |
02 |
Sony |
03 |
Panasonic |
04 |
Nikon |
05 |
CCS |
06 |
Fuji |
07 |
Olympus |
0a |
rcam |
0b |
bmpcc |
0c |
Sigma |
e0 |
Sony USB* |
*Sony USB has sub-types which I haven’t fully explored, they don’t appear to affect the ID.
Heartbeat Format
The heartbeat uses a different format from the normal messages:
Magic Len ???? Inc 00 Mode Lock ??????? CRC
243e 0c00 1815 08 00 01 80 5010c2010000 984b
The format starts similar, but lacks a command ID or call/response argument. I haven’t figured out much about this one, yet.
The first byte of data is the mode, this is the same mode ID as used by command 0x27
- Set Mode.
The second byte is set to 0x80
when AXIS LOCK is triggered. I believe it may also change value for other states, such as sleep mode.