Skip to main content

AVR USB Made Simple - Vendor Specific Control Transfers

· 10 min read

In our previous post we implemented the bare minimum required to get a USB device to enumerate with the host and laid the groundwork for adding actual communications to our device. In this post we will do just that by adding support Vendor Specific control transfers, allowing a Host to Write and Read data from the device.

As a reminder from the previous post, we are using the atmega32u4 breakout board, a breadboard friendly device with a USB 1.1 (Full-speed) interface. The code to follow along can be found on our Github, just be sure to checkout the control_vendor_rw tag.

Without further ado, let's get started!

Vendor Specific Control Transfers

Vendor specific control transfers are exactly as they sound. They are Control transfers, sent via a Setup packet, that are specific to the vendor of the device. They are a great way to implement device configuration, high level device control (on/off), status retrieval, and other low bandwidth items that don't require the overhead of a Bulk or Interrupt transfer.

To achieve this the host sends a setup packet with a bRequestType of 0x40 which indicates a vendor specific request. To clarify a bit, the bRequestType value of 0x40 not only indicates a Control transfer but also that it is in the OUT direction, meaning Host to Device. The bRequestType value of 0xC0 indicates a Control transfer as well but in the IN direction, meaning Device to Host. When implementing Vendor specific control transfers in our device firmware we don't actually care about the IN or OUT direction as we will be handling both in the same way. The bRequestType value of 0x40 is all we need to know and then we will be using the bRequest value to determine if the transfer is a Write or Read. Let's see some code!

else if((bmRequestType & 0x60) == 0x40) { // Vendor specific request type
switch(bRequest) {
case 0x01:

// A packet was WRITTEN to us. Do something with the
// wValue and wIndex values here if needed.
// myVariable = wValue

// Reply with a ZLP
UEINTX &= ~(1 << TXINI);
// Wait for the bank to become ready again
while (!(UEINTX & (1<<TXINI)));
break;

case 0x02:
// Some data was requested from us. We can see how much data
// was requested from us using the wLength value. Load up
// the requested data and send it back as the `DATA` phase
for(uint16_t i = 0; i < wLength; i++) {
UEDATX = i;
}

// Clear the TXINI bit to initiate the transfer
UEINTX &= ~(1 << TXINI);
// Wait for the bank to become ready again
while (!(UEINTX & (1<<TXINI)));
break;

default:
// Unsupported vendor specific request. Reply with a STALL
UECONX |= (1 << STALLRQ);
break;
}
}

As you can see we add a new else if block to our _processSetupPacket function to check if the Vendor specific transfer type is set. From there we say that a bRequest value of 0x01 indicates the host is writing data to us (OUT), and a bRequest value of 0x02 indicates the host is requesting data from us (IN). Again to clarify, you could also implement two separate else if blocks for 0x40 and 0xC0 to further separate out the IN and OUT directions but for our purposes we are just going to handle them the same way.

The handling itself is pretty straight forward: for a Write we store the provided wValue and then reply with a ZLP (Zero length packet) to acknowledge the write. A read is just as simple, we check the requested length set via the wLength parameter and then load up the data to send back to the host. Once the data is loaded we initiate the transfer. If the host requests more data than we have available we will just send back whatever we have. Care should also be taken that all responses back to the Host fit within the defined packet size for your endpoint. Meaning you might need to break up your response into multiple packets if the requested wLength is greater than the packet size.

Integrating into our main application

Now that we can read and write data to our device, how do we actually use it in our application? The first and simplest way is to just add your get/set functions directly into the new if statement for handling control transfers. This is fine for simple applications but we can do better. Let's add some hooks into our main application that will allow us to register functions to be called when a transfer is received.

Creating the hooks

To start we will add two new function types to our usb.h file. The first function type usb_controlWrite_rx_cb_t is a function that takes a single uint16_t parameter and returns nothing - this will be used for our Write callback, meaning it's called when the host writes data to us, and the value written is passed as the rxData variable. The second function type usb_controlRead_tx_cb_t is a function that takes a uint8_t pointer and a uint16_t parameter and returns a uint16_t - this will be used for our Read callback, meaning it's called when the host requests data from us, and the txData pointer is where we will write our data to send back to the host, the requestedTxLen is the amount of data requested by the host. The return value of the function is the amount of data actually written to the txData buffer.

typedef void (*usb_controlWrite_rx_cb_t)(uint16_t rxData);
typedef uint16_t (*usb_controlRead_tx_cb_t)(uint8_t *txData, const uint16_t requestedTxLen);

Now we have a set of callbacks that we can register with our USB library. First create two new variables in our usb.c file to store the callbacks:

usb_controlWrite_rx_cb_t _setupWrite_cb = NULL;
usb_controlRead_tx_cb_t _setupRead_cb = NULL;

Let's update our usb_init function to take these callbacks as parameters and store them in our new variables:

void usb_init(usb_controlWrite_rx_cb_t onControlWriteCb, usb_controlRead_tx_cb_t onControlReadCb) {
// Power-On USB pads regulator
UHWCON |= (1 << UVREGE);

// VBUS int enable, VBUS pad enable, USB Controller enable
USBCON |= (1 << USBE) | (1 << OTGPADE) | (1 << FRZCLK);

// Toggle the FRZCLK to get WAKEUP IRQ
USBCON &= ~(1 << FRZCLK);
USBCON |= (1 << FRZCLK);

// Set the PLL input divisor to be 1:2 since we have
// at 16MHz input (we want 8MHz output on the PLL)
PLLCSR |= (1 << PINDIV);

// Default USB postscaler is fine as it generates
// the 48Mhz clock from the 8Mhz PLL input by default

// Start the PLL
PLLCSR |= (1 << PLLE);

// Wait for the PLL to lock
while (!(PLLCSR &(1<<PLOCK)));

// Leave power saving mode
USBCON &= ~(1 << FRZCLK);

// Store our CB if we received one
if(onControlWriteCb != NULL) {
_setupWrite_cb = onControlWriteCb;
}

if(onControlReadCb != NULL) {
_setupRead_cb = onControlReadCb;
}

// Attach the device by clearing the detach bit
// This is acceptable in the case of a bus-powered device
// otherwise you would initiate this step based on a
// VBUS detection.
UDCON &= ~(1 << DETACH);

// Enable the USB Reset IRQ. This IRQ fires when the
// host sends a USB reset to the device to kickoff
// USB enumeration. Init of the USB will continue in the
// ISRs when the reset signal is received.
UDIEN |= (1 << EORSTE);
}

Next, we need to add our callback definitions in main.c and pass them as parameters to our usb_init call.


void onUsbControlWrite(uint16_t rxData) {
// Do something with the rxData that was just written to us from the Host
}

uint16_t onUsbControlRead(uint8_t *txData, const uint16_t requestedTxLen) {
uint16_t txLen = 0;

// Load up data into the txData buffer to send back to the host.
// Make sure to also update txLen with the amount of data that we load

return txLen;
}

int main(void) {
// Set our LED port as an output
LED_STAT_DDR |= (1 << LED_STAT_PIN);

// Init USB and provide it our callback function
// to be called when data is received via a
// Control Write transfer
usb_init(onUsbControlWrite, onUsbControlRead);

// Enable global interrupts
sei();

while(1) {
// Do nothing
}
}

Last, we need to call the callbacks from our usb.c file when a control transfer is received. Let's update our else if block for handling control transfers to look like this:

else if((bmRequestType & 0x60) == 0x40) { // Vendor specific request type
switch(bRequest) {
case 0x01:
// If we have a callback stored, call it with the value
if(_setupWrite_cb != NULL) {
_setupWrite_cb(wValue);
}

// Reply with a ZLP
UEINTX &= ~(1 << TXINI);
// Wait for the bank to become ready again
while (!(UEINTX & (1<<TXINI)));
break;

case 0x02:
if(_setupRead_cb != NULL) {
// Call our callback to get the data to send back
uint16_t txLen = _setupRead_cb(_setup_read_buff,
(wLength > CONTROL_EP_BANK_SIZE ?
CONTROL_EP_BANK_SIZE :
wLength));
// Send the data back to the host
for(uint16_t i = 0; i < txLen; i++) {
UEDATX = _setup_read_buff[i];
}
// Clear the TXINI bit to initiate the transfer
UEINTX &= ~(1 << TXINI);
// Wait for the bank to become ready again
while (!(UEINTX & (1<<TXINI)));
}
else {
// No callback was provided so
// reply with a stall
UECONX |= (1 << STALLRQ);
}
break;

default:
// Unsupported vendor specific request. Reply with a STALL
UECONX |= (1 << STALLRQ);
break;
}
}

Now when a control transfer is received we will call the appropriate callback if one was provided. Next let's fill out the callbacks in our application to actually do something with the data that is received or requested.

Using the callbacks

For the Write callback, we are going to store the received data and then handle it in the main loop. We don't want to do much more than that since these callbacks are actually being called from the ISR in usb.c. We should be as quick and efficient as possible, offloading any heavy lifting to the main loop. In our example we are going to be using the received value as an LED state, which we will update in the main loop. In the Read callback we are going to load up a single value of 0x03 into the buffer and return with a value of 0x01 since we loaded a single byte. Let's see the code:

uint16_t my_led_state = 0;

void onUsbControlWrite(uint16_t rxData) {
my_led_state = rxData;
}

uint16_t onUsbControlRead(uint8_t *txData, const uint16_t requestedTxLen) {
uint16_t txLen = 1;

txData[0] = 0x03;

return txLen;
}

int main(void) {
// Set our LED port as an output
LED_STAT_DDR |= (1 << LED_STAT_PIN);

// Init USB and provide it our callback function
// to be called when data is received via a
// Control Write transfer
usb_init(onUsbControlWrite, onUsbControlRead);

// Enable global interrupts
sei();

while(1) {
if(my_led_state) {
LED_STAT_PORT |= (1 << LED_STAT_PIN);
}
else {
LED_STAT_PORT &= ~(1 << LED_STAT_PIN);
}
}
}

That's it, we're ready to connect with a Host and send vendor specific data!

Testing it out

To test out our new Vendor specific control transfers we have three languages to pick from: C, Python, and NodeJS. Each of these languages have an example for both the Write and Read transfers.

At each link you'll find instructions for building and using the applications.

Conclusion

That's it, we now have a working USB device that can be controlled via Vendor specific control transfers! In coming posts we will cover the Interrupt, Bulk and Isochronous transfer types, where we will talk about their key differences and when to use each one. We will also cover how to implement them in our device firmware. Stay tuned!