Cynthion Documentation

Cynthion

Cynthion Project Description

Cynthion is an all-in-one tool for building, testing, monitoring, and experimenting with USB devices. Built around a unique FPGA-based architecture, Cynthion’s digital hardware can be fully customized to suit the application at hand. As a result, it can act as a no-compromise High-Speed USB protocol analyzer, a USB-hacking multi-tool, or a USB development platform.

Out-of-the-box, Cynthion acts as a USB protocol analyzer capable of capturing and analyzing traffic between a host and any Low-, Full-, or High-Speed (“USB 2.0”) USB device. It works seamlessly with our Packetry open-source analysis software.

Combined with our LUNA gateware and Facedancer libraries, Cynthion becomes a versatile USB-hacking and development tool. Facedancer makes it quick and easy to create or tamper with real USB devices—not just emulations—even if you don’t have experience with digital hardware design, HDL, or FPGA architecture!

Getting Started with Cynthion

Prerequisites

To use Cynthion you will need to ensure the following software is installed:

Cynthion Host Software Installation

The Cynthion host software distribution can be obtained from the Python Package Index (PyPI) or directly from source.

Note

For more information on installing Python packages from PyPI please refer to the “Installing Packages” section of the Python Packaging User Guide.

Use pip to install the Cynthion host software:

pip install cynthion

Install udev Rules

Configure your system to allow access to Cynthion for logged in users:

cynthion setup

If you’d prefer to perform this step manually, you can download and install the rules as follows:

# download udev rules
curl -O https://raw.githubusercontent.com/greatscottgadgets/cynthion/main/cynthion/python/assets/54-cynthion.rules

# install udev rules
sudo cp 54-cynthion.rules /etc/udev/rules.d

# reload udev rules
sudo udevadm control --reload

# apply udev rules to any devices that are already plugged in
sudo udevadm trigger

You can check that the rules are installed correctly with:

cynthion setup --check

Use Homebrew to install Python and libusb:

brew install python libusb

Use pip to install the Cynthion host software:

pip install cynthion

Note

The Cynthion host software uses the libusb1 Python package to communicate with the hardware. On macOS, the package does not install the native dynamic library with it, so it’s necessary to install the libusb native library through Homebrew, MacPorts or some other route.

If you are not using a Python distribution from Homebrew you may be able to direct Cynthion to the correct location by explicitly setting DYLD_FALLBACK_LIBRARY_PATH to the location of the libusb native library.

For example:

DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib" cynthion info

Use pip to install the Cynthion host software:

pip install cynthion

Test Installation

Connect Hardware

Connection diagram for testing Cynthion installation.

Test Hardware Connectivity

Open a terminal and confirm that everything is working by running:

cynthion info --force-offline

If everything is working you will see the following output:

Found Cynthion device!
    Hardware: Cynthion r1.4
    Manufacturer: Great Scott Gadgets
    Product: Cynthion Apollo Debugger
    Serial number: xxxxxxxxxxxxxxxxxxxxxxxxxx
    Vendor ID: 1d50
    Product ID: 615c
    bcdDevice: 0104
    Firmware version: v1.0.6
    USB API version: 1.1
    Flash UID: xxxxxxxxxxxxxxxx

Updating Cynthion Host Software

To update the Cynthion host software to the latest version run:

pip install --upgrade cynthion

Updating Cynthion Microcontroller Firmware and FPGA configuration flash

To upgrade the Cynthion Microcontroller firmware and FPGA configuration flash to the latest versions run:

cynthion update

You can update the Microcontroller firmware separately with:

cynthion update --mcu-firmware

You can update the FPGA configuration flash separately with:

cynthion update --bitstream

Using Cynthion with Packetry

Together with Packetry, Cynthion can be used as a USB 2.0 protocol analyzer capable of capturing and analyzing traffic between a host and any Low, Full, and High Speed USB device.

Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion section.

Prerequisites

To use Cynthion’s USB Analyzer you will need to ensure the following software is installed:

USB Analyzer Bitstream

Cynthion ships from the factory with the USB Analyzer as the default bitstream for the FPGA.

If you have previously flashed a different default bitstream you can run the USB Analyzer bitstream with:

cynthion run analyzer

If you want to configure USB Analyzer as the default bitstream for the FPGA:

cynthion flash analyzer

You can verify that everything is working by running:

cynthion info

You should see output like:

Detected a Cynthion device!
    Bitstream: USB Analyzer (Cynthion Project)
    Hardware: Cynthion r1.4
    Flash UID: xxxxxxxxxxxxxxxx

Connect Hardware

Connection diagram for using Cynthion with Packetry.

Next, see the Packetry documentation for more detail, or the tutorial Protocol analysis of a USB keyboard for a worked example.

Using Cynthion with Facedancer

Together with Facedancer, Cynthion can be used to quickly and easily emulate USB devices controlled from Python running on the host computer.

Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion section.

Install the Facedancer library

You can install the Facedancer library from the Python Package Index (PyPI), a release archive or directly from source.

Install From PyPI

You can use the pip tool to install the Facedancer library from PyPI using the following command:

pip install facedancer

For more information on installing Python packages from PyPI please refer to the “Installing Packages” section of the Python Packaging User Guide.

Install From Source

git clone https://github.com/greatscottgadgets/facedancer.git
cd facedancer/

Once you have the source code downloaded you can install the Facedancer library with:

pip install .

Load Facedancer Bitstream and Firmware

You can run the Facedancer Bitstream and Firmware by running:

cynthion run facedancer

You can verify that everything is working by running:

cynthion info

You should see output like:

Detected a Cynthion device!
    Bitstream: Facedancer (Cynthion Project)
    Hardware: Cynthion r1.4
    Flash UID: xxxxxxxxxxxxxxxx

Connect Hardware

Connection diagram for using Cynthion with Facedancer.

Make sure that the target host is running a program that can receive keyboard input such as a terminal or text editor and that it has focus.

Run a Facedancer example

Create a new Python file called rubber-ducky.py with the following content:

 1import asyncio
 2
 3from facedancer                   import main
 4from facedancer.devices.keyboard  import USBKeyboardDevice
 5
 6device = USBKeyboardDevice()
 7
 8async def type_letters():
 9    # Wait for device to connect
10    await asyncio.sleep(2)
11
12    # Type a string with the device
13    await device.type_string("echo hello, facedancer\n")
14
15main(device, type_letters())

Open a terminal and run:

python ./rubber-ducky.py

If all goes well, you should see the string hello, facedancer typed into the target host.

More Information

For further information, see the Facedancer documentation.

Using Cynthion with USB Proxy

Together with USB Proxy, Cynthion can proxy packets between a target host and a target device attached to the control computer.

Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion and Using Cynthion with Facedancer sections.

Connect Hardware

Connection diagram for using Cynthion with Facedancer.

Run a USB Proxy example

Create a new Python file called usbproxy.py with the following content:

 1#!/usr/bin/env python3
 2#
 3# This file is part of Facedancer.
 4#
 5""" USB Proxy example; forwards all USB transactions and logs them to the console. """
 6
 7from facedancer          import main
 8
 9from facedancer.proxy    import USBProxyDevice
10from facedancer.filters  import USBProxySetupFilters, USBProxyPrettyPrintFilter
11
12# replace with the proxied device's information
13ID_VENDOR  = 0x1050
14ID_PRODUCT = 0x0407
15
16
17if __name__ == "__main__":
18    # create a USB Proxy Device
19    proxy = USBProxyDevice(idVendor=ID_VENDOR, idProduct=ID_PRODUCT)
20
21    # add a filter to forward control transfers between the target host and
22    # proxied device
23    proxy.add_filter(USBProxySetupFilters(proxy, verbose=0))
24
25    # add a filter to log USB transactions to the console
26    proxy.add_filter(USBProxyPrettyPrintFilter(verbose=5))
27
28    main(proxy)

Open a terminal and run:

python ./usbproxy.py

Note

USBProxy requires root privileges on macOS in order to claim the device being proxied from the operating system.

sudo python ./usbproxy.py
python ./usbproxy.py

If all goes well you should see the output from device enumeration in your terminal and the proxied USB device should be detected by the target computer.

More Information

For further information, see the Facedancer USB Proxy documentation.

Using Cynthion USER I/O with Facedancer

In addition to the four USB ports Cynthion also includes the following user i/o ports:

Apart from PMOD B, which is used for the Facedancer SoC UART and JTAG interface, all of these can be accessed from within Python Facedancer devices.

Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion and Using Cynthion with Facedancer sections.

Requirements

  • A Cynthion running the Facedancer bitstream.

  • Two USB Cables.

Using Cynthion APIs

To access Cynthion USER i/o using Python you first need an instance of the Cynthion object, which can be created as follows:

from cynthion import Cynthion
c = Cynthion()

Once you have a Cynthion instance you will be able to access USER i/o using the leds and gpio APIs.

For example, open a new Python shell:

$ python

Python 3.11.11 (main, Feb 12 2025, 14:40:14) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

First obtain a Cynthion instance:

>>> from cynthion import Cynthion
>>> c = Cynthion()

Turn on USER Led5:

>>> c.leds[5].on()

Turn off USER Led5:

>>> c.leds[5].off()

Toggle USER Led4:

>>> c.leds[4].toggle()

Get the USER Button:

>>> user_button = c.gpio.get_pin("USER")

Wait for the USER Button to be pressed: (hit enter twice to start)

>>> while user_button.read() == False: pass
...

Using USER Button and Leds with Facedancer

Lets modify the Facedancer rubber-ducky example to give us a bit more information and control using Cynthion USER i/o. We’ll subclass a Facedancer device and add some calls to the USER i/o APIs in response to host requests and device responses.

Create a new Python file called facedancer-user-io.py and add the following content:

facedancer-user-io.py
 1import asyncio
 2import logging
 3
 4from facedancer                   import main, errors
 5from facedancer.devices.keyboard  import USBKeyboardDevice
 6
 7from cynthion                     import Cynthion
 8
 9# Subclass USBKeyboardDevice
10class MyKeyboardDevice(USBKeyboardDevice):
11    def __post_init__(self):
12        super().__post_init__()
13
14        # Get a Cynthion instance.
15        cynthion = Cynthion()
16
17        # Get USER Leds
18        self.leds = cynthion.leds
19
20        # Make sure all USER Leds are off
21        [led.off() for led in self.leds.values()]
22
23        # Get USER Button
24        self.user_button = cynthion.gpio.get_pin("USER")
25
26    def handle_bus_reset(self):
27        # Strobe USER Led0 every time we see a bus reset
28        self.leds[0].strobe(duration=0.1)
29        super().handle_bus_reset()
30
31    def handle_request(self, request):
32        # Strobe USER Led1 every time the host makes a control request
33        self.leds[1].strobe(duration=0.1)
34        super().handle_request(request)
35
36    def control_send(self, endpoint_number, in_request, data, *, blocking = False):
37        # Strobe USER Led2 every time the device responds to a control request
38        self.leds[2].strobe(duration=0.1)
39        super().control_send(endpoint_number, in_request, data, blocking=blocking)
40
41    def handle_data_requested(self, endpoint):
42        report = self._generate_hid_report()
43        endpoint.send(report)
44
45        # Strobe USER Led3 every time the host requested a HID report descriptor from the host
46        if report[2] == 0:
47            self.leds[3].strobe(duration=0.1)
48        # Strobe USER Led4 if the report descriptor contained a scancode for the host
49        else:
50            self.leds[4].strobe(duration=0.1)
51
52# Rubber-ducky control script
53async def type_letters():
54    # Wait for device to connect
55    await asyncio.sleep(2)
56
57    logging.info("Press the USER button to proceed.")
58
59    # Wait until Cynthion's USER button is pressed
60    while device.user_button.read() == False:
61        await asyncio.sleep(0.01)
62
63    logging.info("Typing string into target device.")
64
65    # Type a string with the device
66    await device.type_string("echo hello, facedancer\n")
67
68    logging.info("Finished. Press the USER button again to quit.")
69
70    # Done
71    while device.user_button.read() == False:
72        await asyncio.sleep(0.01)
73
74    raise errors.EndEmulation("User quit the emulation.")
75
76# Start emulation
77device = MyKeyboardDevice()
78main(device, type_letters())

Open a terminal and run:

python ./facedancer-user-io.py

If everything went well you should see prompts at various points to press the USER button to continue execution as well as the USER Leds flashiing in response to device events.

USER Pmod inputs and outputs

In addition to the USER Button and Leds, Facedancer can also make use of Cynthion USER Pmod A (USER Pmod B is used for JTAG and UART duties) to trigger or respond to external hardware. They use the same gpio APIs as the USER Button but individual pins can also be configured as inputs or outputs.

Let’s build a simple example that uses two of the USER Pmod A pins to connect a switch and a LED to Cynthion.

You will need:

  • 1x SPST Switch

  • 1x 1 kOhm resistor

  • 1x 10 kOhm resistor

  • 1x LED

  • 1x Breadboard

Circuit Diagram

Cynthion USER Pmod i/o example circuit diagram.

Breadboard Layout

Cynthion USER Pmod i/o example breadboard diagram.

Source Code

Create a new Python file called cynthion-user-pmod.py with the following content:

cynthion-user-pmod.py
 1import time
 2
 3from cynthion import           Cynthion
 4from cynthion.interfaces.gpio  import PinDirection
 5
 6# Get Cynthion instance
 7c = Cynthion()
 8
 9# Get USER Pmod Pin A1 and configure it as an input
10a1 = c.gpio.get_pin("A1")
11a1.set_direction(PinDirection.Input)
12
13# Get USER Pmod Pin A3 and configure it as an output
14a3 = c.gpio.get_pin("A3")
15a3.set_direction(PinDirection.Output)
16
17# Continuously read the input value of Pin A1 and output it to Pin A3.
18while True:
19    value = a1.read()
20    a3.write(value)
21    time.sleep(0.1)

Open a terminal and run:

python ./cynthion-user-pmod.py

If all goes well, the LED should light up when you press the switch and turn off when you release it.

The cynthion command line interface

$ apollo
usage: cynthion [-h] command ...

Cynthion command line interface

positional arguments:
  command
    run       run a bitstream on the FPGA
    flash     overwrite the FPGA's configuration flash with the target bitstream
    update    update MCU firmware and FPGA configuration flash to the latest
              installed versions
    info      print device information
    setup     install Cynthion support files required for operation (Linux only)

optional arguments:
  -h, --help  show this help message and exit

Command Documentation

Display Cynthion Information

Display Cynthion bitstream information:

cynthion info

Display Cynthion Microcontroller information:

cynthion info --force-offline

Note

Once you have switched to the Cynthion Microcontroller by pressing the PROGRAM button or the --force-offline option you will need to press the RESET button to return control to the FPGA.

Set up Cynthion

Check that your host environment is set up for Cynthion:

cynthion setup --check

Set up your host environment for Cynthion:

cynthion setup

Remove all files installed during set up:

cynthion setup --uninstall

Update Cynthion

Update both the Cynthion Debug Microcontroller firmware and USB Analyzer bitstream to the latest installed factory versions:

cynthion update

Update Cynthion Debug Microcontroller firmware to the latest installed factory version:

cynthion update --mcu-firmware

Update Cynthion USB Analyzer bitstream to the latest installed factory version:

cynthion update --bitstream

Run bitstream

Runs the given factory bitstream on the FPGA:

cynthion run <analyzer|facedancer|selftest>

Runs the bitstream specified by <filename> on the FPGA.

cynthion run --bitstream <filename>

Flash firmware and bitstreams

Overwrite the FPGA’s default bitstream with the given factory bitstream:

cynthion flash <analyzer|facedancer>

Overwrite the FPGA’s default bitstream with the one specified by <filename>:

cynthion flash --bitstream <filename>

Overwrite the Microcontroller firmware with the one specified by <filename>:

cynthion flash --mcu-firmware <filename>

Overwrite the SoC firmware with the one specified by <filename>:

cynthion flash --soc-firmware <filename>

Protocol analysis of a USB keyboard

This tutorial walks through the whole process of running a USB protocol capture of a target device, in this case a keyboard. Hopefully most people have a USB keyboard available and can follow along, though this process is also applicable to any USB peripheral device.

Prerequisites

Determine device speed

USB 2.0 supports three different speeds: Low (1.5 Mbit/s), Full (12 Mbit/s), and High (480 Mbit/s). The analyzer needs to know what speed to expect, so we need to detemine what speed the target device is using.

Note

Soon this step won’t be necessary, as the analyzer will be able to determine the speed automatically, but that feature is currently in development.

To determine the speed, we plug the target device into a host check what speed it reports. The way to check depends on the host operating system:

Run the command sudo dmesg -W in a terminal window and then plug the target device in, some new lines should show up with information about it:

[975321.743878] usb 1-6.1: new full-speed USB device number 12 using xhci_hcd
[975321.821329] usb 1-6.1: New USB device found, idVendor=3434, idProduct=0108, bcdDevice= 2.00
[975321.821341] usb 1-6.1: New USB device strings: Mfr=1, Product=2, SerialNumber=0
[975321.821345] usb 1-6.1: Product: Keychron Q1
[975321.821348] usb 1-6.1: Manufacturer: Keychron

Here, it shows that the target device has enumerated at full-speed.

Go to the Apple menu -> About This Mac -> More Info -> System Report -> Hardware -> USB. Highlight the target device and check the Speed field.

The easiest way to look at USB device information on Windows is to install and run USBView, then find the target device and check its Device Bus Speed field.

Connect

Next, we’ll connect everything up for capture. Within the Cynthion hardware the TARGET C and TARGET A ports are connected together, and the analyzer gateware will capture any packets that are seen going between those ports. These packets are then sent out through the CONTROL port.

Connection diagram for using Cynthion with Packetry.

First, connect the CONTROL port to the host that will be running Packetry. Next, connect the TARGET C port to a host. This can be a different host from the one running Packetry, or it can be the same host. If it is the same host, the two connections must not be on the same hub.

The TARGET A port will connect to the target device, but for now we leave it disconnected.

Capture

Open Packetry. At the top of the window, you should see the action bar:

Action Bar

Select the correct speed, press the capture button (the filled circle), and plug in the target device. The cable connections should look like this:

Connection diagram for using Cynthion with Packetry

Upon plugging in the target device, a collection of entries should show up in the Traffic Pane, these show the requests that a host makes to find out information about a new device (known as USB enumeration).

Packetry application showing a capture

The target device should also show up in the Devices pane to the right. All of the entries in the Traffic and Device panes can be expanded for more detail, by clicking on the black triangles. Clicking on an entry in the Traffic pane to highlight it will show extra detail in the pane below.

If you press and release a key on the keyboard, some new entries should show up:

Packetry showing two interrupt transfers for a key press-and-release event

Here I pressed the a key, causing two interrupt transfers; one for the press and one for the release event. If you want to go further, you could look at the USB human interface device class (HID) specification, to learn about the class-specific descriptors and endpoints, and match up what you see on the bus to keypress events.

Troubleshooting

Below are some common issues you may run into, with some advice for resolving them. If you run into further issues, please see the Getting Help section for more support.

Capture button is grayed out and no capture device shows up in Packetry

Action bar showing no capture device available
  • Double check that the Cynthion CONTROL port is connected to the host running Packetry.

  • Check that the cable is good, ideally trying it with another USB device that transfers data (not just charging).

  • Make sure that the analyzer gateware is running on the Cynthion device by following Using Cynthion with Packetry.

No traffic shows up during capture

First, make sure the target device is operating correctly. If following along with a keyboard, make sure that any keypresses get through to the target host. If traffic still isn’t showing up, this is usually caused by selecting the wrong capture speed, try capturing with each of the other two speed options.

Traffic shows “Invalid Groups”

Packetry traffic pane with many "Invalid groups" entries

This means that the analyzer is detecting packets that are invalid. This is usually caused by selecting the wrong capture speed, try capturing with each of the other two speed options.

Traffic shows “unidentified transfer” and Devices shows an “Unknown” device

Packetry showing unidentified transfers and unknown device

This means that valid packets have been captured, but Packetry did not see the target device enumeration so it doesn’t have enough information to fully decode the USB transactions/transfers. If you would like to see the full decoding, make sure to start the capture in Packetry before plugging in the target device.

Emulation of a USB Device

This tutorial walks through the whole process of emulating a USB device with Cynthion and Facedancer. We’ll emulate HackRF One, a software-defined radio platform. The goal of our emulation is to fool the hackrf_info command into reporting that a HackRF One is connected.

Prerequisites

  • Install the Cynthion tools by following Getting Started with Cynthion.

  • Install HackRF Tools by following Installing HackRF Software.

  • Install the Facedancer library and run the Facedancer bitstream and firmware as described in Using Cynthion with Facedancer.

    Note

    If you would like to configure your Cynthion for Facedancer operation permanently instead of temporarily, use cynthion flash facedancer instead of cynthion run facedancer.

Try to Detect a HackRF One

Use the hackrf_info command to detect any connected HackRF devices:

hackrf_info

The command output should indicate that no HackRF devices are found:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
No HackRF boards found.

Connect

We need to connect our Cynthion before we can use it to emulate a HackRF One. If you followed the prerequisites above, you should already have connected the Cynthion’s CONTROL port to your computer.

Now also connect the TARGET C port to your computer. Facedancer software uses CONTROL to control the Cynthion and TARGET C to connect to the target host, the computer which we’ll try to fool into thinking that there is a HackRF One connected. The control host and target host can be two separate computers, but in this tutorial we will use the same computer as both the control host and the target host.

Connection diagram for using Cynthion with Facedancer on a single host computer.

Emulate the Vendor ID and Product ID

Use your favorite text editor to create a new Python program called hackrf_emulation.py with the following contents:

from facedancer import *
from facedancer import main

@use_inner_classes_automatically
class HackRF(USBDevice):
    product_string       : str = "HackRF One (Emulated)"
    manufacturer_string  : str = "Facedancer"
    serial_number_string : str = "1234"
    vendor_id            : int = 0x1d50
    product_id           : int = 0x6089

    class DefaultConfiguration(USBConfiguration):
        class DefaultInterface(USBInterface):
            pass

main(HackRF)

Every USB device identifies itself to its host computer using a 16-bit Vendor ID and 16-bit Product ID. This program uses the Facedancer library to implement a device with the Vendor ID and Product ID associated with HackRF One. It also configures some strings which make our emulated HackRF distinguishable from an actual HackRF One (with tools such as lsusb) for convenience.

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, open another terminal and execute hackrf_info. It should display output similar to this:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
hackrf_board_id_read() failed: Pipe error (-1000)

We’ve just convinced hackrf_info that it has found a HackRF device! However, hackrf_info failed to read the HackRF’s board ID which distinguishes between the various hardware platforms supported by HackRF software. The pipe error indicates that the device did not provide the expected response to the host’s request for the board ID.

Terminate hackrf_emulation.py by typing ctrl-c. Because we used the --suggest option, it should provide output like this:

Automatic Suggestions
These suggestions are based on simple observed behavior;
not all of these suggestions may be useful / desirable.


Request handler code:

    @vendor_request_handler(number=14, direction=USBDirection.IN)
    @to_device
    def handle_control_request_14(self, request):
        # Most recent request was for 1B of data.
        # Replace me with your handler.
        request.stall()

Try the Suggested Code

Add the suggested code to the HackRF class in hackrf_emulation.py. The program should now look like:

from facedancer import *
from facedancer import main

@use_inner_classes_automatically
class HackRF(USBDevice):
    product_string       : str = "HackRF One (Emulated)"
    manufacturer_string  : str = "Facedancer"
    serial_number_string : str = "1234"
    vendor_id            : int = 0x1d50
    product_id           : int = 0x6089

    class DefaultConfiguration(USBConfiguration):
        class DefaultInterface(USBInterface):
            pass

    @vendor_request_handler(number=14, direction=USBDirection.IN)
    @to_device
    def handle_control_request_14(self, request):
        # Most recent request was for 1B of data.
        # Replace me with your handler.
        request.stall()

main(HackRF)

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, execute hackrf_info in another terminal:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
hackrf_board_id_read() failed: Pipe error (-1000)

It turns out that our emulation still results in a pipe error. This is because we are stalling vendor request number 14 which is meant to return a 1 byte board ID. Terminate hackrf_emulation.py and replace the request_stall() line with:

request.reply([1])

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, execute hackrf_info in another terminal:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 1 (Jawbreaker)
hackrf_version_string_read() failed: Pipe error (-1000)

We’ve now convinced hackrf_info that our Cynthion is a HackRF Jawbreaker which was the beta platform that preceded HackRF One. Let’s try a higher board ID number. Replace request.reply([1]) with:

request.reply([2])

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, execute hackrf_info in another terminal:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
hackrf_version_string_read() failed: Pipe error (-1000)

We did it! Our new board ID represents HackRF One! In this example we guessed low numbers for the board ID byte, but we could have discovered that 2 represents HackRF One by observing the behavior of an actual HackRF One or by reading the libhackrf source code or HackRF firmware source code.

Handle the Version String Request

Unfortunately, hackrf_info still indicates an error, this time with reading a version string. The --suggest option on your Facedancer program should give you an idea of how to handle that request:

@vendor_request_handler(number=15, direction=USBDirection.IN)
@to_device
def handle_control_request_15(self, request):
    # Most recent request was for 255B of data.
    # Replace me with your handler.
    request.stall()

Notice that this time the host has requested 255 bytes instead of just one byte. USB devices often return a smaller number of bytes than the length requested by the host. In this case we can guess that the host is requesting a maximum length string and that we can probably return something shorter. Let’s try adding this to the HackRF class in hackrf_emulation.py:

@vendor_request_handler(number=15, direction=USBDirection.IN)
@to_device
def handle_control_request_15(self, request):
    # Most recent request was for 255B of data.
    request.reply(b"tutorial version")

The complete program should now look like:

from facedancer import *
from facedancer import main

@use_inner_classes_automatically
class HackRF(USBDevice):
    product_string       : str = "HackRF One (Emulated)"
    manufacturer_string  : str = "Facedancer"
    serial_number_string : str = "1234"
    vendor_id            : int = 0x1d50
    product_id           : int = 0x6089

    class DefaultConfiguration(USBConfiguration):
        class DefaultInterface(USBInterface):
            pass

    @vendor_request_handler(number=14, direction=USBDirection.IN)
    @to_device
    def handle_control_request_14(self, request):
        # Most recent request was for 1B of data.
        # Replace me with your handler.
        request.reply([2])

    @vendor_request_handler(number=15, direction=USBDirection.IN)
    @to_device
    def handle_control_request_15(self, request):
        # Most recent request was for 255B of data.
        request.reply(b"tutorial version")

main(HackRF)

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, execute hackrf_info in another terminal:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
Firmware Version: tutorial version (API:0.00)
hackrf_board_partid_serialno_read() failed: Pipe error (-1000)

Handle the Part ID Request

Now we can see another unhandled request made by hackrf_info. The --suggest output tells us that we can handle it with something like:

@vendor_request_handler(number=18, direction=USBDirection.IN)
@to_device
def handle_control_request_18(self, request):
    # Most recent request was for 24B of data.
    # Replace me with your handler.
    request.stall()

The host is asking for 24 bytes this time, suggesting that it is looking for exactly 24 bytes. Let’s try replying with 24 bytes of dummy data:

@vendor_request_handler(number=18, direction=USBDirection.IN)
@to_device
def handle_control_request_18(self, request):
    # Most recent request was for 24B of data.
    request.reply(b"A" * 24)

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, execute hackrf_info in another terminal:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
Firmware Version: tutorial version (API:0.00)
Part ID Number: 0x41414141 0x41414141
hackrf_close() failed: Pipe error (-1000)

It looks like the part ID was interpreted as a valid number, and now hackrf_info is trying to close the device! We’re almost done!

Handle the Close Request

Based on the --suggest output, add the following to hackrf_emulation.py:

@vendor_request_handler(number=1, direction=USBDirection.OUT)
@to_device
def handle_control_request_1(self, request):
    request.ack()

Notice that this time the direction of the vendor request is OUT instead of IN. This means that the host is sending data to the device, not asking the device to send data to the host. We acknowledge the request instead of replying with data.

Execute the program:

python hackrf_emulation.py --suggest

While the program is running, execute hackrf_info in another terminal:

hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
Firmware Version: tutorial version (API:0.00)
Part ID Number: 0x41414141 0x41414141

Success! hackrf_info now exits without error!

Put It All Together

With a few edits based on what we’ve learned, our complete program might look like this:

from facedancer import *
from facedancer import main

@use_inner_classes_automatically
class HackRF(USBDevice):
    product_string       : str = "HackRF One (Emulated)"
    manufacturer_string  : str = "Facedancer"
    serial_number_string : str = "1234"
    vendor_id            : int = 0x1d50
    product_id           : int = 0x6089

    class DefaultConfiguration(USBConfiguration):
        class DefaultInterface(USBInterface):
            pass

    @vendor_request_handler(number=14, direction=USBDirection.IN)
    @to_device
    def handle_board_id_request(self, request):
        # return 1-byte board ID
        request.reply([2])

    @vendor_request_handler(number=15, direction=USBDirection.IN)
    @to_device
    def handle_version_string_request(self, request):
        # return up to 255 bytes
        request.reply(b"tutorial version")

    @vendor_request_handler(number=18, direction=USBDirection.IN)
    @to_device
    def handle_part_id_request(self, request):
        # return 24 byte part ID
        request.reply(b"A" * 24)

    @vendor_request_handler(number=1, direction=USBDirection.OUT)
    @to_device
    def handle_close_request(self, request):
        request.reply([])

main(HackRF)

Gateware Blinky

This tutorial walks through the process of developing a simple “blinky” example for Cynthion’s ECP5 FPGA. We’ll use the Amaranth Language & toolchain to create the design and generate a FPGA bitstream using the Yosys Open SYnthesis Suite.

Prerequisites

Before you begin, please make sure you have installed the Cynthion tools by following Getting Started with Cynthion.

Install Toolchain

To generate bitstreams for Cynthion you will need a synthesis toolchain that can convert the Verilog produced by Amaranth into a bitstream for Cynthion’s ECP5 FPGA.

For these tutorials we recommend YoWASP which provides unofficial WebAssembly-based packages for Yosys and NextPNR. It runs a little slower than the official OSS CAD Suite distribution but it’s platform-independent and much easier to get started with.

Install YoWASP using pip:

pip install yowasp-yosys yowasp-nextpnr-ecp5

Create a new Amaranth module

Create a new file called gateware-blinky.py and add the following code to it:

1from amaranth import *
2
3class Top(Elaboratable):
4    def elaborate(self, platform):
5        m = Module()
6
7        return m

Amaranth designs are built from a hierarchy of smaller modules, which are called elaboratables. The Top class expresses that this will be the top-level or entry-point of your design.

Right now the Top module does not do anything except to create an Amaranth Module() and return it from the elaborate(...) method.

The elaborate(...) method also takes an argument called platform that contains resources specific to the board or platform the module is compiled for.

In this case, the argument will be an instance of the Cynthion Board Description and contain a map of Cynthion resources such as LEDs, USB PHY’s, USER PMOD connectors and board constraints.

Obtain a platform resource

Edit gateware-blinky.py and add the highlighted line:

1from amaranth import *
2
3class Top(Elaboratable):
4    def elaborate(self, platform):
5        m = Module()
6
7        leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9        return m

Amaranth platform resources can be obtained via the platform.request(name, number=0) method where name is the name of the resource and number is the index of the resource if there are more than one defined.

In this case we use a Python list comprehension to obtain all six FPGA led’s and concatenate them into a six-bit addressable Amaranth Signal using the Cat operation.

Timer State

To make the LED blink at predictable intervals we’ll use a simple timer.

To start with, let’s define the timer state by adding the highlighted lines:

 1from amaranth import *
 2
 3class Top(Elaboratable):
 4    def elaborate(self, platform):
 5        m = Module()
 6
 7        leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
 8
 9        half_freq: int    = int(60e6 // 2)
10        timer: Signal(25) = Signal(range(half_freq))
11
12        return m

First we’ll declare a variable half_freq which is exactly half of Cynthion FPGA’s default clock frequency in Hz, next we’ll declare timer to be an Amaranth Signal which is wide enough to contain a value equal to half_freq - 1.

If we increment the timer by one for each clock cycle until it reaches half_freq - 1 we get a timer with a 500ms period.

Timer Logic

Now that we have a state definition for our timer we can move forward to the implementation logic, edit your file and add the highlighted lines:

 1from amaranth import *
 2
 3class Top(Elaboratable):
 4    def elaborate(self, platform):
 5        m = Module()
 6
 7        leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
 8
 9        half_freq: int    = int(60e6 // 2)
10        timer: Signal(25) = Signal(range(half_freq))
11
12        with m.If(timer == half_freq - 1):
13            m.d.sync += leds.eq(~leds)
14            m.d.sync += timer.eq(0)
15
16        with m.Else():
17            m.d.sync += timer.eq(timer + 1)
18
19        return m

Amaranth combines normal Python expressions with Amaranth in order to describe a design. Whenever you see the prefix m. you are making a call to the Module object you created at the beginning of the elaborate(...) method. These calls are what build the logic which makes up a design.

The with m.If(...): and with m.Else(): blocks operate much like you’d expect where, every clock-cycle, the expression timer == half_freq - 1 will be evaluated and trigger the corresponding branch.

The first block represents the point at which the timer has expired and we’d like to change the state of the LEDs and then reset the timer back to zero.

In the second block the timer is still active so we simply increment timer by one.

Put It All Together

The contents of gateware-blinky.py should now look like this:

 1#!/usr/bin/env python3
 2#
 3# This file is part of Cynthion.
 4#
 5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
 6# SPDX-License-Identifier: BSD-3-Clause
 7
 8from amaranth import *
 9
10class Top(Elaboratable):
11    def elaborate(self, platform):
12        m = Module()
13
14        leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
15
16        half_freq: int    = int(60e6 // 2)
17        timer: Signal(25) = Signal(range(half_freq))
18
19        with m.If(timer == half_freq - 1):
20            m.d.sync += leds.eq(~leds)
21            m.d.sync += timer.eq(0)
22
23        with m.Else():
24            m.d.sync += timer.eq(timer + 1)
25
26        return m
27
28if __name__ == "__main__":
29    from luna import top_level_cli
30    top_level_cli(Top)

Build and Upload FPGA Bitstream

Make sure your Cynthion CONTROL port is plugged into the host, open a terminal and then run:

python gateware-blinky.py

The blinky gateware will now be synthesized, placed, routed and automatically uploaded to the Cynthion’s FPGA.

Once this process has completed successfully all six of Cynthion’s FPGA LEDs should be flashing on and off.

Exercises

  1. Modify the tutorial to turn the FPGA LEDs into a binary counter that increments by one every 250ms.

  2. Connect the USER PMOD A port to the output of your counter and use a logic analyzer (e.g. GreatFET One) to view the values as they increment.

More information:

USB Gateware: Part 1 - Enumeration

This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:

The goal of this tutorial is to create a gateware design for the simplest USB Device that can still be enumerated by a host.

Prerequisites

Define a USB Device

USB devices are defined using a hierarchy of descriptors that contain information such as:

  • The product name and serial number.

  • The vendor who made it.

  • The class of device it is.

  • The ways in which it can be configured.

  • The number and types of endpoints it has.

At the root of this hierarchy lies the Device Descriptor and a device can only have one.

Create the Device Descriptor

Create a new file called gateware-usb-device.py and add the following code to it:

gateware-usb-device.py
 1from amaranth               import  *
 2from usb_protocol.emitters  import  DeviceDescriptorCollection
 3
 4VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 5PRODUCT_ID = 0x0001
 6
 7class GatewareUSBDevice(Elaboratable):
 8    def create_descriptors(self):
 9        descriptors = DeviceDescriptorCollection()
10
11        with descriptors.DeviceDescriptor() as d:
12            d.idVendor           = VENDOR_ID
13            d.idProduct          = PRODUCT_ID
14            d.iManufacturer      = "Cynthion Project"
15            d.iProduct           = "Gateware USB Device"
16            d.bNumConfigurations = 1
17
18        return descriptors
19
20    def elaborate(self, platform):
21        m = Module()
22        return m

We have now created a minimal device descriptor with a vendor id, product id, a manufacturer, a product description and one Configuration Descriptor.

USB devices can have multiple configurations but only one can be active at a time. This allows a USB device to be configured differently depending on the situtation. For example, a device might be configured differently if it’s bus-powered vs self-powered.

Create the Configuration Descriptor

Next, add a configuration descriptor for our device by adding the highlighted lines:

gateware-usb-device.py
 1from amaranth               import *
 2from usb_protocol.emitters  import DeviceDescriptorCollection
 3
 4VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 5PRODUCT_ID = 0x0001
 6
 7class GatewareUSBDevice(Elaboratable):
 8    def create_descriptors(self):
 9        descriptors = DeviceDescriptorCollection()
10
11        with descriptors.DeviceDescriptor() as d:
12            d.idVendor           = VENDOR_ID
13            d.idProduct          = PRODUCT_ID
14            d.iManufacturer      = "Cynthion Project"
15            d.iProduct           = "Gateware USB Device"
16            d.bNumConfigurations = 1
17
18        with descriptors.ConfigurationDescriptor() as c:
19            with c.InterfaceDescriptor() as i:
20                i.bInterfaceNumber = 0
21
22        return descriptors
23
24    def elaborate(self, platform):
25        m = Module()
26        return m

We have now created the descriptors for a device with a single configuration descriptor and one interface descriptor with no endpoints. (We’ll add some endpoints later!)

Note

Each USB Configuration can have multiple interface descriptors and they can all be active at the same time. This allows a USB device to create functional groups that are each responsible for a single function of the device. For example, a USB Audio Interface may have one interface descriptor with two endpoints for audio input/output and another interface descriptor with one endpoint for MIDI input.

Create Device Gateware

Now that we have defined our device’s descriptors we need to create the interface between our device’s physical USB port and the gateware that implements the device’s function(s). Fortunately the LUNA library takes care of all the hard work for us and we only need to add the following lines:

gateware-usb-device.py
 1from amaranth               import *
 2from luna.usb2              import USBDevice
 3from usb_protocol.emitters  import DeviceDescriptorCollection
 4
 5VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 6PRODUCT_ID = 0x0001
 7
 8class GatewareUSBDevice(Elaboratable):
 9    def create_descriptors(self):
10        descriptors = DeviceDescriptorCollection()
11
12        with descriptors.DeviceDescriptor() as d:
13            d.idVendor           = VENDOR_ID
14            d.idProduct          = PRODUCT_ID
15            d.iManufacturer      = "Cynthion Project"
16            d.iProduct           = "Gateware USB Device"
17            d.bNumConfigurations = 1
18
19        with descriptors.ConfigurationDescriptor() as c:
20            with c.InterfaceDescriptor() as i:
21                i.bInterfaceNumber = 0
22
23        return descriptors
24
25    def elaborate(self, platform):
26        m = Module()
27
28        # configure cynthion's clocks and reset signals
29        m.submodules.car = platform.clock_domain_generator()
30
31        # request the physical interface for cynthion's TARGET C port
32        ulpi = platform.request("target_phy")
33        m.submodules.usb = usb = USBDevice(bus=ulpi)
34
35        # create our descriptors and add them to the device's control endpoint
36        descriptors = self.create_descriptors()
37        control_ep = usb.add_standard_control_endpoint(descriptors)
38
39        # configure the device to connect by default when plugged into a host
40        m.d.comb += usb.connect.eq(1)
41
42        return m
43
44if __name__ == "__main__":
45    from luna import top_level_cli
46    top_level_cli(GatewareUSBDevice)

Testing the Device

Connect

We need to connect our Cynthion before we can test our new USB device. If you followed the prerequisites above, you should already have connected the Cynthion’s CONTROL port to your computer.

Now also connect the TARGET C port to your computer as this is the port we requested our USB Device to run on. The control host and target host can be two separate computers, but in this tutorial we will use the same computer as both the control host and the target host.

Connection diagram for using Cynthion with a Gateware USB Device on a single host computer.

Build

Build the device gateware and upload it to your Cynthion by typing the following into your terminal shell:

python ./gateware-usb-device.py

If everything went well and Cynthion’s TARGET C port is connected we should now be able to check if the target host managed to succesfully enumerate our device.

Test

To check if the device was recognized by the target host’s operating system follow the corresponding instructions:

Create a new file called test-gateware-usb-device.py and add the following code to it:

test-gateware-usb-device.py
 1import usb1
 2
 3def list_available_usb_devices(context):
 4    for device in context.getDeviceList():
 5        try:
 6            manufacturer = device.getManufacturer()
 7            product = device.getProduct()
 8            print(f"{device}:  {manufacturer} - {product}")
 9        except Exception as e:
10            print(f"{device}: {e}")
11
12if __name__ == "__main__":
13    with usb1.USBContext() as context:
14        list_available_usb_devices(context)

Run the file with:

python ./test-gateware-usb-device.py

And, if the device is recognized, you should see a line like:

Bus 000 Device 001: ID 1d5c:5010:  Fresco Logic, Inc. - USB2.0 Hub
Bus 000 Device 002: ID 1d5c:5000:  Fresco Logic, Inc. - USB3.0 Hub
Bus 000 Device 003: ID 1d50:615c:  Great Scott Gadgets - Cynthion Apollo Debugger
Bus 000 Device 007: ID 1209:0001:  Cynthion Project - Gateware USB Device

If you’re running on Windows you may instead see something like:

Bus 000 Device 001: ID 1d5c:5010:  Fresco Logic, Inc. - USB2.0 Hub
Bus 000 Device 002: ID 1d5c:5000:  Fresco Logic, Inc. - USB3.0 Hub
Bus 000 Device 003: ID 1d50:615c:  Great Scott Gadgets - Cynthion Apollo Debugger
Bus 000 Device 007: ID 1209:0001:  LIBUSB_ERROR_NOT_SUPPORTED [-12]

The devices Product and Vendor ID’s are correct (1209:0001) but Windows could not obtain the product or manufacturer strings. This behaviour is expected and we’ll be taking a closer look at it in the next part of the tutorial.

Run the following command in a terminal window:

lsusb

If the device enumerated successfully you should see an entry similiar to the highlighted line:

% lsusb

Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 003: ID 2109:2822 VIA Labs, Inc. USB2.0 Hub
Bus 001 Device 045: ID 1d50:615c OpenMoko, Inc. Cynthion Apollo Debugger
Bus 001 Device 046: ID 1209:0001 Generic pid.codes Test PID
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub

To view the device’s descriptors, pass the product and vendor id’s by running:

lsusb -d 1209:0001 -v

Run the following command in a terminal window:

ioreg -b -p IOUSB

If the device enumerated successfully you should see an entry similiar to the highlighted line:

 % ioreg -b -p IOUSB

 +-o Root  <class IORegistryEntry, id 0x100000100, retain 30>
   +-o AppleT8103USBXHCI@00000000  <class AppleT8103USBXHCI, id 0x100000331, registered, matched, ac$
     +-o USB2.0 Hub@00100000  <class IOUSBHostDevice, id 0x1000ee65d, registered, matched, active, b$
     | +-o USB2.0 Hub@00140000  <class IOUSBHostDevice, id 0x1000ee6b0, registered, matched, active,$
     | | +-o Cynthion Apollo Debugger@00144000  <class IOUSBHostDevice, id 0x100180243, registered, $
     | +-o Gateware USB Device@00130000  <class IOUSBHostDevice, id 0x100181cb3, registered, matched$
     +-o USB3.0 Hub@00200000  <class IOUSBHostDevice, id 0x100181add, registered, matched, active, b$
       +-o USB3.0 Hub@00240000  <class IOUSBHostDevice, id 0x100181aef, registered, matched, active,$

To view more information, pass the device name:

ioreg -b -p IOUSB -n "Gateware USB Device"

The easiest way to check a USB device is to open the Windows Device Manager. However, if you try this with our device you will notice there’s a small problem:

Gateware USB Device on Windows without WCID Descriptors.

We can see our device, but it has a warning icon indicating that it does not have an installed device driver. Unlike macOS or Linux, Windows does not support a generic USB driver for non-class devices with custom vendor interfaces. In the next part of the tutorial we’ll look at how to solve this.

Conclusion

Our device can now be enumerated by a host but, if you’re running Microsoft Windows, you will have noticed that the device still requires a device driver to function.

The next part of the tutorial is optional and will cover WCID Descriptors which is a mechanism introduced by Microsoft to allow Windows applications to communicate directly with USB devices without the neccessity of writing device drivers.

If you don’t need to target Windows please feel free to skip the next part and jump straight to USB Gateware: Part 3 - Control Transfers to learn how to add the Control Request Handlers to our device that allow it to receive and respond to control requests from the host.

Exercises

  1. Try changing the device descriptor information to match an existing hardware USB device. What happens?

More information

Source Code

gateware-usb-device-01.py
 1#!/usr/bin/env python3
 2#
 3# This file is part of Cynthion.
 4#
 5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
 6# SPDX-License-Identifier: BSD-3-Clause
 7
 8from amaranth               import *
 9from luna.usb2              import USBDevice
10from usb_protocol.emitters  import DeviceDescriptorCollection
11
12VENDOR_ID  = 0x1209 # https://pid.codes/1209/
13PRODUCT_ID = 0x0001
14
15class GatewareUSBDevice(Elaboratable):
16    """ A simple USB device that can only enumerate. """
17
18    def create_standard_descriptors(self):
19        """ Create the USB descriptors for the device. """
20
21        descriptors = DeviceDescriptorCollection()
22
23        # all USB devices have a single device descriptor
24        with descriptors.DeviceDescriptor() as d:
25            d.idVendor           = VENDOR_ID
26            d.idProduct          = PRODUCT_ID
27            d.iManufacturer      = "Cynthion Project"
28            d.iProduct           = "Gateware USB Device"
29
30            d.bNumConfigurations = 1
31
32        # and at least one configuration descriptor
33        with descriptors.ConfigurationDescriptor() as c:
34
35            # with at least one interface descriptor
36            with c.InterfaceDescriptor() as i:
37                i.bInterfaceNumber = 0
38
39                # interfaces also need endpoints to do anything useful
40                # but we'll add those later!
41
42        return descriptors
43
44
45    def elaborate(self, platform):
46        m = Module()
47
48        # configure cynthion's clocks and reset signals
49        m.submodules.car = platform.clock_domain_generator()
50
51        # request the physical interface for cynthion's TARGET C port
52        ulpi = platform.request("target_phy")
53
54        # create the USB device
55        m.submodules.usb = usb = USBDevice(bus=ulpi)
56
57        # create our standard descriptors and add them to the device's control endpoint
58        descriptors = self.create_standard_descriptors()
59        control_endpoint = usb.add_standard_control_endpoint(descriptors)
60
61        # configure the device to connect by default when plugged into a host
62        m.d.comb += usb.connect.eq(1)
63
64        return m
65
66
67if __name__ == "__main__":
68    from luna import top_level_cli
69    top_level_cli(GatewareUSBDevice)
test-gateware-usb-device-01.py
 1import usb1
 2
 3# - list available usb devices ------------------------------------------------
 4
 5def list_available_usb_devices(context):
 6    for device in context.getDeviceList():
 7        try:
 8            manufacturer = device.getManufacturer()
 9            product = device.getProduct()
10            print(f"{device}:  {manufacturer} - {product}")
11        except Exception as e:
12            print(f"{device}: {e}")
13
14
15# - main ----------------------------------------------------------------------
16
17if __name__ == "__main__":
18    with usb1.USBContext() as context:
19        list_available_usb_devices(context)

USB Gateware: Part 2 - WCID Descriptors

This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:

The goal of this tutorial is to define the descriptors that will tell Microsoft Windows to use the built-in generic WinUSB driver to communicate with our device.

This tutorial is optional and only required if you would like to use your device on Windows.

Prerequisites

WCID Devices

WCID devices or “Windows Compatible ID devices”, are USB devices that provide extra information to Windows in order to facilitate automatic driver installation or, more frequently, allow programs to obtain direct access to the device.

Historically, Windows required manual installation of drivers for non-class devices with custom vendor interfaces. Contrasted with Linux or macOS which will automatically assign a generic USB driver that allows for direct interaction with the device’s endpoints via a cross-platform library such as libusb or operating system API’s.

Microsoft eventually relented and now provide a Windows-specific mechanism for a device to advertise that it requires a generic WinUSB driver.

The full details are documented in the Microsoft OS 1.0 and Microsoft OS 2.0 specifications but the basic mechanism consists of a set of Windows-specific descriptor requests made by the host whenever a new device is plugged in.

For Microsoft OS 1.0, this boils down to three descriptor requests we need to be able to handle:

  1. Microsoft OS String Descriptor

  2. Microsoft Compatible ID Feature Descriptor

  3. Microsoft Extended Properties Feature Descriptor

Microsoft OS String Descriptor

To start with, edit your gateware-usb-device.py file from the previous tutorial and add/modify the highlighted lines:

gateware-usb-device.py
 1from amaranth                                    import *
 2from luna.usb2                                   import USBDevice
 3from usb_protocol.emitters                       import DeviceDescriptorCollection
 4
 5from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
 6
 7...
 8
 9class GatewareUSBDevice(Elaboratable):
10    ...
11
12    def elaborate(self, platform):
13        m = Module()
14
15        # configure cynthion's clocks and reset signals
16        m.submodules.car = platform.clock_domain_generator()
17
18        # request the physical interface for cynthion's TARGET C port
19        ulpi = platform.request("target_phy")
20
21        # create the USB device
22        m.submodules.usb = usb = USBDevice(bus=ulpi)
23
24        # create our standard descriptors and add them to the device's control endpoint
25        descriptors = self.create_standard_descriptors()
26        control_endpoint = usb.add_standard_control_endpoint(descriptors)
27
28        # add the microsoft os string descriptor
29        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
30
31        # configure the device to connect by default when plugged into a host
32        m.d.comb += usb.connect.eq(1)
33
34        return m

The Microsoft OS String Descriptor responds to a standard String Descriptor request with an index of 0xee. It encodes two values:

0x12,         # Descriptor Length: 18 bytes
0x03,         # Descriptor Type: 3 = String
0x4d, 0x00,   # M
0x53, 0x00,   # S
0x46, 0x00,   # F
0x54, 0x00,   # T
0x31, 0x00,   # 1
0x30, 0x00,   # 0
0x30, 0x00,   # 0
0xee, 0x00,   # Vendor Code: 0xee

The first 14 bytes correspond to the little-endian encoded Unicode string MSFT100, with the remaining two bytes corresponding to the Vendor Code Windows should use when requesting the other descriptors. This is often set to the same value as the Microsoft OS String Descriptor index of 0xee, but you can use another value if it conflicts with an existing Vendor Code used by your device.

Microsoft Compatible ID Feature Descriptor

Next, add the Microsoft Compatible ID Feature Descriptor:

gateware-usb-device.py
 1from amaranth                                    import *
 2from luna.usb2                                   import USBDevice
 3from usb_protocol.emitters                       import DeviceDescriptorCollection
 4
 5from luna.gateware.usb.request.windows           import (
 6    MicrosoftOS10DescriptorCollection,
 7)
 8from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
 9
10
11VENDOR_ID  = 0x1209 # https://pid.codes/1209/
12PRODUCT_ID = 0x0001
13
14class GatewareUSBDevice(Elaboratable):
15    ...
16
17    def elaborate(self, platform):
18        ...
19
20        # add the microsoft os string descriptor
21        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
22
23        # add a microsoft descriptor collection for our other two microsoft descriptors
24        msft_descriptors = MicrosoftOS10DescriptorCollection()
25
26        # add the microsoft compatible id feature descriptor
27        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
28            with c.Function() as f:
29                f.bFirstInterfaceNumber = 0
30                f.compatibleID          = 'WINUSB'
31
32        # configure the device to connect by default when plugged into a host
33        m.d.comb += usb.connect.eq(1)
34
35        return m

Our remaining descriptors are not returned via Standard Requests, instead they are implemented as Vendor Requests with Microsoft-defined Vendor Indices and the Vendor Code supplied in the Microsoft OS String Descriptor. We will implement the actual vendor request handler in the final step of the tutorial but for now we are just defining the Microsoft OS 1.0 Descriptor Collection that will contain these descriptors.

Our example is defining the simplest possible Compatible ID Feature descriptor, specifying a Function with a device interface number of 0 and a compatible ID of WINUSB. This is how we tell Windows to use the generic WinUSB driver for the interface.

If our device had multiple interfaces we could simply extended this by adding additional functions for each interface like so:

with msft_descriptors.ExtendedCompatIDDescriptor() as c:
    with c.Function() as f:
        f.bFirstInterfaceNumber = 0
        f.compatibleID          = 'WINUSB'
    with c.Function() as f:
        f.bFirstInterfaceNumber = 1
        f.compatibleID          = 'WINUSB'
    ...

Microsoft Extended Properties Feature Descriptor

We now come to our third descriptor, the Microsoft Extended Properties Feature Descriptor:

gateware-usb-device.py
 1from amaranth                                    import *
 2from luna.usb2                                   import USBDevice
 3from usb_protocol.emitters                       import DeviceDescriptorCollection
 4
 5from luna.gateware.usb.request.windows           import (
 6    MicrosoftOS10DescriptorCollection,
 7)
 8from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
 9from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
10
11..
12
13class GatewareUSBDevice(Elaboratable):
14    ...
15
16    def elaborate(self, platform):
17        ...
18
19        # add the microsoft os string descriptor
20        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
21
22        # add a microsoft descriptor collection for our other two microsoft descriptors
23        msft_descriptors = MicrosoftOS10DescriptorCollection()
24
25        # add the microsoft compatible id feature descriptor
26        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
27            with c.Function() as f:
28                f.bFirstInterfaceNumber = 0
29                f.compatibleID          = 'WINUSB'
30
31        # add microsoft extended properties feature descriptor
32        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
33            with d.Property() as p:
34                p.dwPropertyDataType = RegistryTypes.REG_SZ
35                p.PropertyName       = "DeviceInterfaceGUID"
36                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
37
38        # configure the device to connect by default when plugged into a host
39        m.d.comb += usb.connect.eq(1)
40
41        return m

The Extended Properties Feature Descriptor can be used to define additional device registry settings but, in our example, we only define the Device Interface GUID we’d like our device to be accessed with.

In this case it’s the Microsoft-defined GUID of {88bae032-5a81-49f0-bc3d-a4ff138216d6} which is defined as “all USB devices that don’t belong to another class”. If, for example, our device were a Keyboard or Mouse we’d need to use the appropriate value here.

Microsoft Descriptor Request Handler

Finally, now that all our descriptors are defined we need to add the actual Vendor Request Handler that will be responsible for responding to descriptor requests from a Windows Host:

gateware-usb-device.py
 1from amaranth                                    import *
 2from luna.usb2                                   import USBDevice
 3from usb_protocol.emitters                       import DeviceDescriptorCollection
 4
 5from luna.gateware.usb.request.windows           import (
 6    MicrosoftOS10DescriptorCollection,
 7    MicrosoftOS10RequestHandler,
 8)
 9from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
10from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
11
12..
13
14class GatewareUSBDevice(Elaboratable):
15    ...
16
17    def elaborate(self, platform):
18        ...
19
20        # add the microsoft os string descriptor
21        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
22
23        # add a microsoft descriptor collection for our other two microsoft descriptors
24        msft_descriptors = MicrosoftOS10DescriptorCollection()
25
26        # add the microsoft compatible id feature descriptor
27        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
28            with c.Function() as f:
29                f.bFirstInterfaceNumber = 0
30                f.compatibleID          = 'WINUSB'
31
32        # add microsoft extended properties feature descriptor
33        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
34            with d.Property() as p:
35                p.dwPropertyDataType = RegistryTypes.REG_SZ
36                p.PropertyName       = "DeviceInterfaceGUID"
37                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
38
39        # add the request handler for Microsoft descriptors
40        msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
41        control_endpoint.add_request_handler(msft_handler)
42
43        # configure the device to connect by default when plugged into a host
44        m.d.comb += usb.connect.eq(1)
45
46        return m

LUNA provides a pre-defined implementation for handling Microsoft OS10 Descriptor Requests and only requires the descriptor collection and the vendor request code we defined in the Microsoft OS10 String Descriptor.

Testing the Device

Connect

  • For this tutorial you will need to connect the Cynthion TARGET C port to a Windows computer for testing.

  • Plug the CONTROL port into the computer you’ve been using to control Cynthion. If this is the same machine as the Windows computer you’re using to test, plug it in there.

Build

Build the device gateware and upload it to your Cynthion by typing the following into your terminal shell:

python ./gateware-usb-device.py

If everything went well we should now be able to check if Windows can recognize the device.

Test

To test whether the WCID descriptors have been recognized, open the Windows Device Manager and look for the device under the “Universal Serial Bus devices” section:

Gateware USB Device on Windows without WCID Descriptors.

You should find that the Python test program from USB Gateware: Part 1 - Enumeration now works as expected:

test-gateware-usb-device.py
 1import usb1
 2
 3def list_devices(context):
 4    for device in context.getDeviceList():
 5        try:
 6            manufacturer = device.getManufacturer()
 7            product = device.getProduct()
 8            print(f"{device}:  {manufacturer} - {product}")
 9        except Exception as e:
10            print(f"{device}: {e}")
11
12if __name__ == "__main__":
13    with usb1.USBContext() as context:
14        list_devices(context)

Run the file with:

python ./test-gateware-usb-device.py

And, if the device is recognized, you should see a line like:

Bus 000 Device 001: ID 1d5c:5010:  Fresco Logic, Inc. - USB2.0 Hub
Bus 000 Device 002: ID 1d5c:5000:  Fresco Logic, Inc. - USB3.0 Hub
Bus 000 Device 003: ID 1d50:615c:  Great Scott Gadgets - Cynthion Apollo Debugger
Bus 000 Device 007: ID 1209:0001:  Cynthion Project - Gateware USB Device

Conclusion

Our device can now be enumerated by Microsoft Windows but it can’t actually do anything yet. In the next part we’ll learn how to add Vendor Request Handlers to our device that allow it to receive and respond to control requests from the host: USB Gateware: Part 3 - Control Transfers

Exercises

  • Modify the example to use a different request code, does it still work?

  • Could you use the information you learnt in this tutorial modify the LUNA ACM Serial example example to support Windows?

  • Modify the PropertyData field of the extended properties descriptor to one of the Microsoft-provided USB device class drivers. What happens?

More information

Source Code

gateware-usb-device-02.py
 1#!/usr/bin/env python3
 2#
 3# This file is part of Cynthion.
 4#
 5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
 6# SPDX-License-Identifier: BSD-3-Clause
 7
 8from amaranth                                    import *
 9from luna.usb2                                   import USBDevice
10from usb_protocol.emitters                       import DeviceDescriptorCollection
11
12from luna.gateware.usb.request.windows           import (
13    MicrosoftOS10DescriptorCollection,
14    MicrosoftOS10RequestHandler,
15)
16from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
17from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
18
19VENDOR_ID  = 0x1209 # https://pid.codes/1209/
20PRODUCT_ID = 0x0001
21
22class GatewareUSBDevice(Elaboratable):
23    """ A simple USB device that can also enumerate on Windows. """
24
25    def create_standard_descriptors(self):
26        """ Create the USB descriptors for the device. """
27
28        descriptors = DeviceDescriptorCollection()
29
30        # all USB devices have a single device descriptor
31        with descriptors.DeviceDescriptor() as d:
32            d.idVendor           = VENDOR_ID
33            d.idProduct          = PRODUCT_ID
34            d.iManufacturer      = "Cynthion Project"
35            d.iProduct           = "Gateware USB Device"
36
37            d.bNumConfigurations = 1
38
39        # and at least one configuration descriptor
40        with descriptors.ConfigurationDescriptor() as c:
41
42            # with at least one interface descriptor
43            with c.InterfaceDescriptor() as i:
44                i.bInterfaceNumber = 0
45
46                # interfaces also need endpoints to do anything useful
47                # but we'll add those later!
48
49        return descriptors
50
51
52    def elaborate(self, platform):
53        m = Module()
54
55        # configure cynthion's clocks and reset signals
56        m.submodules.car = platform.clock_domain_generator()
57
58        # request the physical interface for cynthion's TARGET C port
59        ulpi = platform.request("target_phy")
60
61        # create the USB device
62        m.submodules.usb = usb = USBDevice(bus=ulpi)
63
64        # create our standard descriptors and add them to the device's control endpoint
65        descriptors = self.create_standard_descriptors()
66        control_endpoint = usb.add_standard_control_endpoint(descriptors)
67
68        # add the microsoft os string descriptor
69        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
70
71        # add a microsoft descriptor collection for our other two microsoft descriptors
72        msft_descriptors = MicrosoftOS10DescriptorCollection()
73
74        # add the microsoft compatible id feature descriptor
75        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
76            with c.Function() as f:
77                f.bFirstInterfaceNumber = 0
78                f.compatibleID          = 'WINUSB'
79
80        # add microsoft extended properties feature descriptor
81        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
82            with d.Property() as p:
83                p.dwPropertyDataType = RegistryTypes.REG_SZ
84                p.PropertyName       = "DeviceInterfaceGUID"
85                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
86
87        # add the request handler for Microsoft descriptors
88        msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
89        control_endpoint.add_request_handler(msft_handler)
90
91        # configure the device to connect by default when plugged into a host
92        m.d.comb += usb.connect.eq(1)
93
94        return m
95
96
97if __name__ == "__main__":
98    from luna import top_level_cli
99    top_level_cli(GatewareUSBDevice)
test-gateware-usb-device-02.py
 1import usb1
 2
 3# - list available usb devices ------------------------------------------------
 4
 5def list_available_usb_devices(context):
 6    for device in context.getDeviceList():
 7        try:
 8            manufacturer = device.getManufacturer()
 9            product = device.getProduct()
10            print(f"{device}:  {manufacturer} - {product}")
11        except Exception as e:
12            print(f"{device}: {e}")
13
14
15# - main ----------------------------------------------------------------------
16
17if __name__ == "__main__":
18    with usb1.USBContext() as context:
19        list_available_usb_devices(context)

USB Gateware: Part 3 - Control Transfers

This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:

The goal of this tutorial is to define a control interface for the device we created in Part 1 that will allow it to receive and respond to control requests from a host.

Prerequisites

Data Transfer between a Host and Device

USB is a host-centric bus, what this means is that all transfers are initiated by the host irrespective of the direction of data transfer.

For data transfers to the device, the host issues an OUT token to notify the device of an incoming data transfer. When data has to be transferred from the device, the host issues an IN token to notify the device that it should send some data to the host.

The USB 2.0 specification defines four endpoint or transfer types:

  • Control Transfers: Typically used for command and status operations, control transfers are the only transfer type with a defined USB format.

  • Bulk Transfers: Bulk transfers are best suited for large amounts of data delivered in bursts such as file transfers to/from a storage device or the captured packet data from Cynthion to the control host.

  • Interrupt Transfers: Interrupt transfers are a bit of a misnomer as the host needs to continuously poll the device to check if an interrupt has occurred but the principle is the same. Commonly used for peripherals that generate input events such as a keyboard or mouse.

  • Isochronous Transfers: Finally, isochronous transfers occur continuously with a fixed periodicity. Suited for time-sensitive information such as video or audio streams they do not offer any guarantees on delivery. If a packet or frame is dropped it’s is up to the host driver to decide on how to best handle it.

By default all LUNA devices have a default implementation for two endpoints: An OUT Control Endpoint and an IN Control endpoint. These endpoints are used by the host to enumerate the device but they can also be extended to support various other class or custom vendor requests.

We’ll start by extending our control endpoints to support two vendor requests: One to set the state of the Cynthion FPGA LEDs and another to get the state of the Cynthion USER BUTTON.

Extend Default Control Endpoints

To implement vendor requests, begin by adding a VendorRequestHandler to our device’s control endpoint:

gateware-usb-device.py
 1from amaranth                                    import *
 2from luna.usb2                                   import USBDevice
 3from usb_protocol.emitters                       import DeviceDescriptorCollection
 4
 5from luna.gateware.usb.request.windows           import (
 6    MicrosoftOS10DescriptorCollection,
 7    MicrosoftOS10RequestHandler,
 8)
 9from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
10from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
11
12from luna.gateware.stream.generator              import StreamSerializer
13from luna.gateware.usb.request.control           import ControlRequestHandler
14from luna.gateware.usb.usb2.transfer             import USBInStreamInterface
15
16VENDOR_ID  = 0x1209 # https://pid.codes/1209/
17PRODUCT_ID = 0x0001
18
19class VendorRequestHandler(ControlRequestHandler):
20    VENDOR_SET_FPGA_LEDS   = 0x01
21    VENDOR_GET_USER_BUTTON = 0x02
22
23    def elaborate(self, platform):
24        m = Module()
25
26        # shortcuts
27        interface: RequestHandlerInterface = self.interface
28        setup: SetupPacket = self.interface.setup
29
30        # get a reference to the FPGA LEDs and USER button
31        fpga_leds   = Cat(platform.request("led", i).o for i in range(6))
32        user_button = platform.request("button_user").i
33
34        # create a streamserializer for transmitting IN data back to the host
35        serializer = StreamSerializer(
36            domain           = "usb",
37            stream_type      = USBInStreamInterface,
38            data_length      = 1,
39            max_length_width = 1,
40        )
41        m.submodules += serializer
42
43        return m
44
45class GatewareUSBDevice(Elaboratable):
46
47    ...
48
49    def elaborate(self, platform):
50        m = Module()
51
52        # configure cynthion's clocks and reset signals
53        m.submodules.car = platform.clock_domain_generator()
54
55        # request the physical interface for cynthion's TARGET C port
56        ulpi = platform.request("target_phy")
57
58        # create the USB device
59        m.submodules.usb = usb = USBDevice(bus=ulpi)
60
61        # create our standard descriptors and add them to the device's control endpoint
62        descriptors = self.create_standard_descriptors()
63        control_endpoint = usb.add_standard_control_endpoint(descriptors)
64
65        # add microsoft os 1.0 descriptors and request handler
66        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
67        msft_descriptors = MicrosoftOS10DescriptorCollection()
68        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
69            with c.Function() as f:
70                f.bFirstInterfaceNumber = 0
71                f.compatibleID          = 'WINUSB'
72        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
73            with d.Property() as p:
74                p.dwPropertyDataType = RegistryTypes.REG_SZ
75                p.PropertyName       = "DeviceInterfaceGUID"
76                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
77        msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
78        control_endpoint.add_request_handler(msft_handler)
79
80        # add our vendor request handler
81        control_endpoint.add_request_handler(VendorRequestHandler())
82
83        # configure the device to connect by default when plugged into a host
84        m.d.comb += usb.connect.eq(1)
85
86        return m

Vendor requests are unique to a device and are identified by the 8-bit bRequest field of the control transfer setup packet. Here we’ve defined two id’s corresponding to setting the led states and getting the button state.

So far our VendorRequestHandler contains references to Cynthion’s FPGA LEDs and USER BUTTON, as well as a StreamSerializer we’ll be using to send data back to the host when it asks for the USER BUTTON status.

Implement Vendor Request Handlers

Let’s implement that functionality below:

gateware-usb-device.py
 1class VendorRequestHandler(ControlRequestHandler):
 2    VENDOR_SET_FPGA_LEDS   = 0x01
 3    VENDOR_GET_USER_BUTTON = 0x02
 4
 5    def elaborate(self, platform):
 6        m = Module()
 7
 8        # Shortcuts.
 9        interface: RequestHandlerInterface = self.interface
10        setup: SetupPacket = self.interface.setup
11
12        # Grab a reference to the FPGA LEDs and USER button.
13        fpga_leds   = Cat(platform.request("led", i).o for i in range(6))
14        user_button = platform.request("button_user").i
15
16        # Create a StreamSerializer for sending IN data back to the host
17        serializer = StreamSerializer(
18            domain           = "usb",
19            stream_type      = USBInStreamInterface,
20            data_length      = 1,
21            max_length_width = 1,
22        )
23        m.submodules += serializer
24
25        # we've received a setup packet containing a vendor request.
26        with m.If(setup.type == USBRequestType.VENDOR):
27            # use a state machine to sequence our request handling
28            with m.FSM(domain="usb"):
29                with m.State("IDLE"):
30                    with m.If(setup.received):
31                        with m.Switch(setup.request):
32                            with m.Case(self.VENDOR_SET_FPGA_LEDS):
33                                m.next = "HANDLE_SET_FPGA_LEDS"
34                            with m.Case(self.VENDOR_GET_USER_BUTTON):
35                                m.next = "HANDLE_GET_USER_BUTTON"
36
37                with m.State("HANDLE_SET_FPGA_LEDS"):
38                    # take ownership of the interface
39                    m.d.comb += interface.claim.eq(1)
40
41                    # if we have an active data byte, set the FPGA LEDs to the payload
42                    with m.If(interface.rx.valid & interface.rx.next):
43                        m.d.usb += fpga_leds.eq(interface.rx.payload[0:6])
44
45                    # once the receive is complete, respond with an ACK
46                    with m.If(interface.rx_ready_for_response):
47                        m.d.comb += interface.handshakes_out.ack.eq(1)
48
49                    # finally, once we reach the status stage, send a ZLP
50                    with m.If(interface.status_requested):
51                        m.d.comb += self.send_zlp()
52                        m.next = "IDLE"
53
54                with m.State("HANDLE_GET_USER_BUTTON"):
55                    # take ownership of the interface
56                    m.d.comb += interface.claim.eq(1)
57
58                    # write the state of the user button into a local data register
59                    data = Signal(8)
60                    m.d.comb += data[0].eq(user_button)
61
62                    # transmit our data using a built-in handler function that
63                    # automatically advances the FSM back to the 'IDLE' state on
64                    # completion
65                    self.handle_simple_data_request(m, serializer, data)
66
67        return m

When handling a control request in LUNA the first thing we look at is the setup.type field of the setup packet interface. We could check for other types such as USBRequestType.CLASS or USBRequestType.DEVICE if we wanted to implement handlers for them but, in this case, we’re only interested in vendor requests.

Next, we take ownership of the interface, in order to avoid conflicting with the standard, or other registered request handlers. Then we sequence the actual request handling with an Amaranth Finite State Machine, starting in the IDLE state.

While in IDLE we wait for the setup.received signal to go high and signal the arrival of a new control request. We then parse the setup.request field to identify the next state to advance our FSM to. (We could also use the other setup packet fields such as wValue and wIndex for dispatch or as arguments but for now we’re just intered in bRequest.)

We then implement two handlers, the first is HANDLE_SET_FPGA_LEDS, which needs to read the data sent with our OUT control request in order to set the fpga leds state.

Then the second, in HANDLE_GET_USER_BUTTON we will use one of the built-in LUNA helper function to respond to our IN control request with the data containing the state of the user button.

Test Control Endpoints

First, remember to build and upload the device gateware to your Cynthion with:

python ./gateware-usb-device.py

Then, open your test-gateware-usb-device.py script from the previous tutorials and add the following code to it:

test-gateware-usb-device.py
 1import usb1
 2import time
 3
 4VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 5PRODUCT_ID = 0x0001
 6
 7VENDOR_SET_FPGA_LEDS   = 0x01
 8VENDOR_GET_USER_BUTTON = 0x02
 9
10# - list available usb devices ------------------------------------------------
11
12def list_available_usb_devices(context):
13    for device in context.getDeviceList():
14        try:
15            manufacturer = device.getManufacturer()
16            product = device.getProduct()
17            print(f"{device}:  {manufacturer} - {product}")
18        except Exception as e:
19            print(f"{device}: {e}")
20
21
22# - wrappers for control requests ---------------------------------------------
23
24def set_fpga_leds(device_handle, led_state):
25    response = device_handle.controlWrite(
26        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
27        request      = VENDOR_SET_FPGA_LEDS,
28        index        = 0,
29        value        = 0,
30        data         = [led_state],
31        timeout      = 1000,
32    )
33
34def get_user_button(device_handle):
35    response = device_handle.controlRead(
36        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
37        request      = VENDOR_GET_USER_BUTTON,
38        index        = 0,
39        value        = 0,
40        length       = 1,
41        timeout      = 1000,
42    )
43    return response[0]
44
45
46# - test control endpoints ----------------------------------------------------
47
48def test_control_endpoints(device_handle):
49    led_counter = 0
50    last_button_state = False
51
52    while True:
53        # led counter
54        set_fpga_leds(device_handle, led_counter)
55        led_counter = (led_counter + 1) % 256
56
57        # reset led counter when the USER button is pressed
58        button_state = get_user_button(device_handle)
59        if button_state:
60            led_counter = 0
61
62        # print button state when it changes
63        if button_state != last_button_state:
64            print(f"USER button is: {'ON' if button_state else 'OFF' }")
65            last_button_state = button_state
66
67        # slow the loop down so we can see the counter change
68        time.sleep(0.1)
69
70
71# - main ----------------------------------------------------------------------
72
73if __name__ == "__main__":
74    with usb1.USBContext() as context:
75        # list available devices
76        list_available_usb_devices(context)
77
78        # get a device handle to our simple usb device
79        device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
80        if device_handle is None:
81            raise Exception("Device not found.")
82
83        # claim the device's interface
84        device_handle.claimInterface(0)
85
86        # pass the device handle to our control endpoint test
87        test_control_endpoints(device_handle)

Run the file with:

python ./test-gateware-usb-device.py

And, if all goes well you should see the FPGA LEDs on Cynthion counting in binary. If you press and release the USER button you should see the count reset back to zero and the following text in the terminal.

USER button is: ON
USER button is: OFF

Job done!

In the next part of the tutorial we’ll finish up by adding IN and OUT Bulk endpoints to our device.

Exercises

  1. Add a vendor request to retrieve the current state of the FPGA LEDs.

  2. Add a vendor request that will disconnect and then re-connect your device to the USB bus.

More information

Source Code

gateware-usb-device-03.py
  1#!/usr/bin/env python3
  2#
  3# This file is part of Cynthion.
  4#
  5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
  6# SPDX-License-Identifier: BSD-3-Clause
  7
  8from amaranth                                    import *
  9from luna.usb2                                   import USBDevice
 10from usb_protocol.emitters                       import DeviceDescriptorCollection
 11
 12from luna.gateware.usb.request.windows           import (
 13    MicrosoftOS10DescriptorCollection,
 14    MicrosoftOS10RequestHandler,
 15)
 16from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
 17from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
 18
 19from luna.gateware.stream.generator              import StreamSerializer
 20from luna.gateware.usb.request.control           import ControlRequestHandler
 21from luna.gateware.usb.request.interface         import SetupPacket
 22from luna.gateware.usb.usb2.request              import RequestHandlerInterface
 23from luna.gateware.usb.usb2.transfer             import USBInStreamInterface
 24from usb_protocol.types                          import USBRequestType
 25
 26VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 27PRODUCT_ID = 0x0001
 28
 29class VendorRequestHandler(ControlRequestHandler):
 30    VENDOR_SET_FPGA_LEDS   = 0x01
 31    VENDOR_GET_USER_BUTTON = 0x02
 32
 33    def elaborate(self, platform):
 34        m = Module()
 35
 36        # shortcuts
 37        interface: RequestHandlerInterface = self.interface
 38        setup: SetupPacket = self.interface.setup
 39
 40        # get a reference to the FPGA LEDs and USER button
 41        fpga_leds   = Cat(platform.request("led", i).o for i in range(6))
 42        user_button = platform.request("button_user").i
 43
 44        # create a streamserializer for transmitting IN data back to the host
 45        serializer = StreamSerializer(
 46            domain           = "usb",
 47            stream_type      = USBInStreamInterface,
 48            data_length      = 1,
 49            max_length_width = 1,
 50        )
 51        m.submodules += serializer
 52
 53        # we've received a setup packet containing a vendor request.
 54        with m.If(setup.type == USBRequestType.VENDOR):
 55            # use a state machine to sequence our request handling
 56            with m.FSM(domain="usb"):
 57                with m.State("IDLE"):
 58                    with m.If(setup.received):
 59                        with m.Switch(setup.request):
 60                            with m.Case(self.VENDOR_SET_FPGA_LEDS):
 61                                m.next = "HANDLE_SET_FPGA_LEDS"
 62                            with m.Case(self.VENDOR_GET_USER_BUTTON):
 63                                m.next = "HANDLE_GET_USER_BUTTON"
 64
 65                with m.State("HANDLE_SET_FPGA_LEDS"):
 66                    # take ownership of the interface
 67                    m.d.comb += interface.claim.eq(1)
 68
 69                    # if we have an active data byte, set the FPGA LEDs to the payload
 70                    with m.If(interface.rx.valid & interface.rx.next):
 71                        m.d.usb += fpga_leds.eq(interface.rx.payload[0:6])
 72
 73                    # once the receive is complete, respond with an ACK
 74                    with m.If(interface.rx_ready_for_response):
 75                       m.d.comb += interface.handshakes_out.ack.eq(1)
 76
 77                    # finally, once we reach the status stage, send a ZLP
 78                    with m.If(interface.status_requested):
 79                        m.d.comb += self.send_zlp()
 80                        m.next = "IDLE"
 81
 82                with m.State("HANDLE_GET_USER_BUTTON"):
 83                    # take ownership of the interface
 84                    m.d.comb += interface.claim.eq(1)
 85
 86                    # write the state of the user button into a local data register
 87                    data = Signal(8)
 88                    m.d.comb += data[0].eq(user_button)
 89
 90                    # transmit our data using a built-in handler function that
 91                    # automatically advances the FSM back to the 'IDLE' state on
 92                    # completion
 93                    self.handle_simple_data_request(m, serializer, data)
 94
 95        return m
 96
 97
 98class GatewareUSBDevice(Elaboratable):
 99    """ A simple USB device that can communicate with the host via vendor requests. """
100
101    def create_standard_descriptors(self):
102        """ Create the USB descriptors for the device. """
103
104        descriptors = DeviceDescriptorCollection()
105
106        # all USB devices have a single device descriptor
107        with descriptors.DeviceDescriptor() as d:
108            d.idVendor           = VENDOR_ID
109            d.idProduct          = PRODUCT_ID
110            d.iManufacturer      = "Cynthion Project"
111            d.iProduct           = "Gateware USB Device"
112
113            d.bNumConfigurations = 1
114
115        # and at least one configuration descriptor
116        with descriptors.ConfigurationDescriptor() as c:
117
118            # with at least one interface descriptor
119            with c.InterfaceDescriptor() as i:
120                i.bInterfaceNumber = 0
121
122                # interfaces also need endpoints to do anything useful
123                # but we'll add those later!
124
125        return descriptors
126
127    def elaborate(self, platform):
128        m = Module()
129
130        # configure cynthion's clocks and reset signals
131        m.submodules.car = platform.clock_domain_generator()
132
133        # request the physical interface for cynthion's TARGET C port
134        ulpi = platform.request("target_phy")
135
136        # create the USB device
137        m.submodules.usb = usb = USBDevice(bus=ulpi)
138
139        # create our standard descriptors and add them to the device's control endpoint
140        descriptors = self.create_standard_descriptors()
141        control_endpoint = usb.add_standard_control_endpoint(descriptors)
142
143        # add microsoft os 1.0 descriptors and request handler
144        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
145        msft_descriptors = MicrosoftOS10DescriptorCollection()
146        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
147            with c.Function() as f:
148                f.bFirstInterfaceNumber = 0
149                f.compatibleID          = 'WINUSB'
150        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
151            with d.Property() as p:
152                p.dwPropertyDataType = RegistryTypes.REG_SZ
153                p.PropertyName       = "DeviceInterfaceGUID"
154                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
155        msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
156        control_endpoint.add_request_handler(msft_handler)
157
158        # add the vendor request handler
159        control_endpoint.add_request_handler(VendorRequestHandler())
160
161        # configure the device to connect by default when plugged into a host
162        m.d.comb += usb.connect.eq(1)
163
164        return m
165
166
167if __name__ == "__main__":
168    from luna import top_level_cli
169    top_level_cli(GatewareUSBDevice)
test-gateware-usb-device-03.py
 1import usb1
 2import time
 3
 4VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 5PRODUCT_ID = 0x0001
 6
 7VENDOR_SET_FPGA_LEDS   = 0x01
 8VENDOR_GET_USER_BUTTON = 0x02
 9
10# - list available usb devices ------------------------------------------------
11
12def list_available_usb_devices(context):
13    for device in context.getDeviceList():
14        try:
15            manufacturer = device.getManufacturer()
16            product = device.getProduct()
17            print(f"{device}:  {manufacturer} - {product}")
18        except Exception as e:
19            print(f"{device}: {e}")
20
21
22# - wrappers for control requests ---------------------------------------------
23
24def set_fpga_leds(device_handle, led_state):
25    response = device_handle.controlWrite(
26        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
27        request      = VENDOR_SET_FPGA_LEDS,
28        index        = 0,
29        value        = 0,
30        data         = [led_state],
31        timeout      = 1000,
32    )
33
34def get_user_button(device_handle):
35    response = device_handle.controlRead(
36        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
37        request      = VENDOR_GET_USER_BUTTON,
38        index        = 0,
39        value        = 0,
40        length       = 1,
41        timeout      = 1000,
42    )
43    return response[0]
44
45
46# - test control endpoints ----------------------------------------------------
47
48def test_control_endpoints(device_handle):
49    led_counter = 0
50    last_button_state = False
51
52    while True:
53        # led counter
54        set_fpga_leds(device_handle, led_counter)
55        led_counter = (led_counter + 1) % 256
56
57        # reset led counter when the USER button is pressed
58        button_state = get_user_button(device_handle)
59        if button_state:
60            led_counter = 0
61
62        # print button state when it changes
63        if button_state != last_button_state:
64            print(f"USER button is: {'ON' if button_state else 'OFF' }")
65            last_button_state = button_state
66
67        # slow the loop down so we can see the counter change
68        time.sleep(0.1)
69
70
71# - main ----------------------------------------------------------------------
72
73if __name__ == "__main__":
74    with usb1.USBContext() as context:
75        # list available devices
76        list_available_usb_devices(context)
77
78        # get a device handle to our simple usb device
79        device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
80        if device_handle is None:
81            raise Exception("Device not found.")
82
83        # claim the device's interface
84        device_handle.claimInterface(0)
85
86        # pass the device handle to our control endpoint test
87        test_control_endpoints(device_handle)

USB Gateware: Part 4 - Bulk Transfers

This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:

The goal of this tutorial is to define Bulk Endpoints for the device we created in Part 3 that will allow us to efficiently perform larger data transfers than those allowed by Control Transfers.

Prerequisites

Add Bulk Endpoints

While Control transfers are well suited for command and status operations they are not the best way to exchange large quantities of data. Control transfers have high per-packet protocol overhead and can only transfer packets of 8 bytes on low speed (1.5Mbps) devices and 64 bytes on full (12Mbps) and high (512Mbps) speed devices.

On the other hand, Bulk transfers support a packet size of up to 512 bytes on high speed devices and do not require any protocol overhead.

In the first section we’ll begin by updating our device’s descriptors so it can inform the host that it has bulk endpoints available.

Update Device Descriptors

Open gateware-usb-device.py and add the highlighted lines:

gateware-usb-device.py
 1from amaranth                                    import *
 2from luna.usb2                                   import USBDevice
 3from usb_protocol.emitters                       import DeviceDescriptorCollection
 4
 5from luna.gateware.usb.request.windows           import (
 6    MicrosoftOS10DescriptorCollection,
 7    MicrosoftOS10RequestHandler,
 8)
 9from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
10from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
11
12from luna.gateware.stream.generator              import StreamSerializer
13from luna.gateware.usb.request.control           import ControlRequestHandler
14from luna.gateware.usb.request.interface         import SetupPacket
15from luna.gateware.usb.usb2.request              import RequestHandlerInterface
16from luna.gateware.usb.usb2.transfer             import USBInStreamInterface
17from usb_protocol.types                          import USBRequestType
18
19from luna.usb2                                   import (
20    USBStreamInEndpoint,
21    USBStreamOutEndpoint,
22)
23from usb_protocol.types                          import (
24    USBDirection,
25    USBTransferType,
26)
27
28VENDOR_ID  = 0x1209 # https://pid.codes/1209/
29PRODUCT_ID = 0x0001
30
31MAX_PACKET_SIZE = 512
32
33class VendorRequestHandler(ControlRequestHandler):
34    ...
35
36class GatewareUSBDevice(Elaboratable):
37    def create_standard_descriptors(self):
38        descriptors = DeviceDescriptorCollection()
39
40        with descriptors.DeviceDescriptor() as d:
41            d.idVendor           = VENDOR_ID
42            d.idProduct          = PRODUCT_ID
43            d.iManufacturer      = "Cynthion Project"
44            d.iProduct           = "Gateware USB Device"
45            d.bNumConfigurations = 1
46
47        with descriptors.ConfigurationDescriptor() as c:
48            with c.InterfaceDescriptor() as i:
49                i.bInterfaceNumber = 0
50                # EP 0x01 OUT - receives bulk data from the host
51                with i.EndpointDescriptor() as e:
52                    e.bEndpointAddress = USBDirection.OUT.to_endpoint_address(0x01)
53                    e.bmAttributes     = USBTransferType.BULK
54                    e.wMaxPacketSize   = MAX_PACKET_SIZE
55                # EP 0x82 IN  - transmits bulk data to the host
56                with i.EndpointDescriptor() as e:
57                    e.bEndpointAddress = USBDirection.IN.to_endpoint_address(0x02)
58                    e.bmAttributes     = USBTransferType.BULK
59                    e.wMaxPacketSize   = MAX_PACKET_SIZE
60
61        return descriptors
62
63    def elaborate(self, platform):
64        ...

This adds two endpoint descriptors to our default interface, each of type USBTransferType.BULK and with a MAX_PACKET_SIZE of 512. Where the endpoints differ is in their endpoint address. USB endpoint descriptors encode their direction in an 8 bit endpoint address. The first four bits encode the endpoint number, the next three bits are reserved and set to zero and the final bit encodes the direction; 0 for OUT and 1 for IN.

This means that an OUT endpoint number of 0x01 encodes to an endpoint address of 0x01 while an IN endpoint number of 0x02 encodes to the address 0x82. (0b0000_0010 + 0b1000_0000 = 0b1000_0010 = 0x82)

Add USB Stream Endpoints

Once our endpoint descriptors have been added to our device configuration we will need some gateware that will be able to respond to USB requests from the host and allow us to receive and transmit data.

LUNA provides the USBStreamOutEndpoint and USBStreamInEndpoint components which conform to the Amaranth Data streams interface. Simply put, streams provide a uniform mechanism for unidirectional exchange of arbitrary data between gateware components.

gateware-usb-device.py
 1...
 2
 3class GatewareUSBDevice(Elaboratable):
 4    def create_standard_descriptors(self):
 5        ...
 6
 7    def elaborate(self, platform):
 8        ...
 9
10        # add the vendor request handler
11        control_endpoint.add_request_handler(VendorRequestHandler())
12
13        # create and add stream endpoints for our device's Bulk IN & OUT endpoints
14        ep_out = USBStreamOutEndpoint(
15            endpoint_number=0x01,  # (EP 0x01)
16            max_packet_size=MAX_PACKET_SIZE,
17        )
18        usb.add_endpoint(ep_out)
19        ep_in = USBStreamInEndpoint(
20            endpoint_number=0x02,  # (EP 0x82)
21            max_packet_size=MAX_PACKET_SIZE
22        )
23        usb.add_endpoint(ep_in)
24
25        # configure the device to connect by default when plugged into a host
26        m.d.comb += usb.connect.eq(1)
27
28        return m

We now have two streaming endpoints that are able to receive and transmit data between any other module that supports the Amaranth Data streams interface.

However, before we can stream any data across these endpoints we first need to come up with a USB Function for each of our endpoints. In other words, what does our device actually _do_?

This could be any data source and/or sink but for the purposes of this tutorial let’s create a simple loopback function that will accept a bulk OUT request from the host and then return the request payload when the host makes a bulk IN request.

Define Endpoint Functions

A simple implementation for our device’s endpoint functions could be a simple FIFO (First In First Out) queue with enough space to hold the 512 bytes of a bulk transfer.

Using the OUT endpoint we could then transmit a stream of data from the host to Cynthion and write it into the FIFO. Then, when we transmit a request from the host to the IN endpoint we can stream the previously queued data back to the host.

We’re only working in a single clock-domain so we can use a SyncFIFO from the Amaranth standard library for our queue:

gateware-usb-device.py
 1from amaranth                                    import *
 2from amaranth.lib.fifo                           import SyncFIFO
 3from luna.usb2                                   import USBDevice
 4from usb_protocol.emitters                       import DeviceDescriptorCollection
 5...
 6
 7class VendorRequestHandler(ControlRequestHandler):
 8    ...
 9
10class GatewareUSBDevice(Elaboratable):
11    ...
12
13    def elaborate(self, platform):
14        ...
15
16        # create and add stream endpoints for our device's Bulk IN & OUT endpoints
17        ep_out = USBStreamOutEndpoint(
18            endpoint_number=0x01,  # (EP 0x01)
19            max_packet_size=MAX_PACKET_SIZE,
20        )
21        usb.add_endpoint(ep_out)
22        ep_in = USBStreamInEndpoint(
23            endpoint_number=0x02,  # (EP 0x82)
24            max_packet_size=MAX_PACKET_SIZE
25        )
26        usb.add_endpoint(ep_in)
27
28        # create a FIFO queue we'll connect to the stream interfaces of our
29        # IN & OUT endpoints
30        m.submodules.fifo = fifo = DomainRenamer("usb")(
31            SyncFIFO(width=8, depth=MAX_PACKET_SIZE)
32        )
33
34        # connect our Bulk OUT endpoint's stream interface to the FIFO's write port
35        stream_out = ep_out.stream
36        m.d.comb += fifo.w_data.eq(stream_out.payload)
37        m.d.comb += fifo.w_en.eq(stream_out.valid)
38        m.d.comb += stream_out.ready.eq(fifo.w_rdy)
39
40        # connect our Bulk IN endpoint's stream interface to the FIFO's read port
41        stream_in  = ep_in.stream
42        m.d.comb += stream_in.payload.eq(fifo.r_data)
43        m.d.comb += stream_in.valid.eq(fifo.r_rdy)
44        m.d.comb += fifo.r_en.eq(stream_in.ready)
45
46        # configure the device to connect by default when plugged into a host
47        m.d.comb += usb.connect.eq(1)
48
49        return m

Note

Something to take note off is the use of an Amaranth DomainRenamer component to wrap SyncFIFO in the following lines:

m.submodules.fifo = fifo = DomainRenamer("usb")(
    fifo.SyncFIFO(width=8, depth=MAX_PACKET_SIZE)
)

Any moderately complex FPGA hardware & gateware design will usually consist of multiple clock-domains running at different frequencies. Cynthion, for example, has three clock domains:

  • sync - the default clock domain, running at 120 MHz.

  • usb - the clock domain for USB components and gateware, running at 60 MHz.

  • fast - a fast clock domain used for the HyperRAM, running at 240 MHz.

Because our designs so far have all been interfacing with Cynthion’s USB components we’ve only needed to use the usb clock domain. However, reusable Amaranth components such as SyncFIFO are usually implemented using the default sync domain. We therefore need to be able to rename its clock domain to match the domain used in our design. This is what DomainRenamer does.

And that’s it, we’ve defined our endpoint functions! Let’s try it out.

Test Bulk Endpoints

Open up test-gateware-usb-device.py and add the following code to it:

test-gateware-usb-device.py
  1import usb1
  2import time
  3import random
  4
  5VENDOR_ID  = 0x1209 # https://pid.codes/1209/
  6PRODUCT_ID = 0x0001
  7
  8VENDOR_SET_FPGA_LEDS   = 0x01
  9VENDOR_GET_USER_BUTTON = 0x02
 10
 11MAX_PACKET_SIZE = 512
 12
 13# - list available usb devices ------------------------------------------------
 14
 15def list_available_usb_devices(context):
 16    for device in context.getDeviceList():
 17        try:
 18            manufacturer = device.getManufacturer()
 19            product = device.getProduct()
 20            print(f"{device}:  {manufacturer} - {product}")
 21        except Exception as e:
 22            print(f"{device}: {e}")
 23
 24
 25# - wrappers for control requests ---------------------------------------------
 26
 27def set_fpga_leds(device_handle, led_state):
 28    response = device_handle.controlWrite(
 29        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
 30        request      = VENDOR_SET_FPGA_LEDS,
 31        index        = 0,
 32        value        = 0,
 33        data         = [led_state],
 34        timeout      = 1000,
 35    )
 36
 37def get_user_button(device_handle):
 38    response = device_handle.controlRead(
 39        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
 40        request      = VENDOR_GET_USER_BUTTON,
 41        index        = 0,
 42        value        = 0,
 43        length       = 1,
 44        timeout      = 1000,
 45    )
 46    return response[0]
 47
 48
 49# - test control endpoints ----------------------------------------------------
 50
 51def test_control_endpoints(device_handle):
 52    led_counter = 0
 53    last_button_state = False
 54
 55    while True:
 56        # led counter
 57        set_fpga_leds(device_handle, led_counter)
 58        led_counter = (led_counter + 1) % 256
 59
 60        # reset led counter when the USER button is pressed
 61        button_state = get_user_button(device_handle)
 62        if button_state:
 63            led_counter = 0
 64
 65        # print button state when it changes
 66        if button_state != last_button_state:
 67            print(f"USER button is: {'ON' if button_state else 'OFF' }")
 68            last_button_state = button_state
 69
 70        # slow the loop down so we can see the counter change
 71        time.sleep(0.1)
 72
 73
 74# - wrappers for bulk requests ------------------------------------------------
 75
 76def bulk_out_transfer(device_handle, data):
 77    response = device_handle.bulkWrite(
 78        endpoint = 0x01,
 79        data     = data,
 80        timeout  = 1000,
 81    )
 82    return response
 83
 84def bulk_in_transfer(device_handle, length):
 85    response = device_handle.bulkRead(
 86        endpoint = 0x02,
 87        length   = length,
 88        timeout  = 1000,
 89    )
 90    return response
 91
 92
 93# - test bulk endpoints -------------------------------------------------------
 94
 95def test_bulk_endpoints(device_handle):
 96    # bulk_out - write a list of random numbers to memory
 97    data = list([random.randint(0, 255) for _ in range(MAX_PACKET_SIZE)])
 98    response = bulk_out_transfer(device_handle, data)
 99    print(f"OUT endpoint transmitted {response} bytes: {data[0:4]} ... {data[-4:]}")
100
101    # bulk_in - retrieve the contents of our memory
102    response = list(bulk_in_transfer(device_handle, MAX_PACKET_SIZE))
103    print(f"IN  endpoint received {len(response)} bytes:    {response[0:4]} ... {response[-4:]}")
104
105    # check that the stored data matches the sent data
106    assert(data == list(response))
107
108
109# - main ----------------------------------------------------------------------
110
111if __name__ == "__main__":
112    with usb1.USBContext() as context:
113        # list available devices
114        list_available_usb_devices(context)
115
116        # get a device handle to our simple usb device
117        device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
118        if device_handle is None:
119            raise Exception("Device not found.")
120
121        # claim the device's interface
122        device_handle.claimInterface(0)
123
124        # pass the device handle to our bulk endpoint test
125        test_bulk_endpoints(device_handle)
126
127        # pass the device handle to our control endpoint test
128        test_control_endpoints(device_handle)

Run the file with:

python ./test-gateware-usb-device.py

Assuming everything is going to plan you should see two matching sets of random numbers:

OUT endpoint transmitted 512 bytes: [252, 107, 106, 56] ... [109, 175, 112, 126]
IN  endpoint received 512 bytes:    [252, 107, 106, 56] ... [109, 175, 112, 126]

Congratulations, if you made it this far then you’ve just finished building your first complete USB Gateware Device with custom vendor request control and bulk data transfer!

Exercises

  1. Create a benchmark to test the speed of your device when doing Bulk IN and OUT transfers.

  2. Move the device endpoint to aux_phy and attempt to capture the packets exchanged between a device plugged into a host via the target_phy port.

More information

Source Code

gateware-usb-device-04.py
  1#!/usr/bin/env python3
  2#
  3# This file is part of Cynthion.
  4#
  5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
  6# SPDX-License-Identifier: BSD-3-Clause
  7
  8from amaranth                                    import *
  9from amaranth.lib.fifo                           import SyncFIFO
 10from luna.usb2                                   import USBDevice
 11from usb_protocol.emitters                       import DeviceDescriptorCollection
 12
 13from luna.gateware.usb.request.windows           import (
 14    MicrosoftOS10DescriptorCollection,
 15    MicrosoftOS10RequestHandler,
 16)
 17from usb_protocol.emitters.descriptors.standard  import get_string_descriptor
 18from usb_protocol.types.descriptors.microsoft10  import RegistryTypes
 19
 20from luna.gateware.stream.generator              import StreamSerializer
 21from luna.gateware.usb.request.control           import ControlRequestHandler
 22from luna.gateware.usb.request.interface         import SetupPacket
 23from luna.gateware.usb.usb2.request              import RequestHandlerInterface
 24from luna.gateware.usb.usb2.transfer             import USBInStreamInterface
 25from usb_protocol.types                          import USBRequestType
 26
 27from luna.usb2                                   import USBStreamInEndpoint, USBStreamOutEndpoint
 28from usb_protocol.types                          import USBDirection, USBTransferType
 29
 30VENDOR_ID  = 0x1209 # https://pid.codes/1209/
 31PRODUCT_ID = 0x0001
 32
 33MAX_PACKET_SIZE = 512
 34
 35class VendorRequestHandler(ControlRequestHandler):
 36    VENDOR_SET_FPGA_LEDS   = 0x01
 37    VENDOR_GET_USER_BUTTON = 0x02
 38
 39    def elaborate(self, platform):
 40        m = Module()
 41
 42        # shortcuts
 43        interface: RequestHandlerInterface = self.interface
 44        setup: SetupPacket = self.interface.setup
 45
 46        # get a reference to the FPGA LEDs and USER button
 47        fpga_leds   = Cat(platform.request("led", i).o for i in range(6))
 48        user_button = platform.request("button_user").i
 49
 50        # create a streamserializer for transmitting IN data back to the host
 51        serializer = StreamSerializer(
 52            domain           = "usb",
 53            stream_type      = USBInStreamInterface,
 54            data_length      = 1,
 55            max_length_width = 1,
 56        )
 57        m.submodules += serializer
 58
 59        # we've received a setup packet containing a vendor request.
 60        with m.If(setup.type == USBRequestType.VENDOR):
 61            # use a state machine to sequence our request handling
 62            with m.FSM(domain="usb"):
 63                with m.State("IDLE"):
 64                    with m.If(setup.received):
 65                        with m.Switch(setup.request):
 66                            with m.Case(self.VENDOR_SET_FPGA_LEDS):
 67                                m.next = "HANDLE_SET_FPGA_LEDS"
 68                            with m.Case(self.VENDOR_GET_USER_BUTTON):
 69                                m.next = "HANDLE_GET_USER_BUTTON"
 70
 71                with m.State("HANDLE_SET_FPGA_LEDS"):
 72                    # take ownership of the interface
 73                    m.d.comb += interface.claim.eq(1)
 74
 75                    # if we have an active data byte, set the FPGA LEDs to the payload
 76                    with m.If(interface.rx.valid & interface.rx.next):
 77                        m.d.usb += fpga_leds.eq(interface.rx.payload[0:6])
 78
 79                    # once the receive is complete, respond with an ACK
 80                    with m.If(interface.rx_ready_for_response):
 81                        m.d.comb += interface.handshakes_out.ack.eq(1)
 82
 83                    # finally, once we reach the status stage, send a ZLP
 84                    with m.If(interface.status_requested):
 85                        m.d.comb += self.send_zlp()
 86                        m.next = "IDLE"
 87
 88                with m.State("HANDLE_GET_USER_BUTTON"):
 89                    # take ownership of the interface
 90                    m.d.comb += interface.claim.eq(1)
 91
 92                    # write the state of the user button into a local data register
 93                    data = Signal(8)
 94                    m.d.comb += data[0].eq(user_button)
 95
 96                    # transmit our data using a built-in handler function that
 97                    # automatically advances the FSM back to the 'IDLE' state on
 98                    # completion
 99                    self.handle_simple_data_request(m, serializer, data)
100
101        return m
102
103
104class GatewareUSBDevice(Elaboratable):
105    """ A simple USB device that can communicate with the host via vendor and bulk requests. """
106
107    def create_standard_descriptors(self):
108        """ Create the USB descriptors for the device. """
109
110        descriptors = DeviceDescriptorCollection()
111
112        # all USB devices have a single device descriptor
113        with descriptors.DeviceDescriptor() as d:
114            d.idVendor           = VENDOR_ID
115            d.idProduct          = PRODUCT_ID
116            d.iManufacturer      = "Cynthion Project"
117            d.iProduct           = "Gateware USB Device"
118
119            d.bNumConfigurations = 1
120
121        # and at least one configuration descriptor
122        with descriptors.ConfigurationDescriptor() as c:
123
124            # with at least one interface descriptor
125            with c.InterfaceDescriptor() as i:
126                i.bInterfaceNumber = 0
127
128                # an endpoint for receiving bulk data from the host
129                with i.EndpointDescriptor() as e:
130                    e.bEndpointAddress = USBDirection.OUT.to_endpoint_address(0x01) # EP 0x01 OUT
131                    e.bmAttributes     = USBTransferType.BULK
132                    e.wMaxPacketSize   = MAX_PACKET_SIZE
133
134                # and an endpoint for transmitting bulk data to the host
135                with i.EndpointDescriptor() as e:
136                    e.bEndpointAddress = USBDirection.IN.to_endpoint_address(0x02)  # EP 0x82 IN
137                    e.bmAttributes     = USBTransferType.BULK
138                    e.wMaxPacketSize   = MAX_PACKET_SIZE
139
140        return descriptors
141
142    def elaborate(self, platform):
143        m = Module()
144
145        # configure cynthion's clocks and reset signals
146        m.submodules.car = platform.clock_domain_generator()
147
148        # request the physical interface for cynthion's TARGET C port
149        ulpi = platform.request("target_phy")
150
151        # create the USB device
152        m.submodules.usb = usb = USBDevice(bus=ulpi)
153
154        # create our standard descriptors and add them to the device's control endpoint
155        descriptors = self.create_standard_descriptors()
156        control_endpoint = usb.add_standard_control_endpoint(descriptors)
157
158        # add microsoft os 1.0 descriptors and request handler
159        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
160        msft_descriptors = MicrosoftOS10DescriptorCollection()
161        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
162            with c.Function() as f:
163                f.bFirstInterfaceNumber = 0
164                f.compatibleID          = 'WINUSB'
165        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
166            with d.Property() as p:
167                p.dwPropertyDataType = RegistryTypes.REG_SZ
168                p.PropertyName       = "DeviceInterfaceGUID"
169                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
170        msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
171        control_endpoint.add_request_handler(msft_handler)
172
173        # add the vendor request handler
174        control_endpoint.add_request_handler(VendorRequestHandler())
175
176        # create and add stream endpoints for our device's Bulk IN & OUT endpoints
177        ep_out = USBStreamOutEndpoint(
178            endpoint_number=0x01,  # (EP 0x01)
179            max_packet_size=MAX_PACKET_SIZE,
180        )
181        usb.add_endpoint(ep_out)
182        ep_in = USBStreamInEndpoint(
183            endpoint_number=0x02,  # (EP 0x82)
184            max_packet_size=MAX_PACKET_SIZE
185        )
186        usb.add_endpoint(ep_in)
187
188        # create a FIFO queue we'll connect to the stream interfaces of our
189        # IN & OUT endpoints
190        m.submodules.fifo = fifo = DomainRenamer("usb")(
191            SyncFIFO(width=8, depth=MAX_PACKET_SIZE)
192        )
193
194        # connect our Bulk OUT endpoint's stream interface to the FIFO's write port
195        stream_out = ep_out.stream
196        m.d.comb += fifo.w_data.eq(stream_out.payload)
197        m.d.comb += fifo.w_en.eq(stream_out.valid)
198        m.d.comb += stream_out.ready.eq(fifo.w_rdy)
199
200        # connect our Bulk IN endpoint's stream interface to the FIFO's read port
201        stream_in  = ep_in.stream
202        m.d.comb += stream_in.payload.eq(fifo.r_data)
203        m.d.comb += stream_in.valid.eq(fifo.r_rdy)
204        m.d.comb += fifo.r_en.eq(stream_in.ready)
205
206        # configure the device to connect by default when plugged into a host
207        m.d.comb += usb.connect.eq(1)
208
209        return m
210
211
212if __name__ == "__main__":
213    from luna import top_level_cli
214    top_level_cli(GatewareUSBDevice)
test-gateware-usb-device-04.py
  1import usb1
  2import time
  3import random
  4
  5VENDOR_ID  = 0x1209 # https://pid.codes/1209/
  6PRODUCT_ID = 0x0001
  7
  8VENDOR_SET_FPGA_LEDS   = 0x01
  9VENDOR_GET_USER_BUTTON = 0x02
 10
 11MAX_PACKET_SIZE = 512
 12
 13# - list available usb devices ------------------------------------------------
 14
 15def list_available_usb_devices(context):
 16    for device in context.getDeviceList():
 17        try:
 18            manufacturer = device.getManufacturer()
 19            product = device.getProduct()
 20            print(f"{device}:  {manufacturer} - {product}")
 21        except Exception as e:
 22            print(f"{device}: {e}")
 23
 24
 25# - wrappers for control requests ---------------------------------------------
 26
 27def set_fpga_leds(device_handle, led_state):
 28    response = device_handle.controlWrite(
 29        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
 30        request      = VENDOR_SET_FPGA_LEDS,
 31        index        = 0,
 32        value        = 0,
 33        data         = [led_state],
 34        timeout      = 1000,
 35    )
 36
 37def get_user_button(device_handle):
 38    response = device_handle.controlRead(
 39        request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
 40        request      = VENDOR_GET_USER_BUTTON,
 41        index        = 0,
 42        value        = 0,
 43        length       = 1,
 44        timeout      = 1000,
 45    )
 46    return response[0]
 47
 48
 49# - test control endpoints ----------------------------------------------------
 50
 51def test_control_endpoints(device_handle):
 52    led_counter = 0
 53    last_button_state = False
 54
 55    while True:
 56        # led counter
 57        set_fpga_leds(device_handle, led_counter)
 58        led_counter = (led_counter + 1) % 256
 59
 60        # reset led counter when the USER button is pressed
 61        button_state = get_user_button(device_handle)
 62        if button_state:
 63            led_counter = 0
 64
 65        # print button state when it changes
 66        if button_state != last_button_state:
 67            print(f"USER button is: {'ON' if button_state else 'OFF' }")
 68            last_button_state = button_state
 69
 70        # slow the loop down so we can see the counter change
 71        time.sleep(0.1)
 72
 73
 74# - wrappers for bulk requests ------------------------------------------------
 75
 76def bulk_out_transfer(device_handle, data):
 77    response = device_handle.bulkWrite(
 78        endpoint = 0x01,
 79        data     = data,
 80        timeout  = 1000,
 81    )
 82    return response
 83
 84def bulk_in_transfer(device_handle, length):
 85    response = device_handle.bulkRead(
 86        endpoint = 0x02,
 87        length   = length,
 88        timeout  = 1000,
 89    )
 90    return response
 91
 92
 93# - test bulk endpoints -------------------------------------------------------
 94
 95def test_bulk_endpoints(device_handle):
 96    # bulk_out - write a list of random numbers to memory
 97    data = list([random.randint(0, 255) for _ in range(MAX_PACKET_SIZE)])
 98    response = bulk_out_transfer(device_handle, data)
 99    print(f"OUT endpoint transmitted {response} bytes: {data[0:4]} ... {data[-4:]}")
100
101    # bulk_in - retrieve the contents of our memory
102    response = list(bulk_in_transfer(device_handle, MAX_PACKET_SIZE))
103    print(f"IN  endpoint received {len(response)} bytes:    {response[0:4]} ... {response[-4:]}")
104
105    # check that the stored data matches the sent data
106    assert(data == list(response))
107
108
109# - main ----------------------------------------------------------------------
110
111if __name__ == "__main__":
112    with usb1.USBContext() as context:
113        # list available devices
114        list_available_usb_devices(context)
115
116        # get a device handle to our simple usb device
117        device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
118        if device_handle is None:
119            raise Exception("Device not found.")
120
121        # claim the device's interface
122        device_handle.claimInterface(0)
123
124        # pass the device handle to our bulk endpoint test
125        test_bulk_endpoints(device_handle)
126
127        # pass the device handle to our control endpoint test
128        test_control_endpoints(device_handle)

Introduction

Cynthion PCB (Front)

Cynthion Hardware

Device Overview

Top View

Cynthion Top View
  • A-E - Five status LEDs managed by the debug microcontroller.

    • A - Power Indicator.

    • B - FPGA is online.

    • C - FPGA has requested control of the CONTROL port.

    • D - FPGA has control of the CONTROL port.

    • E - Reserved for future use.

  • 0-5 - Six USER LEDs managed by the FPGA.

Left View

Cynthion Left View
  • PROGRAM - Press this button to return control of the CONTROL port to the debug microcontroller and hold the FPGA in an unconfigured state.

    • Recovery mode: Press this button during power-on to invoke the Saturn-V bootloader on the CONTROL port.

  • CONTROL - Primary USB connector used by the host computer to control Cynthion.

  • USER - A user-assignable button that can be used in your own designs.

  • AUX - An auxiliary USB connection that can be used in your own designs.

Right View

Cynthion Right View
  • TARGET C - USB Type-C connector for Packetry traffic capture and Facedancer device emulation.

  • TARGET A - USB Type-A connector shared with the TARGET C connector.

  • RESET - Press this button to reset Cynthion’s debug microcontroller and reconfigure the FPGA from flash.

Front View

Cynthion Front View
  • A & B - Two Digilent Pmod™ Compatible I/O connectors for a total of 16 high-speed FPGA user IOs.

    • B can also be configured to act as a serial port and JTAG connector for debugging SoC designs:

      • 1 - SERIAL RX

      • 2 - SERIAL TX

      • 7 - JTAG TMS

      • 8 - JTAG TDI

      • 9 - JTAG TDO

      • 10 - JTAG TCK

Bottom View

Cynthion Bottom View

Self-made Hardware Bringup

This guide is intended to help you bring up a Cynthion board you’ve built yourself. If you’ve received your board from Great Scott Gadgets, it should already be set up, and you shouldn’t need to follow these steps.

Prerequisites

  • A Cynthion board with a populated Debug Controller microprocessor. This is the SAMD microcontroller located in the Debug section at the bottom of the board. When powering the board, the test points should have the marked voltages. The FPGA LEDs might be dimly lit.

  • A programmer capable of uploading firmware via SWD. Examples include the Black Magic Probe; the Segger J-Link, and many OpenOCD compatible boards.

  • A toolchain capable of building binaries for Cortex-M0 processors, such as the GNU Arm Embedded toolchain. If you’re using Linux or macOS, you’ll likely want to fetch this using a package manager; a suitable toolchain may be called something like arm-none-eabi-gcc.

  • A DFU programming utility, such as dfu-util.

Bring-up Process

The high-level process for bringing up your board is as follows:

  1. Compile and upload the Saturn-V bootloader, which allows Debug Controller to program itself.

  2. Compile and upload the Apollo Debug Controller firmware, which allows FPGA configuration & flashing; and provides debug interfaces for working with the FPGA.

  3. Install the cynthion tools, and run through the self-test procedures to validate that your board is working.

Build/upload Saturn-V

The “recovery mode (RVM)” bootloader for Cynthion boards is named Saturn-V; as it’s the first stage in “getting to Cynthion”. The bootloader is located in in its own repository

You can clone the bootloader using git:

$ git clone https://github.com/greatscottgadgets/saturn-v

Build the DFU bootloader by invoking make. An example invocation for modern Cynthion hardware might look like:

$ cd saturn-v
$ make

If you’re building a board that predates r0.3 hardware, you’ll need to specify the board you’re building for:

$ cd saturn-v
$ make BOARD=luna_d21

The build should yield two useful build products: bootloader.elf and bootloader.bin; your SWD programmer will likely consume one of these two files.

Next, connect your SWD programmer to the header labeled uC, and upload bootloader image. You can use both the ports labelled Sideband and Main Host to power the board in this process. If you’re using the Black Magic Probe, this might look like:

$ arm-none-eabi-gdb -nx --batch \
    -ex 'target extended-remote /dev/ttyACM0' \
    -ex 'monitor swdp_scan' \
    -ex 'attach 1' \
    -ex 'load' \
    -ex 'kill' \
    bootloader.elf

If you are using openocd, the process might look similar to the following (add the configuration file for your SWD adapter:

$ openocd -f openocd/scripts/target/at91samdXX.cfg
Open On-Chip Debugger 0.11.0-rc2
Licensed under GNU GPL v2
Info : Listening on port 4444 for telnet connections
Info : clock speed 400 kHz
Info : SWD DPIDR 0x0bc11477
Info : at91samd.cpu: hardware has 4 breakpoints, 2 watchpoints
Info : at91samd.cpu: external reset detected
$ nc localhost 4444
Open On-Chip Debugger
> targets
    TargetName         Type       Endian TapName            State
--  ------------------ ---------- ------ ------------------ ------------
0* at91samd.cpu       cortex_m   little at91samd.cpu       reset

> at91samd chip-erase
chip erase started

> program Luna/saturn-v/bootloader.bin verify reset
target halted due to debug-request, current mode: Thread
xPSR: 0xf1000000 pc: 0xfffffffe msp: 0xfffffffc
** Programming Started **
SAMD MCU: SAMD21G18A (256KB Flash, 32KB RAM)
** Programming Finished **
** Verify Started **
** Verified OK **
** Resetting Target **

If your programmer works best with .bin files, be sure to upload the bootloader.bin to the start of flash (address 0x00000000).

Once the bootloader is installed, you should see LED A blinking rapidly. This is the indication that your board is in Recovery Mode (RVM), and can be programmed via DFU.

You can verify that the board is DFU-programmable by running dfu-util while connected to the USB port labelled Sideband:

$ dfu-util --list
dfu-util 0.9

Copyright 2005-2009 Weston Schmidt, Harald Welte and OpenMoko Inc.
Copyright 2010-2016 Tormod Volden and Stefan Schmidt
This program is Free Software and has ABSOLUTELY NO WARRANTY
Please report bugs to http://sourceforge.net/p/dfu-util/tickets/

Found DFU: [1d50:615c] ver=0000, devnum=22, cfg=1, intf=0, path="2-3.3.1.2", alt=1, name="SRAM"
Found DFU: [1d50:615c] ver=0000, devnum=22, cfg=1, intf=0, path="2-3.3.1.2", alt=0, name="Flash"

If your device shows up as a Cynthion board, congratulations! You’re ready to move on to the next step.

Optional: Bootloader Locking

Optionally, you can reversibly lock the bootloader region of the Debug Controller, preventing you from accidentally overwriting the bootloader. This is most useful for users developing code for the Debug Controller.

If you choose to lock the bootloader, you should lock the first 2KiB of flash. Note that currently, the bootloader lock feature of Black Magic Probe devices always locks 8KiB of flash; and thus cannot be used for Cynthion.

Build/upload Apollo

The next bringup step is to upload the Apollo Debug Controller firmware, which will provide an easy way to interface with the board’s FPGA and any gateware running on it. The Apollo source is located in its own repository.

You can clone the bootloader using git:

$ git clone --recurse-submodules https://github.com/greatscottgadgets/apollo

You can build and run the firmware in one step by invoking make. In order to ensure your firmware matches the hardware it’s running on, you’ll need to provide board type and hardware revision using the APOLLO_BOARD, BOARD_REVISION_MAJOR and BOARD_REVISION_MINOR make variables.

The board’s hardware revision is printed on its silkscreen in a r(MAJOR).(MINOR) format. Board r1.4 would have a BOARD_REVISION_MAJOR=1 and a BOARD_REVISION_MINOR=4. If your board’s revision ends in a +, do not include it in the revision number.

An example invocation for a r1.4 board might be:

$ make APOLLO_BOARD=cynthion BOARD_REVISION_MAJOR=1 BOARD_REVISION_MINOR=4 dfu

Once programming is complete, LED’s A, B, C, D and E should all be on; indicating that the Apollo firmware is idle.

You can also upload a firmware binary using dfu-util with:

$ dfu-util -d 1d50:615c -D firmware.bin

Running Self-Tests

The final step of bringup is to validate the functionality of your hardware. This is most easily accomplished by running Cynthion’s interactive self-test applet.

Before you can run the applet, you’ll need to have a working cynthion development environment. See Introduction to get your environment set up.

Next, we can check to make sure your Cynthion board is recognized by the Cynthion toolchain. Running the apollo info command will list any detected devices:

$ apollo info
Detected a Cynthion device!
    Hardware: Cynthion r1.4
    Serial number: <snip>

Once you’ve validated connectivity, you’re ready to try running the cynthion selftest command.

$ cynthion selftest

 INFO    | __init__    | Building and uploading gateware to attached Cynthion r1.4...
 INFO    | __init__    | Upload complete.
 INFO    | selftest    | Connected to onboard debugger; hardware revision r1.4 (s/n: <snip>).
 INFO    | selftest    | Running tests...

 Automated tests:
         Debug module:    PASSED
         AUX PHY:         PASSED
         HyperRAM:        PASSED
         CONTROL PHY:     PASSED
         TARGET PHY:      PASSED

Troubleshooting

Issue: some of the build files weren’t found; make produces a message like “ no rule to make target “.

Chances are, your clone of Apollo was pulled down without its submodules. You can pull down the relevant submodules using git:

$ git submodule update --init --recursive

Issue: the ``apollo info`` command doesn’t see a connected board.

On Linux, this can be caused by a permissions issue. Check first for the presence of your device using lsusb; if you see a device with the VID/PID 1d50:615c, your board is present – and you likely have a permissions issue. You’ll likely need to install permission-granting udev rules.

The apollo command line utility

The cynthion distribution provides the apollo command-line utility, that can be used to perform various simple functions useful in development; including simple JTAG operations, SVF playback, manipulating the board’s flash, and debug communications.

$ apollo
usage: apollo [-h] command ...

Apollo FPGA Configuration / Debug tool

positional arguments:
  command
    info                Print device info.
    jtag-scan           Prints information about devices on the onboard JTAG chain.
    flash-info          Prints information about the FPGA's attached configuration
                        flash.
    flash-erase         Erases the contents of the FPGA's flash memory.
    flash-program       Programs the target bitstream onto the FPGA's configuration
                        flash.
    flash-fast          Programs a bitstream onto the FPGA's configuration flash using
                        a SPI bridge.
    flash-read          Reads the contents of the attached FPGA's configuration flash.
    svf                 Plays a given SVF file over JTAG.
    configure           Uploads a bitstream to the device's FPGA over JTAG.
    reconfigure         Requests the attached ECP5 reconfigure itself from its SPI flash.
    force-offline       Forces the board's FPGA offline.
    spi                 Sends the given list of bytes over debug-SPI, and returns the
                        response.
    spi-inv             Sends the given list of bytes over SPI with inverted CS.
    spi-reg             Reads or writes to a provided register over the debug-SPI.
    jtag-spi            Sends the given list of bytes over SPI-over-JTAG, and returns the
                        response.
    jtag-reg            Reads or writes to a provided register of JTAG-tunneled debug SPI.
    leds                Sets the specified pattern for the Debug LEDs.

optional arguments:
  -h, --help            show this help message and exit

Getting Help

Before asking for help with Cynthion, check to see if your question is answered in this documentation, or addressed in the Cynthion GitHub repository issues.

For assistance with Cynthion general use or development, please look at the issues on the GitHub project. This is the preferred place to ask questions so that others may locate the answer to your question in the future.

We invite you to join our community discussions on Discord. Note that while technical support requests are welcome here, we do not have support staff on duty at all times. Be sure to also submit an issue on GitHub if you’ve found a bug or if you want to ensure that your request will be tracked and not overlooked.

Cynthion Projects and Mentions

Have you done something cool with Cynthion or mentioned Cynthion in one of your papers or presentations? Email us at info@greatscottgadgets.com with a link to what you’ve done and we might post it here!

Safety Information

Warnings

  • This product shall only be connected to an external power supply rated at 5 V DC. Any power supply used with this product shall comply with relevant regulations and standards applicable in the country of intended use.

  • This product should be used in a shielded enclosure. Removing this product from its enclosure or using this product without a shielded enclosure may expose it to additional risks of damage. Extra care should be taken to prevent damage from electrostatic discharge and impact damage when this product is removed from its enclosure or used without a shielded enclosure.

  • This product should only be connected to another device that is rated to be used at the power level this device is configured to provide. Attaching this product to another device that is not rated to be used at the power level that this device is configured to provide may result in damage.

Instructions For Safe Use

  • Do not expose this product to water or moisture.

  • Do not expose this product to excessive heat while in use.

  • Do not place undue stress on any connector port.

  • Any equipment connected to Cynthion should comply with relevant standards for the country of use and be marked accordingly to ensure that safety and performance requirements are met.

  • Do not allow equipment connected to Cynthion to make contact with any internal component of Cynthion other than a connector.

  • A pass-through power supply connected to Cynthion must supply at least 5 V DC and no more than 20 V DC. The pass-through power supply must be rated for the current drawn by the pass-through device connected to Cynthion.

  • A device connected to Cynthion must be rated for 5 V DC or the pass-through power supply voltage and must draw no more than 3 A current from the pass-through power supply or 500 mA current from Cynthion’s power supply.

  • Use USB cables of no more than 2 m length with Cynthion.

  • Do not expose Pmod or mezzanine connectors to voltages greater than 3.3 V.

Introduction

Setting up a Development Environment

This guide highlights the installation and setup process for setting up a local copy of the Cynthion source code for development.

Prerequisites

Installation

For development you’ll need a local copy of the Cynthion repository:

Use git to clone the repository:

git clone https://github.com/greatscottgadgets/cynthion.git

Please perform the following steps to enable support for symlinks before attempting to clone the repository on Windows:

  1. Open the “For developers” page in System settings and enable Developer Mode.

  2. Restart your computer.

  3. Open the Group Policy editor: gpedit.msc

  4. Navigate to Computer Configuration → Windows Settings → Security Settings → Local Policies → User Rights Assignment → Create symbolic links and check that you have user permission to create symbolic links.

  5. Restart your computer.

  6. Configure git to enable symbolic links on Windows:

    git config --global core.symlinks true
    

Use git to clone the repository:

git clone https://github.com/greatscottgadgets/cynthion.git

Note

To install the cynthion Python package and allow for in-place editing of the sources you can use the pip --editable command:

# change to the 'cynthion' Python package directory
cd cynthion/python/

# install the 'cynthion' Python package, including dependencies required for gateware development
pip install --editable .

Bitstream Generation

Before proceeding, please ensure you have followed the prerequisites in the Setting up a Development Environment section.

Cynthion Gateware

The Cynthion repository contains gateware for two designs:

  • analyzer – USB analyzer for using Cynthion with Packetry.

  • facedancer – System-on-Chip for using Cynthion with Facedancer.

Bitstreams can be generated from the cynthion Python package sub-directory as follows:

Analyzer Gateware

# change to the 'cynthion' Python package directory
cd cynthion/python/

# generate bitstream
python3 -m cynthion.gateware.analyzer.top

Facedancer SoC Gateware

# change to the 'cynthion' Python package directory
cd cynthion/python/

# generate bitstream
python3 -m cynthion.gateware.facedancer.top

Additional Options

Additional options for bitstream generation can be listed by appending --help to the command:

$ python3 -m cynthion.gateware.analyzer.top --help

usage: top.py [-h] [--output filename] [--erase] [--upload] [--flash]
              [--dry-run] [--keep-files] [--fpga part_number] [--console port]

Gateware generation/upload script for 'USBAnalyzerApplet' gateware.

optional arguments:
  -h, --help            show this help message and exit
  --output filename, -o filename
                        Build and output a bitstream to the given file.
  --erase, -E           Clears the relevant FPGA's flash before performing
                        other options.
  --upload, -U          Uploads the relevant design to the target hardware.
                        Default if no options are provided.
  --flash, -F           Flashes the relevant design to the target hardware's
                        configuration flash.
  --dry-run, -D         When provided as the only option; builds the relevant
                        bitstream without uploading or flashing it.
  --keep-files          Keeps the local files in the default `build` folder.
  --fpga part_number    Overrides build configuration to build for a given
                        FPGA. Useful if no FPGA is connected during build.
  --console port        Attempts to open a convenience 115200 8N1 UART console
                        on the specified port immediately after uploading.

Facedancer SoC Firmware Compilation

Prerequisites

Before proceeding, please ensure you have followed the prerequisites in the Setting up a Development Environment section.

Install Rust Dependencies

You will need to install RISC-V embedded target support to compile the firmware:

rustup target add riscv32imac-unknown-none-elf
rustup component add llvm-tools-preview
cargo install cargo-binutils

Optionally, to use gdb for firmware debugging over JTAG, you will need a RISC-V GNU tool chain:

# debian
apt install gcc-riscv64-unknown-elf

# arch
pacman -S riscv-gnu-toolchain-bin

# macos brew - https://github.com/riscv-software-src/homebrew-riscv
brew tap riscv-software-src/riscv
brew install riscv-gnu-toolchain

Building and Running

Firmware for the Cynthion SoC can be found in the firmware/moondancer/ sub-directory.

You can rebuild the firmware using cargo as follows:

# change to the Cynthion firmware directory
cd firmware/moondancer/

# rebuild the firmware
cargo build --release

To upload the firmware binary to Cynthion and flash the SoC bitstream you can run:

# change to the Cynthion firmware directory
cd firmware/moondancer/

# upload firmware and run it
cargo run --release

Note

By default the firmware’s flash script will look for the SoC UART on /dev/ttyACM0, if this is not the case on your machine you will need to specify the correct path using the UART environment variable, for example:

UART=/dev/cu.usbmodem22401 cargo run --release

By default the SoC bitstream is obtained from the latest build in cynthion/python/build/top.bit but you can override it with:

BITSTREAM=path/to/bitstream.bit cargo run --release

Running Firmware Unit Tests

Once the firmware is running on the SoC you can execute some unit tests to exercise the firmware.

In order to do this you will need to connect both the CONTROL and AUX ports of the Cynthion to the host and then run:

# change to the Cynthion firmware directory
cd firmware/moondancer/

# run firmware unit tests
python -m unittest

Firmware Examples

There are a number of firmware examples in the firmware/moondancer/examples/ sub-directory.

# change to the Cynthion firmware directory
cd firmware/moondancer/

# run example
cargo run --release --example <example name>