Thumbnail image

Crafting simple hardware drivers with libusb

Mon, Nov 1, 2021 13-minute read

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:

image

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.

image

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.

image

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:

image

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

image

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:

image

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:

image

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: