Crafting simple hardware drivers with libusb
I recently upgraded my computer mouse. Windows drivers are available through the vendor’s website, but what’s a daily Linux user like me to do? Reverse engineer it, of course!
The plan
This mouse has some nice customization options, but only if you’re able to run its companion software package, a Windows frontend called iCUE which does not work on Linux.
As a Linux user wanting customization, I formulated the following plan:
- Install iCUE on a Windows machine.
- Intercept packets sent to the mouse from iCUE.
- Examine the packets, documenting the data structure of each.
- Create a Linux-compatible driver based on my findings.
I then drafted a list of requirements that my driver must satisfy. It must allow the user to tweak each of the following settings:
- LED color and brightness
- DPI (precision)
- Polling rate
- DPI button behavior
Now I’ll walk you through the entire process.
Intercepting USB packets
On a laptop running Windows 7, I installed the iCUE software suite from the vendor’s website and launched it. It detected the mouse, and after a little poking around, I found the settings I was looking for. To keep things simple, I’ll start with the easiest of the bunch: the color of the LED.
A brief web search for “how to sniff USB packets” led me to USBPcap. It looks like this when launched:
You can see here that I selected filter 1
. That’s because HID-compliant mouse
(the Corsair mouse) is listed beneath it.
Once it started logging packets, I moved on to changing the LED color in iCUE.
I wanted the color value to stand out against any other data that might be present in the packet, so I chose something recognizable: abcdef
.
With the relevant packet sent, it’s time to terminate USBPcap and move on to the next step.
Examining the captured packet
I used Wireshark to inspect the PCAP file output by USBPcap.
Notice how abcdef
stands out against all the other data. This is unquestionably the LED color.
By mousing over a section of the hex dump, Wireshark automatically highlights and labels it. To illustrate, here is what it looks like if I highlight and color code each section:
The only block that will be relevant as I write my driver is the “Leftover Capture Data” block. Everything else is thankfully abstracted by libusb
.
With the “set LED color” packet solved for, into the notes it goes:
Packet Formats
Set LED Color
Length: 0x40
Data: 07 22 01 01 03 rr gg bb
And it is translated into a C function:
/* construct LED color packet */
uint8_t *mksig_color(uint8_t r, uint8_t g, uint8_t b)
{
static uint8_t out[out_wMaxPacketSize];
uint8_t tmp[out_wMaxPacketSize] =
{
0x07, 0x22, 0x01, 0x01, 0x03, r, g, b
};
return memcpy(out, tmp, out_wMaxPacketSize);
}
Writing the intial program
The libusb
library requires certain parameters in order to locate and communicate with a USB device. Before writing any code, I plugged the mouse into my Linux machine and ran the lsusb
command:
Bus 002 Device 002: ID 8087:8000 Intel Corp. Integrated Rate Matching Hub
Bus 002 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 002: ID 8087:8008 Intel Corp. Integrated Rate Matching Hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 003 Device 004: ID 0bda:0158 Realtek Semiconductor Corp. USB 2.0 multicard reader
Bus 003 Device 003: ID 413c:2003 Dell Computer Corp. Keyboard SK-8115
Bus 003 Device 013: ID 13ba:0018 PCPlay Barcode PCP-BCG4209
Bus 003 Device 015: ID 1b1c:1b3c Corsair Corsair Gaming HARPOON RGB Mouse
Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Here’s the entry for the mouse:
Bus 003 Device 015: ID 1b1c:1b3c Corsair Corsair Gaming HARPOON RGB Mouse
The relevant values here are 1b1c
(the vendor ID), and 1b3c
(the product ID). I reran lsusb
with the options -v -d 1b1c:1b3c
to request more information about the mouse:
Bus 003 Device 021: ID 1b1c:1b3c Corsair Corsair Gaming HARPOON RGB Mouse
Device Descriptor:
bLength 18
bDescriptorType 1
bcdUSB 2.00
bDeviceClass 0
bDeviceSubClass 0
bDeviceProtocol 0
bMaxPacketSize0 64
idVendor 0x1b1c Corsair
idProduct 0x1b3c
bcdDevice 3.08
iManufacturer 1 Corsair
iProduct 2 Corsair Gaming HARPOON RGB Mouse
iSerial 3 0300F02BAF7C05075F8F52D3F5001C05
bNumConfigurations 1
Configuration Descriptor:
bLength 9
bDescriptorType 2
wTotalLength 0x0042
bNumInterfaces 2
bConfigurationValue 1
iConfiguration 0
bmAttributes 0xa0
(Bus Powered)
Remote Wakeup
MaxPower 300mA
Interface Descriptor:
bLength 9
bDescriptorType 4
bInterfaceNumber 0
bAlternateSetting 0
bNumEndpoints 1
bInterfaceClass 3 Human Interface Device
bInterfaceSubClass 1 Boot Interface Subclass
bInterfaceProtocol 2 Mouse
iInterface 0
HID Device Descriptor:
bLength 9
bDescriptorType 33
bcdHID 1.11
bCountryCode 0 Not supported
bNumDescriptors 1
bDescriptorType 34 Report
wDescriptorLength 106
Report Descriptors:
** UNAVAILABLE **
Endpoint Descriptor:
bLength 7
bDescriptorType 5
bEndpointAddress 0x81 EP 1 IN
bmAttributes 3
Transfer Type Interrupt
Synch Type None
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
Interface Descriptor:
bLength 9
bDescriptorType 4
bInterfaceNumber 1
bAlternateSetting 0
bNumEndpoints 2
bInterfaceClass 3 Human Interface Device
bInterfaceSubClass 0
bInterfaceProtocol 0
iInterface 0
HID Device Descriptor:
bLength 9
bDescriptorType 33
bcdHID 1.11
bCountryCode 0 Not supported
bNumDescriptors 1
bDescriptorType 34 Report
wDescriptorLength 29
Report Descriptors:
** UNAVAILABLE **
Endpoint Descriptor:
bLength 7
bDescriptorType 5
bEndpointAddress 0x82 EP 2 IN
bmAttributes 3
Transfer Type Interrupt
Synch Type None
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
Endpoint Descriptor:
bLength 7
bDescriptorType 5
bEndpointAddress 0x02 EP 2 OUT
bmAttributes 3
Transfer Type Interrupt
Synch Type None
Usage Type Data
wMaxPacketSize 0x0040 1x 64 bytes
bInterval 1
can't get device qualifier: Resource temporarily unavailable
can't get debug descriptor: Resource temporarily unavailable
Device Status: 0x0000
(Bus Powered)
For sending data packets to the mouse, these snippets are the most important:
idVendor 0x1b1c Corsair
idProduct 0x1b3c
/* output interface */
bInterfaceNumber 1
bEndpointAddress 0x02 EP 2 OUT
wMaxPacketSize 0x0040 1x 64 bytes
I finally had everything I needed to write a basic C program that changes the color of the LED.
/*
* harpoon-led.c <z64.me>
*
* rudimentary test to change the LED
* color on a Corsair Harpoon RGB mouse
* using libusb
*
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <libusb-1.0/libusb.h>
/* device info */
#define idVendor 0x1b1c
#define idProduct 0x1b3c
/* output interface */
#define out_bInterfaceNumber 1
#define out_bEndpointAddress 0x02 /* EP 2 OUT */
#define out_wMaxPacketSize 0x0040
/* construct LED color packet */
uint8_t *mksig_color(uint8_t r, uint8_t g, uint8_t b)
{
static uint8_t out[out_wMaxPacketSize];
uint8_t tmp[out_wMaxPacketSize] =
{
0x07, 0x22, 0x01, 0x01, 0x03, r, g, b
};
return memcpy(out, tmp, out_wMaxPacketSize);
}
int main(int argc, char *argv[])
{
libusb_device_handle *device = 0; /* libusb stuff */
libusb_context *context = 0;
const char *progname = argv[0]; /* args */
const char *colorStr = argv[1];
int errcode = 0; /* misc */
int sent;
unsigned color;
/* confirm arguments */
if (argc != 2)
{
fprintf(stderr, "arguments: %s 0xHexColor\n", progname);
fprintf(stderr, "for example: %s 0xff0000\n", progname);
return 1;
}
/* parse arguments */
if (sscanf(colorStr, "%x", &color) != 1)
{
fprintf(stderr, "could not parse color '%s'\n", colorStr);
return 1;
}
/* fatal error (with cleanup) */
#define die(X) { \
perror(X); \
if (errcode) \
fprintf(stderr, "libusb_strerror: %s\n", libusb_strerror(errcode)); \
if (device) \
libusb_close(device); \
if (context) \
libusb_exit(context); \
return 1; \
}
/* initialize libusb context */
if ((errcode = libusb_init(&context)))
die("libusb_init failed");
#ifndef NDEBUG
libusb_set_option(context, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_INFO);
#endif
/* open device handle */
if (!(device = libusb_open_device_with_vid_pid(context, idVendor, idProduct)))
die("libusb_open_device_with_vid_pid");
/* tell libusb to automatically detach kernel driver when
* interface is claimed, and reattach when interface is released
*/
if ((errcode = libusb_set_auto_detach_kernel_driver(device, true)))
die("libusb_set_auto_detach_kernel_driver");
/* now attempt to claim the interface */
if ((errcode = libusb_claim_interface(device, out_bInterfaceNumber)))
die("libusb_claim_interface");
/* transfer color code to mouse */
if ((errcode = libusb_bulk_transfer(
device
, out_bEndpointAddress | LIBUSB_ENDPOINT_OUT
, mksig_color(color >> 16, color >> 8, color) /* r, g, b */
, out_wMaxPacketSize
, &sent
, 0 /* no timeout */
))
|| sent != out_wMaxPacketSize
)
die("libusb_bulk_transfer");
/* cleanup */
if ((errcode = libusb_release_interface(device, out_bInterfaceNumber)))
die("libusb_release_interface");
libusb_close(device);
libusb_exit(context);
return 0;
}
And it works! From there, I went on to solve for every other type of packet.
Polling rate
Using the same process as before, I produced a hex dump for each setting:
Polling Rate Settings
125 Hz / 8 msec
0000 1b 00 a0 69 3d 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 0a 00 00 08
250 Hz / 4 msec
0000 1b 00 a0 69 3d 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 0a 00 00 04
500 Hz / 2 msec
0000 1b 00 a0 69 3d 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 0a 00 00 02
1000 Hz / 1 msec
0000 1b 00 a0 69 3d 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 0a 00 00 01
This packet type follows a simple pattern: 07 0a 00 00 xx
, where xx
is the millisecond delay on the polling rate. Here it is adapted into a C function:
/* construct a polling rate packet */
uint8_t *mksig_pollrate(uint8_t msec)
{
static uint8_t out[out_wMaxPacketSize];
uint8_t tmp[out_wMaxPacketSize] =
{
0x07, 0x0a, 0x00, 0x00, msec
};
return memcpy(out, tmp, out_wMaxPacketSize);
}
Now, for something a little more challenging…
Reverse engineering the DPI settings
iCUE has this nifty menu:
There is quite a bit going on here:
- There are five DPI settings.
- Each has a customizable color.
- Each can be turned on or off.
- Each can be set to any precision between 250 and 6000, inclusive (locked to increments of 250).
- When you press the DPI button on the mouse, it goes to the next (enabled) DPI setting in the list.
- A default can be selected.
For my driver, I’m keeping things simple. I want only one DPI setting, and I’m going to accomplish this by turning them all off. Then I’ll be free to repurpose that mouse button.
In this screenshot, I illustrate the value I chose (2500) being converted to hexadecimal notation:
The value appears scrambled in this packet due to something called Endianness, also known as byte order. Because the driver is running on a Little Endian machine, the bytes for the value 09C4 are stored in reverse, hence C409.
There are two instances of this value, which suggests the mouse supports differing X and Y precisions. The iCUE frontend does not allow the user to specify them separately, however.
DPI[5] = 2500 (0x9C4)
0000 1b 00 30 24 34 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 d5 00 00
0020 c4 09 c4 09 ab cd ef 00 00 00 00 00 00 00 00 00
DPI[1] = 2500 (0x9C4)
0000 1b 00 70 98 4d 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 d1 00 00
0020 c4 09 c4 09 01 23 45 00 00 00 00 00 00 00 00 00
By experimenting with each DPI setting and comparing packets, I derived the pattern 07 13 dz 00 00 xxxx yyyy rr gg bb
, where z
is the index of the DPI mode, x
is the X DPI, y
is the Y DPI, and rgb
represents the LED color channels.
The C function I wrote to generate such packets looks like this:
/* construct a DPI setup packet */
uint8_t *mksig_dpisetup(uint8_t index, unsigned x, unsigned y, uint8_t r, uint8_t g, uint8_t b)
{
static uint8_t out[out_wMaxPacketSize];
uint8_t tmp[out_wMaxPacketSize] =
{
0x07, 0x13, 0xd0 | index, 0x00, 0x00
, (x & 0xff) << 8, (x & 0xff00) >> 8 /* XXX ensure Little Endian byte order */
, (y & 0xff) << 8, (y & 0xff00) >> 8
, r, g, b
};
return memcpy(out, tmp, out_wMaxPacketSize);
}
Note that this packet type doesn’t control whether or not a DPI mode is enabled. That’s the final piece of the puzzle. To begin, I had iCUE emit new packets to compare, toggling each setting independently:
all on
0000 1b 00 f0 0a 52 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 05 00 3f
only [1] off
0000 1b 00 f0 0a 52 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 05 00 3d
only [2] off
0000 1b 00 f0 0a 52 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 05 00 3b
only [3] off
0000 1b 00 f0 0a 52 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 05 00 37
only [4] off
0000 1b 00 f0 0a 52 04 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 05 00 2f
only [5] off
0000 1b 00 10 a0 ea 03 80 fa ff ff 00 00 00 00 09 00
0010 00 01 00 02 00 02 01 40 00 00 00 07 13 05 00 1f
The last byte on the second line is the only one that changes. Here is each value represented in binary notation:
index hex binary
all on 3f 00111111
only 1 off 3d 00111101
only 2 off 3b 00111011
only 3 off 37 00110111
only 4 off 2f 00101111
only 5 off 1f 00011111
This reveals the mouse uses a bitfield to keep track of whether or not a DPI mode is enabled. Literal zeroes and ones determine what happens when you press the DPI button on the mouse: how exciting!
To produce my own packets, I used bitwise operations to construct the bitfield, like so:
/* construct a packet indicating which DPI modes are enabled */
uint8_t *mksig_dpisetenabled(bool m0, bool m1, bool m2, bool m3, bool m4, bool m5)
{
static uint8_t out[out_wMaxPacketSize];
uint8_t tmp[out_wMaxPacketSize] =
{
0x07, 0x13, 0x05, 0x00
, m0
| (m1 << 1)
| (m2 << 2)
| (m3 << 3)
| (m4 << 4)
| (m5 << 5)
};
return memcpy(out, tmp, out_wMaxPacketSize);
}
When I invoke this function with every argument set to false
and send the resulting packet to the mouse, pressing the DPI button no longer has any effect. And when I do the same but with every argument set to true
, it goes back to behaving as it originally had. It’s working exactly as expected!
Adding a Graphical User Interface (GUI)
My driver now does everything I need, and I’m happy running it from the command line. But the fun doesn’t have to stop here! I’m going to give it a graphical interface! QtCreator makes this process relatively straightforward.
Because the GUI runs indefinitely, I wanted to support hotplugging (the mouse being unplugged and plugged back in while the program is running). I achieved this by using a QTimer
, which spins a thread that continuously reinvokes a given function at a set interval. Inside this function, I had the program monitor the mouse’s connection status. Conveniently, this also accounts for the mouse restarting itself each time its polling rate is adjusted.
Here’s a YouTube video of the driver in action. A webcam shows how the mouse responds as changes are made in the GUI using a different mouse.
Browse the source code
I published the source code on GitHub in case anyone wants to try it out: z64me/harpoon-rgb-mouse
Attribution
The following software made this project possible: