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(
27            descriptors,
28            # the blockram descriptor handler lacks support for
29            # non-contiguous string descriptor indices, which is
30            # required for the Microsoft OS string descriptor at 0xEE.
31            avoid_blockram=True,
32        )
33
34        # add the microsoft os string descriptor
35        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
36
37        # configure the device to connect by default when plugged into a host
38        m.d.comb += usb.connect.eq(1)
39
40        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(
 67            descriptors,
 68            # the blockram descriptor handler lacks support for
 69            # non-contiguous string descriptor indices, which is
 70            # required for the Microsoft OS string descriptor at 0xEE
 71            avoid_blockram=True,
 72        )
 73
 74        # add the microsoft os string descriptor
 75        descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
 76
 77        # add a microsoft descriptor collection for our other two microsoft descriptors
 78        msft_descriptors = MicrosoftOS10DescriptorCollection()
 79
 80        # add the microsoft compatible id feature descriptor
 81        with msft_descriptors.ExtendedCompatIDDescriptor() as c:
 82            with c.Function() as f:
 83                f.bFirstInterfaceNumber = 0
 84                f.compatibleID          = 'WINUSB'
 85
 86        # add microsoft extended properties feature descriptor
 87        with msft_descriptors.ExtendedPropertiesDescriptor() as d:
 88            with d.Property() as p:
 89                p.dwPropertyDataType = RegistryTypes.REG_SZ
 90                p.PropertyName       = "DeviceInterfaceGUID"
 91                p.PropertyData       = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
 92
 93        # add the request handler for Microsoft descriptors
 94        msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
 95        control_endpoint.add_request_handler(msft_handler)
 96
 97        # configure the device to connect by default when plugged into a host
 98        m.d.comb += usb.connect.eq(1)
 99
100        return m
101
102
103if __name__ == "__main__":
104    from luna import top_level_cli
105    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)