Embedded Journeys

First Time USB Data Streaming from the Raspberry Pico 2W Using TinyUSB

Going into the datasheet of the RP2350, I noticed the presence of an ADC, which led me to the idea to measure and visualize one of the signals produced by an ultrasonic distance sensor I previously wrote about. Using the ADC to measure the data is one thing, visualizing it is another. In order to visualize the data, I first had to find out a way to transfer the data from the Pico 2W onto my desktop. This is where USB comes in. This post describes how I finally managed to get my first USB data stream in place from the Raspberry Pico 2W to my desktop. The TinyUSB stack on the Pico 2W and python’s PyUSB on the desktop provided the necessary tools to enable this USB datastream. Python’s matplotlib package then made it possible to get a visualization. This USB data stream is just an intermediate step in streaming and plotting the ADC sampled values in the near future.

Environment

Just before getting into this post, here is some information about the setup I used.

I have been using a Raspberry Pi Pico 2W and some of the following software and their respective versions:

  • Visual Studio Code 1.106.3
  • Raspberry Pi Pico extension 0.19.0
  • TinyUSB 0.18.0, note: version installed through Raspberry Pi Pico extension, upstream already at 0.20.
  • Host PC running Windows 10
  • Python package PyUSB 1.3.1 and libusb1 3.3.1
  • Python package Matplotlib 3.10.7

High level component interaction

A simple model for USB connectivity, which suits the purpose of this post, can be seen in the image below. In its most basic form, USB defines communication channels between a host and a device. These communication channels are typically referred to as endpoints. Endpoint 0 is the most particular amongst these endpoints as it is the only endpoint which allows bidirectional communication and is always present. Endpoint 0 will allow the device to define other endpoints which will then actually be used for the data transfers.

USB is also a protocol which has the host polling the device.

High level mental model for USB

Since the microcontroller I’m using here is an RP2350 with a USB controller only allowing full speed USB, throughputs of up to 12Mbit/s should be achievable. However, this is just a theoretical and not a guaranteed throughput, so don’t expect that you’ll always attain this speed. If the device’s application is not providing fast enough, if the host is sharing the USB with other devices, if the host is not polling fast enough…, these are all reasons why the theoretical througput might not be obtained.

In the next chapters, the device and host setup will be discussed before they are put together.

RP2350 in USB Device mode

As stated earlier, the RP2350 provides a USB 2.0 hardware controller which can be used as a full speed USB device [1]. Looking through the pico C SDK [2] to interface with this USB controller, I ended up with tinyUSB [3] as the software stack to correctly configure and operate the USB controller. TinyUSB itself is already providing a decent amount of abstractions, so you interact from a relatively high level with this hardware.

TinyUSB architecture and runtime behavior

TinyUSB is provided with the Raspberry Pi Pico extension in Visual Studio Code and you can find the source code and examples in ...pico-sdk\sdk\2.2.0\lib\tinyusb. Besides, a first look at the official “Integrating TinyUSB” page, I found myself regularly going back to the example code and source files. Especially understanding the TinyUSB architecture (generic USB stack into device and then into the device class) and the related header files made the implementation a lot easier. The tusb.h header file is where it starts, it declares a.o. tusb_init() and whenever you #define CFG_TUD_ENABLED (in your tusb_config.h file), you notice how it includes the usbd.h file, which itself declares device specific functions like tud_ready(). Defining the correct device class like CFG_TUD_VENDOR, brings in vendor_device.h declaring functions like tud_vendor_write_available() and tud_vendor_write().

tusb_init() and tusb_inited()

First thing to do is to initialize the USB stack using tusb_init() and before calling anything else, you should wait for tusb_inited() to return true so you know the TinyUSB stack has correctly initialized.

tud_task()

This one is simple, but hard to miss ;) make sure tud_task() runs frequently! TinyUSB is a state machine and in order to advance its states, you need to run tud_task() frequently. Failing to do so breaks the USB device. TinyUSB’s troubleshooting mentions to regularly call it in the main loop with less than 1ms in between calls.

tud_ready(), tud_vendor_write_available() and tud_vendor_write()

After the previous items, it came down to checking wether the device was in a ready state, available for writing and then writing the data.

putting it all together

The pseudocode then becomes:

tusb_init()
while (!tusb_inited())
	//nop

while (true)
	tud_task()
	if (tud_ready() && tud_vendor_write_available())
		tud_vendor_write(data-to-transfer)

Since tud_task() needs to run frequently, you need to make sure your while loop loops fast enough.

USB (Device) enumeration

Another crucial part, is the USB Device Enumeration, which I found rather tedious to get it right. This enumeration process is performed when connecting the device. The USB host detects the presence of the device and “discovers” the endpoints that are available to interact with this device. The device itself only needs to correctly configure through a device descriptor and a configuration descriptor. What I finally did to configure the device with TinyUSB, was to:

  1. Define the USB device as a vendor class to get bulk transfers working in tusb_config.h
#define CFG_TUD_VENDOR 	1
  1. Selection of USB_VID and USB_PID: these are respectively the USB vendor ID and USB product ID of the device. For your custom projects, these can be chosen as you like, just avoid that you have a collision with one of your other USB devices. For commercial products, you’ll need to obtain your own vendor ID. Some USB providers allow you to use theirs. Up to you to decide what you can come by.
#define USB_VID 0xCafe
#define USB_PID 0x4001
  1. Define a device descriptor and configuration descriptor in usb_descriptors.c and make sure the corresponding callback functions tud_descriptor_device_cb() and tud_descriptor_configuration_cb() are defined.

Host

On the USB host side, I have my Windows 10 PC with a custom python application. First thing to do was to check whether my device got enumerated correctly. First time plugging in the USB device, I noticed how the device was just showing up in my device manager’s “other devices” as an “unknown device”. This can be seen in the image below. The “details” tab in the device properties reflected my devices’ VID and PID providing a high probability that this was actually my device.

Device manager’s “other devices” Unknown device

Since I have configured my device as a vendor device, I explicitly assigned a generic WinUSB driver to it using Zadig [4].

Using Zadig to assign a device driver to my usb device

Successful device enumeration on the host

In reality, device enumeration wasn’t that simple though. I was frequently confronted with “This device cannot start. (Code 10)” and “Windows has stopped this device because it has reported problems. (Code 43)”. These were typically caused by some bugs on the USB device. The former was typically cause when tud_task() wasn’t called frequently enough, the latter was typically due to some bugs in the device or configuration descriptor.

Device manager - device cannot start Device manager - device descriptor failed

Some initial tests on the datastream

Initial throughput tests

Just to get an initial idea of the throughput, without any optimization, I made it to 3.4Mbit/s with my current setup. Just adding a printf() statement in the main loop made the throughput drop to 1.6Mbit/s. If you’ve been working with USB data transfers, I’m curious what throughput you’ve achieved and under what conditions. Feel free to drop a note in the comments below!

Cosine wave visualization

As a second test, I had my USB device create a cosine wave with a 2 second period. I had a timer running which generated a new sample every 10ms. There is absolutely no timestamping of the samples created on the USB device, nor is there any timestamp calculation performed on the host device. The samples were just drawn on the host the moment they came in over the USB connection.

Animated cosine wave with a period of 2s

References

Future work

These are clearly some first steps in working with the USB data transfers, but a crucial foundation. Next I plan to stream actual ADC measurements from the RP2350, add timestamps, and explore buffering strategies to better understand the real performance limits of this setup.

comments powered by Disqus