Switch Dual Shock adapter part 4: Talking to a Switch

Posted on Apr 7, 2023

In this series of posts, I’m attempting to make a Dual Shock to Switch controller adapter. It will plug into the Switch Dock’s USB port.

In previous posts, I got a breadboard with an ATmega8A on it running code I wrote using V-USB, masquerading as a generic USB HID game controller when plugged into a computer.

Breadboard with an ATmega8A and a few components attached

By the end of this post, it will control a real Nintendo Switch! Scroll down to the video at the end for a sneak peek if you’re impatient.

Can I just plug this breadboard - the one that implements an industry standard USB game controller - into the Switch? When I started out on this adventure I kind of thought that would be the case. Sadly it is not. As I talked about in a footnote in my first post on this project:

To be honest, I’d kind of assumed [USB game controllers] were all compatible. I got into gaming again in the pandemic, and played all of Tomb Raider 2013 on Stadia on my Mac with my trusty twenty year old Dual Shock and a twenty year old PlayStation to USB adapter. I assumed all USB controllers nowadays were just USB HID devices, and would work anywhere - like keyboards and mice. But, no. And worse than that, Microsoft and Sony actually have cryptographic authentication built into the Xbox and Playstation that restricts them to working only with officially licensed controllers! Nintendo doesn’t do the authentication thing with the Switch, at least (though the Switch still doesn’t ‘just work’ with standard USB controllers).

You can definitely buy USB controllers for the Switch though - even the Pro Controller can work over USB if you don’t want to use it wirelessly. How do they work?

I didn’t need to work this out myself. There’s been a lot of research into, and reverse-engineering of, the Switch Pro Controller’s USB protocol since it was released.

The seminal work is the Nintendo Switch Reverse Engineering Git repository, started and maintained by dekuNukem, with a lot of crowdsourced updates. It’s comprehensive - but rather, uh, terse. More a collection of notes than a reference. And certainly not a tutorial!

Some of the best writing about this I found is in a blog post from 2020 by Yuki Mizuno - unfortunately for me it’s written in Japanese - but after trip through Google Translate it is quite readable. He connected a Raspberry Pi to a Switch over USB, with the Pi emulating a Pro Controller. The code in that blog post is in Python, and he later released an extended Go implementation.

Using this information - and a little more Googling - I was able to get my ATmega to emulate a Switch Pro Controller. Let’s dive into the code!

Looking like a Pro Controller

Happily, the Pro Controller does actually use USB HID protocol to set up communication, so the first thing to do was to tell the Switch that my ATmega is a Nintendo Pro Controller through the usual USB identification mechanisms.

With the blog posts and reverse engineered information I found on the web to guide me, I updated usbconfig.h. Here’s the relevant #defines, with comments elided:

#define USB_CFG_VENDOR_ID         0x7e, 0x05 /* 0x057e */
#define USB_CFG_DEVICE_ID         0x09, 0x20 /* 0x2009 */
#define USB_CFG_DEVICE_VERSION    0x00, 0x02 /* 0x0200 */

#define USB_CFG_VENDOR_NAME       'N','i','n','t','e','n','d','o',' ','C','o','.',',',' ','L','t','d','.'
#define USB_CFG_VENDOR_NAME_LEN   18

#define USB_CFG_DEVICE_NAME       'P','r','o',' ','C','o','n','t','r','o','l','l','e','r'
#define USB_CFG_DEVICE_NAME_LEN   14

#define USB_CFG_SERIAL_NUMBER     '0','0','0','0','0','0','0','0','0','0','0','1'
#define USB_CFG_SERIAL_NUMBER_LEN 12

#define USB_COUNT_SOF             1

I also defined USB_COUNT_SOF to 1 while I was in here - this isn’t related to identification - it causes V-USB to expose a usbSofCount variable that increases by one on every USB frame received - so basically a millisecond counter while the USB connection is working. This will be useful later.

The next thing to change was the HID Report Descriptor, in descriptors.c. Previously, I’d hand-made one to describe how my fake HID controller encoded its state. Now, it needs to be the same one that a real Pro Controller sends. It’s only listed in Yuki Mizuno’s blog post in hex form, and weirdly seems not to be in the reverse-engineering repo, but there’s a Gist containing it in a more readable form here. A quick copy-paste job and a wrapping in C syntax produced this:

PROGMEM const char usbDescriptorHidReport[] = {
    0x05, 0x01,                   // Usage Page (Generic Desktop Ctrls)
    0x15, 0x00,                   // Logical Minimum (0)
    0x09, 0x04,                   // Usage (Joystick)
    0xA1, 0x01,                   // Collection (Application)
    0x85, 0x30,                   //   Report ID (0x30)
    0x05, 0x01,                   //   Usage Page (Generic Desktop Ctrls)
    0x05, 0x09,                   //   Usage Page (Button)
    0x19, 0x01,                   //   Usage Minimum (0x01)
    0x29, 0x0A,                   //   Usage Maximum (0x0A)
    0x15, 0x00,                   //   Logical Minimum (0)
    0x25, 0x01,                   //   Logical Maximum (1)
    0x75, 0x01,                   //   Report Size (1)
    0x95, 0x0A,                   //   Report Count (10)
    0x55, 0x00,                   //   Unit Exponent (0)
    0x65, 0x00,                   //   Unit (None)
    0x81, 0x02,                   //   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x05, 0x09,                   //   Usage Page (Button)
    0x19, 0x0B,                   //   Usage Minimum (0x0B)
    0x29, 0x0E,                   //   Usage Maximum (0x0E)
    0x15, 0x00,                   //   Logical Minimum (0)
    0x25, 0x01,                   //   Logical Maximum (1)
    0x75, 0x01,                   //   Report Size (1)
    0x95, 0x04,                   //   Report Count (4)
    0x81, 0x02,                   //   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x75, 0x01,                   //   Report Size (1)
    0x95, 0x02,                   //   Report Count (2)
    0x81, 0x03,                   //   Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x0B, 0x01, 0x00, 0x01, 0x00, //   Usage (0x010001)
    0xA1, 0x00,                   //   Collection (Physical)
    0x0B, 0x30, 0x00, 0x01, 0x00, //     Usage (0x010030)
    0x0B, 0x31, 0x00, 0x01, 0x00, //     Usage (0x010031)
    0x0B, 0x32, 0x00, 0x01, 0x00, //     Usage (0x010032)
    0x0B, 0x35, 0x00, 0x01, 0x00, //     Usage (0x010035)
    0x15, 0x00,                   //     Logical Minimum (0)
    0x27, 0xFF, 0xFF, 0x00, 0x00, //     Logical Maximum (65534)
    0x75, 0x10,                   //     Report Size (16)
    0x95, 0x04,                   //     Report Count (4)
    0x81, 0x02,                   //     Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0xC0,                         //   End Collection
    0x0B, 0x39, 0x00, 0x01, 0x00, //   Usage (0x010039)
    0x15, 0x00,                   //   Logical Minimum (0)
    0x25, 0x07,                   //   Logical Maximum (7)
    0x35, 0x00,                   //   Physical Minimum (0)
    0x46, 0x3B, 0x01,             //   Physical Maximum (315)
    0x65, 0x14,                   //   Unit (System: English Rotation, Length: Centimeter)
    0x75, 0x04,                   //   Report Size (4)
    0x95, 0x01,                   //   Report Count (1)
    0x81, 0x02,                   //   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x05, 0x09,                   //   Usage Page (Button)
    0x19, 0x0F,                   //   Usage Minimum (0x0F)
    0x29, 0x12,                   //   Usage Maximum (0x12)
    0x15, 0x00,                   //   Logical Minimum (0)
    0x25, 0x01,                   //   Logical Maximum (1)
    0x75, 0x01,                   //   Report Size (1)
    0x95, 0x04,                   //   Report Count (4)
    0x81, 0x02,                   //   Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x34,                   //   Report Count (52)
    0x81, 0x03,                   //   Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x06, 0x00, 0xFF,             //   Usage Page (Vendor Defined 0xFF00)
    0x85, 0x21,                   //   Report ID (0x21)
    0x09, 0x01,                   //   Usage (0x01)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x3F,                   //   Report Count (63)
    0x81, 0x03,                   //   Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x85, 0x81,                   //   Report ID (0x81)
    0x09, 0x02,                   //   Usage (0x02)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x3F,                   //   Report Count (63)
    0x81, 0x03,                   //   Input (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x85, 0x01,                   //   Report ID (0x01)
    0x09, 0x03,                   //   Usage (0x03)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x3F,                   //   Report Count (63)
    0x91, 0x83,                   //   Output (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Volatile)
    0x85, 0x10,                   //   Report ID (0x10)
    0x09, 0x04,                   //   Usage (0x04)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x3F,                   //   Report Count (63)
    0x91, 0x83,                   //   Output (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Volatile)
    0x85, 0x80,                   //   Report ID (0x80)
    0x09, 0x05,                   //   Usage (0x05)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x3F,                   //   Report Count (63)
    0x91, 0x83,                   //   Output (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Volatile)
    0x85, 0x82,                   //   Report ID (0x82)
    0x09, 0x06,                   //   Usage (0x06)
    0x75, 0x08,                   //   Report Size (8)
    0x95, 0x3F,                   //   Report Count (63)
    0x91, 0x83,                   //   Output (Const,Var,Abs,No Wrap,Linear,Preferred State,No Null Position,Volatile)
    0xC0,                         // End Collection

    // 203 bytes
};

// Just to make sure these are in sync.
static_assert(sizeof(usbDescriptorHidReport) == USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH, "usbHidReportDescriptor contains a different number of entries than the USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH macro specifies");

As that last static assert suggests, I also needed to update usbconfig.h to let V-USB know the length of the decsriptor:

#define USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH    203

What the descriptor describes

Let’s think about that descriptor a bit. It’s extremely large! Here’s my old fake-HID-controller one for comparison:

PROGMEM const char usbDescriptorHidReport[] = {
    0x05, 0x01,        // Usage Page (Generic Desktop Ctrls)
    0x09, 0x04,        // Usage (Joystick)
    0xA1, 0x01,        // Collection (Application)
    0xA1, 0x00,        //   Collection (Physical)
    0x85, 0x42,        //     Report ID (0x42)
    0x05, 0x09,        //     Usage Page (Button)
    0x19, 0x01,        //     Usage Minimum (0x01)
    0x29, 0x10,        //     Usage Maximum (0x10)
    0x15, 0x00,        //     Logical Minimum (0)
    0x25, 0x01,        //     Logical Maximum (1)
    0x75, 0x01,        //     Report Size (1)
    0x95, 0x10,        //     Report Count (16)
    0x81, 0x02,        //     Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0x05, 0x01,        //     Usage Page (Generic Desktop Ctrls)
    0x09, 0x30,        //     Usage (X)
    0x09, 0x31,        //     Usage (Y)
    0x09, 0x32,        //     Usage (Z)
    0x09, 0x33,        //     Usage (Rx)
    0x15, 0x81,        //     Logical Minimum (-127)
    0x25, 0x7F,        //     Logical Maximum (127)
    0x75, 0x08,        //     Report Size (8)
    0x95, 0x04,        //     Report Count (4)
    0x81, 0x02,        //     Input (Data,Var,Abs,No Wrap,Linear,Preferred State,No Null Position)
    0xC0,              //   End Collection
    0xC0,              // End Collection
    
    // 48 bytes
};

It implements one ‘report’, ID 0x42, in which it specifies sixteen [0 - 1] (so, off/on) buttons for the buttons, and a four [-127 - 127] axes for the analog sticks (two for each stick). Super simple as far as USB HID goes.

Nintendo’s descriptor - woah. It specifies not one but seven different possible reports, with IDs 0x30, 0x21, 0x81, 0x01, 0x10, 0x80 and 0x82!

The first one listed, 0x30, does look pretty game-controllerey. It has ten off/on buttons, then another four off/on buttons, another two off/on buttons, then four 0-65534 axes, a 0-315 value that takes 4 bytes and is apparently rotation (I don’t quite get what format it’s describing - probably it’s the accelerometer though?), and another four off/on buttons, and, lastly, fifty-two(!) 8-bit values. That does seems like it could describe the Pro Controller’s state. The 52 bytes at the end are probably just padding to bring the total size of a report to a nice round 64 bytes.

What about reports 0x21, 0x81, 0x01, 0x10, 0x80 though? They’re all just described as 63 8-bit values. Not very descriptive.

Well, it turns out that we don’t really need to worry much about any of this, because the way the Pro Controller actually communicates with the Switch is pretty much not in compliance with the fine details of this descriptor. The report IDs will begin to make a bit of sense later though.

Endpoints

For my fake USB HID controller, V-USB implemented one USB ‘endpoint’ for us, and numbered it 0x01.

Switch Pro Controllers specify two endpoints - an IN endpoint numbered 0x01 (‘IN’ endpoints are for sending data ‘IN’ from the peripheral to the host, remember), and one OUT endpoint (for sending data ‘OUT’ from the host to the peripheral) numbered 0x81.

This configuration is not described by the HID report descriptor. Endpoints are specified in a more general kind of descriptor, a “Configuration Descriptor”. In the fake HID controller, I relied on V-USB to generate this descriptor for me. Now, I want it to be identical to the configuration descriptor of a Pro Controller, so more control over it is needed. V-USB allows this - you can switch off its auto-generation and specify your own descriptor.

Here is the configuration descriptor I added to descriptors.c, again created by wrapping the one in this Gist up in C:

PROGMEM const char usbDescriptorConfiguration[] = { 
    9,                          //  8: sizeof(usbDescriptorConfiguration): 
                                //     length of descriptor in bytes
    USBDESCR_CONFIG,            //  8: descriptor type
    USB_PROP_LENGTH(USB_CFG_DESCR_PROPS_CONFIGURATION), 0,  
                                // 16: total length of data returned 
                                //     (including inlined descriptors)
    2,                          //  8: number of interfaces in this configuration
    1,                          //  8: configuration value 
                                //     (index of this configuration)
    0,                          //  8: configuration name string index (no name)
    1 << 7 |
    USBATTR_REMOTEWAKE,         //  8: attributes (standard requires bit 7 
                                //     to be set)
    USB_CFG_MAX_BUS_POWER/2,    //  8: max USB current in 2mA units
    // Interface descriptors follow inline:
        9,                          //  8: sizeof(usbDescrInterface):
                                    //     length of descriptor in bytes
        USBDESCR_INTERFACE,         //  8: descriptor type
        0,                          //  8: index of this interface
        0,                          //  8: alternate setting for this interface
        2,                          //  8: number of endpoint descriptors to 
                                    //     follow (_excluding_ endpoint 0)
        USB_CFG_INTERFACE_CLASS,    //  8: interface class code
        USB_CFG_INTERFACE_SUBCLASS, //  8: interface subclass code
        USB_CFG_INTERFACE_PROTOCOL, //  8: interface protocol code
        0,                          //  8: string index for interface

            9,                      //  8: sizeof(usbDescrHID): 
                                    //     length of descriptor in bytes
            USBDESCR_HID,           //  8: descriptor type: HID
            0x01, 0x01,             //  8: BCD representation of HID version
            0x00,                   //  8: target country code
            0x01,                   //  8: number of HID Report (or other HID 
                                    //     class) Descriptor infos to follow
            0x22,                   //  8: descriptor type: report
            (USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH & 0xFF), ((USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH >> 8) & 0xFF),
                                    // 16: descriptor length

            7,                          //  8: sizeof(usbDescrEndpoint)
            USBDESCR_ENDPOINT,          //  8: descriptor type = endpoint
            0x81,                       //  8: IN endpoint number 1
            0x03,                       //  8: attrib: Interrupt endpoint
            8, 0,                       // 16: maximum packet size
            2,                          //  8: poll interval in ms

            7,                          //  8: sizeof(usbDescrEndpoint)
            USBDESCR_ENDPOINT,          //  8: descriptor type = endpoint
            0x01,                       //  8: OUT endpoint number 1
            0x03,                       //  8: attrib: Interrupt endpoint
            8, 0,                       // 16: maximum packet size
            8,                          //  8: poll interval in ms
};

static_assert(sizeof(usbDescriptorConfiguration) == USB_PROP_LENGTH(USB_CFG_DESCR_PROPS_CONFIGURATION), "usbHidReportDescriptor contains a different number of entries than the USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH macro specifies");

To get V-USB to use this descriptor, I also had to tell it about it in usbconfig.h. I replaced these two defines:

#define USB_CFG_INTR_POLL_INTERVAL.       8 
        // Not used - see descriptors in descriptors.c
        
#define USB_CFG_DESCR_PROPS_CONFIGURATION USB_PROP_LENGTH(9 + 9 + 9 + 7 + 7)

Just setting a size for USB_CFG_DESCR_PROPS_CONFIGURATION, using the USB_PROP_LENGTH macro, is enough to get V-USB to stop defining its own configuration descriptor and use the one implemented as usbDescriptorConfiguration. I didn’t really need to replace USB_CFG_INTR_POLL_INTERVAL. Because we’re specifying a custom configuration descriptor, the intervals listed in it will be used instead. I thought a comment would be a good idea in case I forget that in the future though.

Now that we’ve started at so many USB HID report descriptors, this Configuration Descriptor might look at least a little self-explanatory. It tells the host (the Switch), that we’re implementing one interface, which will have two endpoints.

I made two changes from the configuration descriptor in the Gist. First, the maximum packet size is set to 8 bytes, rather than 64. This needs to be done because “Low Speed” USB has a maximum packet size of 8 bytes. V-USB is limited to implementing Low Speed devices. The real Pro Controller is “Full Speed”, which is faster and has a 64-byte limit. The second change is that the poll interval for endpoint 1 is set to 2ms rather than 8ms. This is to ensure that the 8 packets that we have to send for each report can arrive quickly enough to drive gameplay.

Luckily, the Switch doesn’t notice that the descriptor is slightly different, and its USB hardware (probably just an off-the-shelf component) is perfectly happy to talk to us at ‘Low Speed’ and poll us at the rate we want.1

Speaking like a Pro Controller

That’s the configuration done with. Being a Pro Controller is more than looking like a Pro Controller, though. You actually need to quack like a Pro Controller.

If there’s nothing else going on, the Pro Controller just sends its current state to the Switch whenever IN endpoint 0x01 receives an interrupt from the Switch - just like the fake HID controller did.

In main.cpp, I changed where ‘sReports’ buffer was defined to make them 64 bytes long, which is what the Switch wants (and what our new HID descriptor specifies)

static uint8_t sReports[2][64] = { 0 };
static const uint8_t sReportSize = sizeof(sReports[0]);
static uint8_t sCurrentReport = 0;

Next, I rewrote prepareReport to prepare reports in the Switch’s format. Given that the Pro Controller has a USB HID descriptor, and we’re putting together reports with a 0x30 report ID here, you might think that the format of these reports would be the one described for report ID 0x30 in that HID descriptor. You would be wrong, however. The format of the report is actually completely different. It consists of a header with an increasing integer (I’m using V-USB’s SOF count here to provide that), then a report in the format that I’m preparing in prepareInputSubReportInBuffer. Yuki Mizuno’s blog post, that I mentioned earlier, goes into more detail on the format of this report (see the section titled Input Data).

static uint8_t prepareInputSubReportInBuffer(uint8_t *buffer) 
{
    buffer[0] = 0x81;
    memset(&buffer[1], 0, 10);
    
    // We'll alternately press the left and right dpad buttons for testing.
    if(sLedIsOn) {
        buffer[3] |= 0b100;
    } else {
        buffer[3] |= 0b1000;
    }
    
    // Return how much of the buffer we've filled.
    return 11;
}

static void prepareInputReport()
{
    uint8_t *report = sReports[sCurrentReport];
    report[0] = 0x30;
    report[1] = usbSofCount;
    
    // Prepare the input report in the Pro Controller's format. 
    const uint8_t innerReportLength = prepareInputSubReportInBuffer(&report[2]);
    
    // Fill the remainder of the buffer with 0. 
    memset(&report[2 + innerReportLength], 0, sReportSize - (2 + innerReportLength));
}

Later, I’ll create a struct that matches the expected format. For now, though, I’m just manually setting the bit corresponding to ‘left’ or ‘right’ on the D-pad, alternating direction every second based on the state of sLedIsOn, which ledHeartbeat() is already changing.

It might seem a bit weird have split prepareInputReport() into two functions - the reasons I did this this will become clear later.

There’s one last thing to do: I mentioned the reports are 64 bytes long. This presents a problem. As we discussed above, Low Speed USB can only send up to 8 bytes per interrupt. This isn’t as problematic as it first seems though - devices can just keep sending the next 8 bytes on the next interrupt until the report is done. It does mean, though, that just calling V-USB’s usbSetInterrupt once per report will no longer work. I wrote a helper function to replace usbSetInterrupt in the main loop that just keeps calling usbSetInterrupt until the entire report is sent:

static void sendReportBlocking()
{
    const uint8_t reportIndex = sCurrentReport;
    sCurrentReport = reportIndex == 0 ? 1 : 0;

    const uint8_t reportSize = sReportSize;
    uint8_t *report = sReports[reportIndex];
    uint8_t reportCursor = 0;
    do {
        while(!usbInterruptIsReady()) {
            usbPoll();
        }
        uint8_t bytesToSend = min(8, reportSize - reportCursor);
        usbSetInterrupt(&report[reportCursor], bytesToSend);
        reportCursor += bytesToSend;
    } while(reportCursor < reportSize);    
}

Bi-directional communication

Okay, so we’re now sending reports containing the controller state to the 0x01 endpoint regularly. Plug this in and it’ll control a Switch, right?

Well, no. Sorry. Things are about to get much more complicated!

We need to deal with the OUT endpoint 0x81. OUT endpoints, remember, are for the host (the Switch) to send data to the peripheral (the controller). V-USB can be configured to call a function we define when it receives data senrt to an OUT endpoint - void usbFunctionWriteOut(uchar *data, uchar len). To get it to do this, I needed to alter usbconfig.h a little more:

#define USB_CFG_IMPLEMENT_FN_WRITEOUT   1

Then I needed to implement usbFunctionWriteOut. My implementation is really too big to quote here, so instead of including the source inline, here’s a link to the function in the GitHub repo you can read along with the explanation below.

As I mentioned above, usbFunctionWriteOut will be called whenever V-USB receives a report to an OUT endpoint. It needs to parse the report, and prepare a reply if necessary. The Switch is extremely picky. If it does not receive a reply to a command it sends, or if the controller behaves in an unexpected manner, it will stop communication with the controller entierly until it is unplugged and plugged back in again.

The report ID is the first byte - and this corresponds to one of the IDs in the HID descriptor (above). That’s not immediately useful to know given that it’s not clear what the descriptor actually describes. But it’s been reverse-engineered! These are the IDs to expect:

  • 0x80: A ‘regular’ command. The Switch is asking the controller to do something. A reply is expected for some commands.
  • 0x01: A ‘UART’ command. This is a second, different, format the Switch sends commands in. It’s called ‘UART’ because it’s in the same format as the Switch uses to communicate with a physically connected Joycon over a wired UART connection. A reply is expected.
  • 0x10: Unknown. These are definitely Pro-Controller related reports, but the Switch does not expect a reply. They contain increasing integers. Maybe they’re just keep-alive reports, intended to allow the controller to notice if the connection gets broken if they stop coming? I ignore them.
  • 0x00: Unknown, but they can also be ignored. I think these might just be artifacts of V-USB letting some ‘internal’ USB communication get through and unrelated to the 0x81 endpoint.

All of the interesting packets are more than 8 bytes long. Just like when sending, Low Speed USB allows a maximum of 8 bytes per transfer, so these arrive in consecutive 8 byte chunks until they’re done. We need to accumulate the data on consecutive calls to usbFunctionWriteOut until we have a whole report. All of the reports are in different formats, and seemingly vary in length (despite the descriptor saying they should be 64 bytes long), and it took me quite some time and a lot of logging over a serial connection to my computer to work this out…

After a whole report is received, there is a switch(reportId) statement that decodes it - switching first on the report ID, then decoding the different formats for each ID.

I started with a pretty straightforward port of Yuki Mizuno’s Python and Go code, modifying it with some help from the UART command reverse-engineering document and other files in the reverse engineering repo.

One subcommand that looks complicated is UART subcommand 0x10, ‘NVRAM read’. It literally asks the controller to read from a specific address in some NVRAM it contrains. In the controller, an SPI interface is used to communicate with this RAM, and that acronym is peppered about my code too. If I were re-writing this I might try to use ‘NVRAM’ everywhere and eliminate the use of ‘SPI’ in my naming, but the use of ‘SPI’ is in line with the reverse-engineering documents.

Another notable pair are the ‘Regular’ subcommands 0x05 and 0x04. These mean ‘Stop HID Reports’ and ‘Start HID Reports’. Between receiving these commands, the controller must stop generating its regular HID-like reports (the ones described above, generated in response to the 0x01 interrupt). I added a sInputReportsSuspended global which is set when we’re supposed to be quiet.

How do we actually send the replies? There’s only one way to send data - responding to the 0x01 interrupt - so that’s what’s used.

If a reply to a command sent to the 0x81 endpoint is required, when an interrupt is next received the reply (in a completely different format to the usual report!) is sent instead of the HID-like report - even if sInputReportsSuspended is set.

Reply generation is done in the reportXXX functions - one function for each different format of reply. You might notice that some of the function names have a _P suffix - that’s just to remind me that the function expects data passed into it to be in PROGMEM rather than RAM.

All replies also contain a report of the controller’s current input state. I think this is done because, when the Switch gets particularly chatty, UART commands can come in thick and fast. Remember, we only have one way - responding to interrupts on endpoint 0x01 - to send data to the Switch. If the controller state was not included in command replies it would mean that state was sometimes not sent for milliseconds-long periods - which would obviously not be a good thing when you’re playing Metroid.

The prepareInputSubReportInBuffer function I wrote above is used to write the state into the replies - this is the reason I separated it from prepareInputSubReport.

One last notable thing: I also removed the implementation of usbFunctionSetup - the function V-USB calls when it receives a request to the ‘setup’ endpoint. The larger-than-8-byte reports complicated what it has to do. I believe that handling some setup packets is actually required by the USB spec - but I’ve never seen this actually called when connected to a Switch (or a Mac). I changed it to always return 0 (signifying ‘not handled’). I’ll worry about it again when if and when it turns out to be necessary in real life.

usbMsgLen_t usbFunctionSetup(uchar data[8])
{
    // I've never seen this called when connected to a Switch (or a Mac).
    // It might be necessary to implement the USB spec, but I really don't 
    // like having untested code that's never actually used.
    // Let's just return 0 (signifies 'not handled'), and worry about this if it
    // turns out to be needed later.
    return 0;
}

Working!

So, after all that, is it working? Can it control a Switch? Take a look at this!

This is my Switch in an iPad stand, upside-down to expose its USB port. It’s plugged into the breadboard’s USB-A cable via a USB-C convertor. You can see the cursor moving left and right, alternating direction every second!

The sharp eyed will notice some other additions to the breadboard. They will come into play in the next post, when I’ll tackle some problems I found while soak-testing this.

You can follow the code for this series on GitHub. The main branch there will grow as this series does. The blog_post_4 tag corresponds to the code as it is (was) at the end of this post.

All the posts in this series will be listed here as they appear.


  1. If you’re now getting concerned about this ‘speed’ difference being a problem, don’t worry, “Low Speed” USB is still plenty fast enough for a game controller - both in terms of raw bandwidth and latency. I may dive into analyzing this more in a future post. ↩︎