This is a visual gamepad tester for CircuitPython on Fruit Jam. The tester can detect and map buttons for several common gamepad types, including the Adafruit SNES-like gamepad. The tester can also display hexdumps of input event reports from HID boot-compatible keyboards and mice. Other HID devices may also work as long as their report lengths are short enough to fit on the screen (about 8 bytes).
I wrote the code on CircuitPython 10.0.0-alpha.4 through 10.0.0-alpha.6 on a rev B Fruit Jam prototype. Using a Metro RP2350 with USB hub would probably work too. I haven't tried that, so it's possible you might need to make minor tweaks to the code for pin names (e.g. boot button and DVI out).
To learn more about the spritesheet animation I use for the gamepad visualizer, check out my Feather TFT Gamepad Tester with Sprites guide from last year. This version of the tester for Fruit Jam is based on the Feather TFT version. For Fruit Jam, I added support for the larger display and more gamepad types.
Transparency note: Adafruit provided the Fruit Jam rev B prototype board I used for this guide (Thanks Adafruit!). Adafruit makes good stuff, please support them by buying from the Adafruit shop.
Demo Video
This is a demo video showing DVI and serial console output from the Fruit Jam as I try the code with the pile of test devices strewn across my desk (so many cables!).
Parts
I wrote this for Fruit Jam, but a Metro RP2350 with a USB hub would probably also work. For more information on setting up a Metro RP2350 as a pseudo-Fruit Jam, you can check out some of the recent guides by Tim C. and M. LeBlanc-Williams.
Updating CircuitPython
As I write this (May 19, 2025), USB support for Fruit Jam and Metro RP250 is under active development. My gamepad tester code was most recently tested on CircuitPython 10.0.0-alpha.6 for Fruit Jam. I'm not sure what will change by the time 10.0.0 is released. To keep your board up to date, you can use the "DOWNLOAD .UF2 NOW" buttons on the appropriate download page of circuitpython.org:
- Metro RP2350 Download page
- Fruit Jam Download page
To install the UF2 File:
- Connect your board to a computer with a USB data cable (charge-only cables won't work! )
- Press and hold the board's boot button (Button 1 on Fruit Jam)
- Press and release the reset button
- Release the boot button
- When the removable drive named RP2350 appears, copy the UF2 file onto it. After the copy finishes, you should see the RP2350 drive disappear and soon after that a new CIRCUITPY drive should appear.
CircuitPython Code
You can view the code at the samblenny/fruit-jam-gamepad-tester GitHub repository. To download a zip archive project bundle with the code and all the necessary libraries, use the "Download Project Bundle" button:
Install Project Bundle
To copy the project bundle files to your CIRCUITPY drive:
- Download the project bundle .zip file using the "Download Project Bundle" button above.
- Expand the zip file by opening it, or use unzip in a Terminal. The zip archive should expand to a folder. When you open the folder, it should contain a README.txt file and a CircuitPython 9.x folder. (yes, 9.x works for the 10.0.0-alpha builds)
- Open the CircuitPython 9.x folder and copy all of its contents to your CIRCUITPY drive.
To learn more about copying libraries to your CIRCUITPY drive, check out the CircuitPython Libraries section of the Welcome to CircuitPython! learn guide.
Understanding the Code
Here's an outline of the major files, functions, and classes, along with their role in making the Fruit Jam gamepad tester work:
-
CIRCUITPY/code.py:- The
main()function initializes the display and sets up a GUI with a sprite-based gamepad visualizer and somebitmap_labelobjects for displaying text status messages. The main loop, starting withwhile True:, scans for USB devices, configures the first input device it finds, then polls for input events. To stop the loop and rescan the bus, you can press Button 1. The loop also stops forUSBError, which happens sometimes when a device is unplugged. When input events arrive, the main loop prints the data to the console and DVI display and updates the gamepad visualizer widget. - The
update_GUI()function decodes a 16-bit bitfield of button states provided by the main loop and uses them to swap sprites in aTileGrid. The button bitfield is in the format for the third and fourth bytes of a raw USB input event report for an XInput gamepad. Input events from other gamepad types get normalized into this form by button mapping functions.
- The
-
CIRCUITPY/gamepad.py:- The
UP,DOWN,LEFT,RIGHT,START,SELECT,L,R,A,B,X, andYconstants provide bit masks for each of the buttons in the 16-bit XInput format bitfield. For examples of how to use the bitmask constants, check out theupdate_GUI()function incode.py. - The
find_usb_device()function uses the usb.core API to loop through devices connected to the USB bus. For each of those devices, it uses theusb_descriptor.Descriptorclass fromCIRCUITPY/usb_descriptor.pyto check the vendor ID, product ID, and other device properties to identify different types of gamepads and HID devices. Whenfind_usb_device()locates an input device, it creates aScanResultclass instance and returns the scan result to the main loop fromcode.py. - The
ScanResultclass holds a reference to theusb.core.Deviceobject for an input device along with its device type and the theusb_descriptor.Descriptorobject. The main point of aScanResultobject is to hold all the stuff you need in order to initialize anInputDeviceobject. The point of splitting things into two classes is to let the main loop look at theScanResultobject to decide if it wants to start reading input from that device or not. - The
InputDeviceclass implements device drivers for various types of gamepads. Theinput_event_generator()method provides an abstraction layer for event detection and button mapping. It creates an iterator that can be used with aforloop to get input change events without having to worry about button mappings or the specifics of low-level USB protocols.
- The
-
CIRCUITPY/usb_descriptor.py:- The
Descriptorclass provides parsing for USB device descriptors and configuration descriptors. The descriptor information makes it possible to identify gamepads either by vendor and product IDs, or by looking at details of interfaces (e.g. to identify different types of HID device).
- The
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
import binascii
from board import BUTTON1, CKP, CKN, D0P, D0N, D1P, D1N, D2P, D2N
from digitalio import DigitalInOut, Direction, Pull
from displayio import (Bitmap, Group, OnDiskBitmap, Palette, TileGrid,
release_displays)
from framebufferio import FramebufferDisplay
import gc
from picodvi import Framebuffer
import supervisor
from terminalio import FONT
from time import sleep
from usb.core import USBError, USBTimeoutError
import usb_host
from adafruit_display_text import bitmap_label
import adafruit_imageload
import adafruit_logging as logging
from gamepad import (
find_usb_device, InputDevice,
UP, DOWN, LEFT, RIGHT, START, SELECT, L, R, A, B, X, Y)
# Configure logging
logger = logging.getLogger('code.py')
logger.setLevel(logging.DEBUG)
def update_GUI(scene, buttons, diff):
# Update TileGrid sprites to reflect changed state of gamepad buttons
# Scene is 10 sprites wide by 5 sprites tall:
# Y
# 0 . L . . . . . . R .
# 1 . . dU. . . . X . .
# 2 . dL. dR. . Y . A .
# 3 . . dD. SeSt. B . .
# 4 . . . . . . . . . .
# 0 1 2 3 4 5 6 7 8 9 X
#
if diff & A:
scene[8, 2] = 15 if (buttons & A) else 17
if diff & B:
scene[7, 3] = 15 if (buttons & B) else 17
if diff & X:
scene[7, 1] = 15 if (buttons & X) else 17
if diff & Y:
scene[6, 2] = 15 if (buttons & Y) else 17
if diff & L:
scene[1, 0] = 1 if (buttons & L) else 5
if diff & R:
scene[8, 0] = 1 if (buttons & R) else 5
if diff & UP:
scene[2, 1] = 8 if (buttons & UP) else 12
if diff & DOWN:
scene[2, 3] = 22 if (buttons & DOWN) else 26
if diff & LEFT:
scene[1, 2] = 14 if (buttons & LEFT) else 18
if diff & RIGHT:
scene[3, 2] = 16 if (buttons & RIGHT) else 20
if diff & SELECT:
scene[4, 3] = 10 if (buttons & SELECT) else 24
if diff & START:
scene[5, 3] = 11 if (buttons & START) else 25
def main():
# Make sure display is configured for 320x240 8-bit
display = supervisor.runtime.display
if (display is None) or display.width != 320:
logger.info("Re-initializing display for 320x240")
release_displays()
gc.collect()
fb = Framebuffer(320, 240, clk_dp=CKP, clk_dn=CKN,
red_dp=D0P, red_dn=D0N, green_dp=D1P, green_dn=D1N,
blue_dp=D2P, blue_dn=D2N, color_depth=8)
display = FramebufferDisplay(fb)
supervisor.runtime.display = display
else:
logger.info("Using existing display")
display.auto_refresh = False
grp = Group(scale=2) # 2x zoom
display.root_group = grp
# load spritesheet and palette
(bitmap, palette) = adafruit_imageload.load("sprites.bmp", bitmap=Bitmap,
palette=Palette)
# assemble TileGrid with gamepad using sprites from the spritesheet
scene = TileGrid(bitmap, pixel_shader=palette, width=10, height=5,
tile_width=8, tile_height=8, default_tile=9, x=8, y=8)
tilemap = (
(0, 5, 2, 3, 3, 3, 3, 4, 5, 6), # . L . . . . . . R .
(7, 9, 12, 9, 9, 9, 9, 17, 9, 13), # . . dU. . . . X . .
(7, 18, 19, 20, 9, 9, 17, 9, 17, 13), # . dL. dR. . Y . A .
(7, 9, 26, 9, 24, 25, 9, 17, 9, 13), # . . dD. SeSt. B . .
(21, 23, 23, 23, 23, 23, 23, 23, 23, 27), # . . . . . . . . . .
)
for (y, row) in enumerate(tilemap):
for (x, sprite) in enumerate(row):
scene[x, y] = sprite
grp.append(scene)
# Make a text label for status messages
status = bitmap_label.Label(FONT, text="", color=0xFFFFFF, scale=1)
status.line_spacing = 1.0
status.anchor_point = (0, 0)
status.anchored_position = (8, 54)
grp.append(status)
# Make a separate text label for input event report data
report = bitmap_label.Label(FONT, text="", color=0xFFFFFF, scale=1)
report.line_spacing = 1.0
report.anchor_point = (0, 0)
report.anchored_position = (8, 54 + (12*4))
grp.append(report)
# Configure button #1 as input to trigger USB bus re-connect
button_1 = DigitalInOut(BUTTON1)
button_1.direction = Direction.INPUT
button_1.pull = Pull.UP
# Define status label updater with access to local vars from main()
def set_status(msg, log_it=False):
status.text = msg
display.refresh()
if log_it:
logger.info(msg)
# Define report label updater with access to local vars from main()
def set_report(data):
if data is None:
report.text = ''
elif isinstance(data, str):
print('\r%s' % data, end='') # NOTE: this uses '\r...', end=''!
report.text = data
else:
msg = ' '.join(['%02x' % b for b in data])
print('\r%s' % msg, end='') # NOTE: this uses '\r...', end=''!
report.text = msg
display.refresh()
show_scan_msg = True
while True:
if show_scan_msg:
print()
set_status("Scanning USB bus...", log_it=True)
set_report(None)
show_scan_msg = False
gc.collect()
device_cache = {}
try:
scan_result = find_usb_device(device_cache)
if scan_result is None:
# No connection yet, so sleep briefly then try the find again
sleep(0.4)
continue
# Found an input device, so try to configure it and start polling
dev = InputDevice(scan_result)
sr = scan_result
set_status((
"%04X:%04X %s\n" # vid:pid tag
"dev %02X:%02X:%02X\n" # device class:subclass:protocol
"int0 %02X:%02X:%02X\n" # interface 0 class:subclass:proto.
"(button 1: rescan bus)"
) % ((sr.vid, sr.pid, sr.tag) + sr.dev_info + sr.int0_info)
)
# Poll for input events until Button #1 pressed or USB error
prev = 0
for data in dev.input_event_generator():
if not button_1.value: # Fruit Jam Button #1 pressed
break
if data is None: # Rate limit or USB timedout
continue
elif isinstance(data, int): # SNES-like uint16 bitfield
diff = prev ^ data
prev = data
update_GUI(scene, data, diff)
set_report('%04x' % data)
else: # Raw HID report bytes
set_report(data)
show_scan_msg = True
except USBError as e:
# This sometimes happens when devices are unplugged. Not always.
print()
logger.info("USBError: '%s' (device unplugged?)" % e)
show_scan_msg = True
except ValueError as e:
# This can happen if an initialization handshake glitches
print()
logger.error(e)
show_scan_msg = True
main()
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# Gamepad driver for various USB wired gamepads.
#
# NOTE: This code uses performance boosting tricks to avoid bogging down the
# CPU or making a lot of heap allocations. To learn more about caching function
# references, caching instance variables, and making iterators with generator
# functions, check out the links below.
#
# Related docs:
# - https://docs.circuitpython.org/projects/logging/en/latest/api.html
# - https://learn.adafruit.com/a-logger-for-circuitpython/overview
# - https://docs.python.org/3/glossary.html#term-generator
# - https://docs.python.org/3/glossary.html#term-iterable
# - https://docs.micropython.org/en/latest/reference/speed_python.html
#
import binascii
import gc
from micropython import const
from struct import unpack, unpack_from
from supervisor import ticks_ms
from time import sleep
from usb import core
from usb.core import USBError, USBTimeoutError
from usb.util import SPEED_LOW, SPEED_FULL, SPEED_HIGH
import adafruit_logging as logging
import usb_descriptor
# Configure logging
logger = logging.getLogger('gamepad')
logger.setLevel(logging.INFO)
# Gamepad button bitmask constants
UP = const(0x0001) # dpad: Up
DOWN = const(0x0002) # dpad: Down
LEFT = const(0x0004) # dpad: Left
RIGHT = const(0x0008) # dpad: Right
START = const(0x0010)
SELECT = const(0x0020)
L = const(0x0100) # Left shoulder button
R = const(0x0200) # Right shoulder button
B = const(0x1000) # button cluster: bottom button (Nintendo B, Xbox A)
A = const(0x2000) # button cluster: right button (Nintendo A, Xbox B)
Y = const(0x4000) # button cluster: left button (Nintendo Y, Xbox X)
X = const(0x8000) # button cluster: top button (Nintendo X, Xbox Y)
# USB detected device types
TYPE_SWITCH_PRO = const(1) # 057e:2009 clones of Switch Pro Controller
TYPE_ADAFRUIT_SNES = const(2) # 081f:e401 generic SNES layout HID, low-speed
TYPE_8BITDO_ZERO2 = const(3) # 2dc8:9018 mini SNES layout, HID over USB-C
TYPE_XINPUT = const(4) # (vid:pid vary) Clones of Xbox360 controller
TYPE_BOOT_MOUSE = const(5)
TYPE_BOOT_KEYBOARD = const(6)
TYPE_HID_COMPOSITE = const(7)
TYPE_HID = const(8)
TYPE_POWERA_WIRED = const(9) # 20d6:a711 PowerA Wired Controller (for Switch)
def find_usb_device(device_cache):
# Find a USB wired gamepad by inspecting usb device descriptors
# - device_cache: dictionary of previously checked device descriptors
# - return: ScanResult object for success or None for failure.
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
for device in core.find(find_all=True):
# Read descriptors to identify devices by type
try:
desc = usb_descriptor.Descriptor(device)
k = str(desc.to_bytes())
if k in device_cache:
return None
# Remember this device to avoid repeatedly checking it later
device_cache[k] = True
# Compare descriptor to known device type fingerprints
desc.read_configuration(device)
vid, pid = desc.vid_pid()
# Get tuples of class/subclass/protocol for device and interface 0
dev_info = desc.dev_class_subclass_protocol()
int0_info = desc.int0_class_subclass_protocol()
dev_int0_info = dev_info + int0_info
logger.info(desc)
dev = device
if (vid, pid) == (0x057e, 0x2009):
return ScanResult(dev, TYPE_SWITCH_PRO, 'SwitchPro', desc)
elif (vid, pid) == (0x081f, 0xe401):
# Generic SNES layout HID gamepad sold by Adafruit
return ScanResult(dev, TYPE_ADAFRUIT_SNES, 'AdafruitSNES', desc)
elif (vid, pid) == (0x2dc8, 0x9018):
# This one is HID but quirky, so it needs special handling
return ScanResult(dev, TYPE_8BITDO_ZERO2, '8BitDoZero2', desc)
elif (vid, pid) == (0x20d6, 0xa711):
# This is for Switch, but it's HID, with 8-bits per axis analog
return ScanResult(dev, TYPE_POWERA_WIRED, 'PowerAWired', desc)
elif dev_int0_info == (0xff, 0xff, 0xff, 0xff, 0x5d, 0x01):
return ScanResult(dev, TYPE_XINPUT, 'XInput', desc)
elif dev_int0_info == (0x00, 0x00, 0x00, 0x03, 0x00, 0x00):
return ScanResult(dev, TYPE_HID_COMPOSITE, 'HIDComposite', desc)
elif dev_int0_info == (0x00, 0x00, 0x00, 0x03, 0x01, 0x01):
return ScanResult(dev, TYPE_BOOT_KEYBOARD, 'BootKeyboard', desc)
elif dev_int0_info == (0x00, 0x00, 0x00, 0x03, 0x01, 0x02):
return ScanResult(dev, TYPE_BOOT_MOUSE, 'BootMouse', desc)
elif int0_info == (0x03, 0x00, 0x00):
return ScanResult(dev, TYPE_HID, 'HID', desc)
else:
logger.info("IGNORING UNRECOGNIZED DEVICE")
return None
except ValueError as e:
logger.info(e)
except USBError as e:
logger.info("find_usb_device() USBError: '%s'" % e)
return None
class ScanResult:
def __init__(self, device, dev_type, tag, descriptor):
self.device = device
self.dev_type = dev_type
self.tag = tag
self.descriptor = descriptor
self.vid = descriptor.idVendor
self.pid = descriptor.idProduct
self.dev_info = descriptor.dev_class_subclass_protocol()
self.int0_info = descriptor.int0_class_subclass_protocol()
def elapsed_ms_generator():
# Generator function for measuring time intervals efficiently.
# - returns: an iterator
# - iterator yields: ms since last call to next(iterator)
#
ms = ticks_ms # caching function ref avoids dictionary lookups
mask = 0x3fffffff # (2**29)-1 because ticks_ms rolls over at 2**29
t0 = ms()
while True:
t1 = ms()
delta = (t1 - t0) & mask # handle possible timer rollover gracefully
t0 = t1
yield delta
class InputDevice:
def __init__(self, scan_result):
# Initialize buffers used in polling USB gamepad events
# - scan_result: a ScanResult instance
# Exceptions: may raise usb.core.USBError
#
device = scan_result.device
dev_type = scan_result.dev_type
self._prev = 0
self.buf64 = bytearray(64)
self.device = device
self.dev_type = dev_type
# Make sure CircuitPython core is not claiming the device
interface = 0
if device.is_kernel_driver_active(interface):
logger.debug('Detaching interface %d from kernel' % interface)
device.detach_kernel_driver(interface)
# Set configuration
device.set_configuration()
# Figure out which endpoints to use
int0_ins = scan_result.descriptor.int0_input_endpoints()
int0_outs = scan_result.descriptor.int0_output_endpoints()
endpoint_in = None if (len(int0_ins) < 1) else int0_ins[0]
endpoint_out = None if (len(int0_outs) < 1) else int0_outs[0]
logger.debug('INT0 IN: %s' % endpoint_in)
logger.debug('INT0 OUT: %s' % endpoint_out)
self.int0_endpoint_in = endpoint_in
self.int0_endpoint_out = endpoint_out
# Initialize USB device if needed (e.g. handshake or set gamepad LEDs)
if dev_type == TYPE_SWITCH_PRO:
self.init_switch_pro_gamepad()
elif dev_type == TYPE_ADAFRUIT_SNES:
logger.info('Initializing Adafruit SNES-like gamepad')
elif dev_type == TYPE_8BITDO_ZERO2:
logger.info('Initializing 8BitDo Zero 2 gamepad')
elif dev_type == TYPE_POWERA_WIRED:
logger.info('Initializing PowerA Wired Controller')
elif dev_type == TYPE_XINPUT:
self.init_xinput()
elif dev_type == TYPE_BOOT_MOUSE:
logger.info('Initializing Boot-Compatible Mouse')
elif dev_type == TYPE_BOOT_KEYBOARD:
logger.info('Initializing Boot-Compatible Keyboard')
elif dev_type == TYPE_HID_COMPOSITE:
logger.info('Initializing HID composite device')
elif dev_type == TYPE_HID:
logger.info('Initializing HID device')
else:
raise ValueError('Unknown dev_type: %d' % dev_type)
def init_switch_pro_gamepad(self):
# Prepare Switch Pro compatible gamepad for use.
# Exceptions: may raise usb.core.USBError and usb.core.USBTimeoutError
#
logger.info('Initializing SwitchPro gamepad')
out_addr = self.int0_endpoint_out.bEndpointAddress
in_addr = self.int0_endpoint_in.bEndpointAddress
out_interval = self.int0_endpoint_out.bInterval
in_interval = self.int0_endpoint_in.bInterval
max_packet = min(64, self.int0_endpoint_in.wMaxPacketSize)
data = bytearray(max_packet)
data_mv = memoryview(data)
handshake_messages = (
bytes(b'\x80\x01'), # get device type and mac address
bytes(b'\x80\x02'), # handshake
bytes(b'\x80\x03'), # set faster baud rate
bytes(b'\x80\x02'), # handshake
bytes(b'\x80\x04'), # use USB HID only and disable timeout
# set input report mode to standard
bytes(b'\x01\x06\x00\x00\x00\x00\x00\x00\x00\x00\x03\x30'),
# set player LED1 to on (for LED1+LED2 do 30 03, etc.)
bytes(b'\x01\x0a\x00\x00\x00\x00\x00\x00\x00\x00\x30\x01'),
# set home LED
bytes(b'\x01\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x38\x01\x00\x00\x11\x11'),
)
hexdump = binascii.hexlify # cache hexdumper function
for msg in handshake_messages:
try:
self.device.write(out_addr, msg, timeout=out_interval)
except USBTimeoutError as e:
raise ValueError("SwitchPro HANDSHAKE GLITCH (wr)")
# Wait for ACK
okay = False
for _ in range(8):
try:
self.device.read(in_addr, data, timeout=in_interval)
logger.info('ACK %s' % hexdump(data_mv[:2]))
okay = True
break
except USBTimeoutError:
pass
if not okay:
# This happens with my 8BitDo Ultimate Bluetooth Controller's
# 2.4 GHz USB adapter. It glitches several times like this
# before re-appearing in XInput mode with vid:pid 045e:028e.
raise ValueError("SwitchPro HANDSHAKE GLITCH (rd)")
def init_xinput(self):
# Prepare XInput gamepad for use.
# Exceptions: may raise USBError
logger.info('Initializing XInput gamepad')
out_addr = self.int0_endpoint_out.bEndpointAddress
in_addr = self.int0_endpoint_in.bEndpointAddress
out_inteval = self.int0_endpoint_out.bInterval
in_interval = self.int0_endpoint_in.bInterval
max_packet = min(64, self.int0_endpoint_in.wMaxPacketSize)
data = bytearray(max_packet)
# Set player number LEDs on XInput gamepad (hardcode to player 1)
msg = bytes(b'\x01\x03\x02') # 1 LED
#msg = bytes(b'\x01\x03\x03') # 2 LEDs
#msg = bytes(b'\x01\x03\x04') # 3 LEDs
#msg = bytes(b'\x01\x03\x05') # 4 LEDs
self.device.write(out_addr, msg, timeout=8)
# Some XInput gamepads send a bunch of stuff initially before normal
# reports begin, so drain the input pipe
for _ in range(8):
try:
self.device.read(in_addr, data, timeout=in_interval)
except USBTimeoutError as e:
# Ignore timeouts
pass
def input_event_generator(self):
# This is a generator that makes an iterable for reading input events.
# - returns: iterable that can be used with a for loop
# - yields: (3 possibilities)
# 1. Normalized 16-bit integer with XInput style button bitfield
# 2. A memoryview(bytearray(...)) with raw or filtered data from
# polling the default endpoint.
# 3. None in the case of a timeout or rate limit throttle
# Exceptions: may raise USBError
#
dev_type = self.dev_type # cache this as we use it several times
int0_gen = self.int0_read_generator # cache to make shorter lines
if self.device is None:
return None
elif dev_type == TYPE_SWITCH_PRO:
# Report format (cluster layout: A on right)
# byte 0: report ID
# byte 1: sequence number
# byte 2: 0x01=Y, 0x02=X, 0x04=B, 0x08=A, 0x40=R, 0x80=R2
# byte 3: 0x01=Select, 0x02=Start, 0x04=R_stick_btn,
# 0x08=L_stick_btn, 0x10=Home=0x10, 0x20=Share
# byte 4: DpadDn=0x01, DpadUp=0x02, DpadR=0x04, DpadL=0x08,
# 0x40=L, 0x80=L2
#
# Generator function converts byte array to an XInput format uint16
# - data: an iterator that yields memoryview(bytearray(...))
def normalize_switchpro(data):
for d in data:
if d is None:
yield None
continue
v = 0
d2 = d[0] # byte 2 of the unfiltered report
d3 = d[1] # byte 3 of the unfiltered report
d4 = d[2] # byte 4 of the unfiltered report
v |= Y if d2 & 0x01 else 0
v |= X if d2 & 0x02 else 0
v |= B if d2 & 0x04 else 0
v |= A if d2 & 0x08 else 0
v |= R if d2 & 0x40 else 0
v |= SELECT if d3 & 0x01 else 0
v |= START if d3 & 0x02 else 0
v |= DOWN if d4 & 0x01 else 0
v |= UP if d4 & 0x02 else 0
v |= RIGHT if d4 & 0x04 else 0
v |= LEFT if d4 & 0x08 else 0
v |= L if d4 & 0x40 else 0
yield v
# This filter lambda returns None when report ID is not 0x30. For
# report ID 0x30, filter trims off report ID, sequence number, and
# IMU data, leaving bytes for buttons, dpad, and sticks.
filter_fn = lambda d: None if (d[0] != 0x30) else d[3:6]
return normalize_switchpro(int0_gen(filter_fn=filter_fn))
elif dev_type == TYPE_ADAFRUIT_SNES:
# Report format (SNES cluster layout, A on right)
# byte 0: (analog dpad) 0x00=dPadL, 0x7f=dPadCenter, 0xff=dPadR
# byte 1: (analog dpad) 0x00=dPadUp, 0x7f=dPadCenter, 0xff=dPadDn
# ...
# byte 5: (bitfield) 0x10=X, 0x20=A, 0x40=B, 0x80=Y
# byte 6: (bitfield) 0x01=L, 0x02=R, 0x10=Select, 0x20=Start
#
def normalize_adasnes(data):
for d in data:
if d is None:
yield None
continue
v = 0
d0 = d[0] # byte 0 of the unfiltered report
d1 = d[1] # byte 1 of the unfiltered report
d5 = d[5] # byte 5 of the unfiltered report
d6 = d[6] # byte 6 of the unfiltered report
# Dpad uses 2 analog axes
v |= LEFT if d0 == 0x00 else 0
v |= RIGHT if d0 == 0xff else 0
v |= UP if d1 == 0x00 else 0
v |= DOWN if d1 == 0xff else 0
# Buttons are bitfield
v |= X if d5 & 0x10 else 0
v |= A if d5 & 0x20 else 0
v |= B if d5 & 0x40 else 0
v |= Y if d5 & 0x80 else 0
v |= L if d6 & 0x01 else 0
v |= R if d6 & 0x02 else 0
v |= SELECT if d6 & 0x10 else 0
v |= START if d6 & 0x20 else 0
yield v
return normalize_adasnes(int0_gen(filter_fn=lambda d: d[:7]))
elif dev_type == TYPE_8BITDO_ZERO2:
# This device is quirky because it alternates between 8 byte and
# 24 byte HID reports. The 24 byte reports seem to be three of the
# 8 byte reports stuck together.
#
# Report format (dpad is 4-bit BCD style):
# byte 0: 0x01=A, 0x02=B, 0x08=X, 0x10=Y, 0x40=L, 0x80=R
# byte 1: 0x04=Select, 0x08=Start
# byte 2: 0x00=dPadN, 0x01=dPadNE, 0x02=dPadE, 0x03=dPadSE,
# 0x04=dPadS, 0x05=dPadSW, 0x06=dPadW, 0x07=dPadNW,
# 0x0f=dPadCenter
#
def normalize_zero2(data):
for d in data:
if d is None:
yield None
continue
v = 0
d0 = d[0] # byte 0 of the unfiltered report
d1 = d[1] # byte 1 of the unfiltered report
d2 = d[2] # byte 2 of the unfiltered report
# Buttons are bitfield
v |= A if d0 & 0x01 else 0
v |= B if d0 & 0x02 else 0
v |= X if d0 & 0x08 else 0
v |= Y if d0 & 0x10 else 0
v |= L if d0 & 0x40 else 0
v |= R if d0 & 0x80 else 0
v |= SELECT if d1 & 0x04 else 0
v |= START if d1 & 0x08 else 0
# Dpad is 4-bit BCD
v |= UP if d2 == 0x00 else 0
v |= UP | RIGHT if d2 == 0x01 else 0
v |= RIGHT if d2 == 0x02 else 0
v |= DOWN | RIGHT if d2 == 0x03 else 0
v |= DOWN if d2 == 0x04 else 0
v |= DOWN | LEFT if d2 == 0x05 else 0
v |= LEFT if d2 == 0x06 else 0
v |= UP | LEFT if d2 == 0x07 else 0
yield v
return normalize_zero2(int0_gen(filter_fn=lambda d: d[:3]))
elif dev_type == TYPE_POWERA_WIRED:
# This device is a straightforward well-behaved HID gamepad with
# 4-bit BCD dpad and 8-bits per axis analog (which I'm ignoring).
#
# Report format (dpad is 4-bit BCD style, buttons are bitfield):
# byte 0: 0x01=Y, 0x02=B, 0x04=A, 0x08=X, 0x10=L, 0x20=R,
# 0x40=L1, 0x80=R2
# byte 1: 0x01=Select, 0x02=Start, 0x10=Home, 0x20=Screenshot
# byte 2: 0x00=dPadN, 0x01=dPadNE, 0x02=dPadE, 0x03=dPadSE,
# 0x04=dPadS, 0x05=dPadSW, 0x06=dPadW, 0x07=dPadNW,
# 0x0f=dPadCenter
#
def normalize_powera_wired(data):
for d in data:
if d is None:
yield None
continue
v = 0
d0 = d[0] # byte 0 of the unfiltered report
d1 = d[1] # byte 1 of the unfiltered report
d2 = d[2] # byte 2 of the unfiltered report
# Buttons are bitfield
v |= Y if d0 & 0x01 else 0
v |= B if d0 & 0x02 else 0
v |= A if d0 & 0x04 else 0
v |= X if d0 & 0x08 else 0
v |= L if d0 & 0x10 else 0
v |= R if d0 & 0x20 else 0
v |= SELECT if d1 & 0x01 else 0
v |= START if d1 & 0x02 else 0
# Dpad is 4-bit BCD
v |= UP if d2 == 0x00 else 0
v |= UP | RIGHT if d2 == 0x01 else 0
v |= RIGHT if d2 == 0x02 else 0
v |= DOWN | RIGHT if d2 == 0x03 else 0
v |= DOWN if d2 == 0x04 else 0
v |= DOWN | LEFT if d2 == 0x05 else 0
v |= LEFT if d2 == 0x06 else 0
v |= UP | LEFT if d2 == 0x07 else 0
yield v
return normalize_powera_wired(int0_gen(filter_fn=lambda d: d[:3]))
elif dev_type == TYPE_XINPUT:
# Report format (clone w/ SNES cluster layout, A on right):
# (NOTE: This is the canonical format that others get normalized to)
# ...
# byte 2: 0x01=dPadUp, 0x02=dPadDn, 0x04=dPadL, 0x08=dPadR,
# 0x10=Start, 0x20=Select
# byte 3: 0x01=L, 0x02=R, 0x10=B, 0x20=A, 0x05=Home, 0x40=Y, 0x80=X
#
def normalize_xinput(data):
for d in data:
yield None if d is None else ((d[1] << 8) | d[0])
# Filter lambda trims off all the analog stuff
return normalize_xinput(int0_gen(filter_fn=lambda d: d[2:4]))
elif dev_type == TYPE_BOOT_MOUSE:
return int0_gen()
elif dev_type == TYPE_BOOT_KEYBOARD:
return int0_gen()
elif dev_type == TYPE_HID_COMPOSITE:
return int0_gen()
elif dev_type == TYPE_HID:
return int0_gen()
elif dev_type == TYPE_OTHER:
# Don't mess with unknown non-HID devices
logger.info("Ignoring unknown device type")
return
else:
logger.error('UNEXPECTED VALUE FOR dev_type: %d' % dev_type)
def int0_read_generator(self, filter_fn=lambda d: d):
# Generator function: read from interface 0 and yield raw report data
# - filter_fn: Optional lambda function to modify raw reports. This is
# for slicing off sequence numbers, analog values, or junk bytes.
# - yields: memoryview of bytes
# Exceptions: may raise USBError
#
# Meaning of bInterval depends on negotiated speed:
# - USB 2.0 spec: 5.6.4 Isochronous Transfer Bus Access Constraints
# - USB 2.0 spec: 9.6.6 Endpoint (table 9-13)
# - Low-speed: max time between polling requests = bInterval * 1 ms
# - Full-speed: max time = bInterval * 1 ms
# - High-speed: max time = math.pow(2, bInterval-1) * 125 µs
#
# This implementation alternates between two data buffers so it's
# possible to compare the previous report with the current report
# without having to heap allocate a new buffer every time.
#
in_addr = self.int0_endpoint_in.bEndpointAddress
interval = self.int0_endpoint_in.bInterval
if self.device.speed == SPEED_LOW:
logger.info('LOW SPEED, period = %d ms' % interval)
elif self.device.speed == SPEED_FULL:
logger.info('FULL SPEED, period = %d ms' % interval)
elif self.device.speed == SPEED_HIGH:
# Units here are 125 µs or (1 ms)/8. Since timer resolution we have
# available is 1 ms, quantize the requested interval to 1 ms units
# (left shift 3 to divide by 8).
interval = (2 << (interval - 1)) >> 3
logger.info('HIGH SPEED, period = %d ms' % interval)
max_packet = min(64, self.int0_endpoint_in.wMaxPacketSize)
odd = True
data_odd = bytearray(max_packet)
data_even = bytearray(max_packet)
mv_odd = memoryview(data_odd) # memoryview reduces heap allocations
mv_even = memoryview(data_even)
prev_report = mv_even
dev_read = self.device.read # cache function to avoid dictionary lookups
# Make timer to throttle the polling rate because...
# 1. Reading USB too much bogs down the system and fights with DVI
# 2. Waiting too long to read USB will upset some devices
poll_ms = 0
poll_dt = elapsed_ms_generator()
poll_target = (interval * 3) >> 2 # 75% of the max polling interval
# Polling loop
while True:
poll_ms += next(poll_dt)
if poll_ms < poll_target:
yield None # It's too soon to poll now
continue
else:
poll_ms = 0
# Enough time has passed, so poll endpoint and compare report data
# to that of the previous report. If they differ, update the
# previous value, swap the active buffer, and yield a memoryview
# into the most recent trimmed report data. The even/odd buffer
# swapping is necessary for the memoryview stuff to work properly.
#
# NOTE: This is using a lambda function provided by the caller to
# filter the raw data read from the endpoint. The lambda function
# can return None when the current read should be skipped (e.g. HID
# report with boring report ID).
#
curr_data = data_odd if odd else data_even
try:
if odd:
n = dev_read(in_addr, data_odd, timeout=interval)
report = filter_fn(mv_odd[:n])
if (report is None) or (report == prev_report):
yield None
else:
prev_report = report
odd = False
yield report
else:
n = dev_read(in_addr, data_even, timeout=interval)
report = filter_fn(mv_even[:n])
if (report is None) or (report == prev_report):
yield None
else:
prev_report = report
odd = True
yield report
except USBTimeoutError as e:
# This is normal. Timeouts happen fairly often.
yield None
except USBError as e:
# This may happen when device is unplugged (not always though)
raise e
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# Descriptor parser for USB devices
#
# Related Documentation:
# - https://docs.circuitpython.org/en/latest/shared-bindings/usb/core/index.html
#
from usb import core
from usb.core import USBError, USBTimeoutError
import adafruit_logging as logging
# Configure logging
logger = logging.getLogger('usb_descriptor')
logger.setLevel(logging.DEBUG)
def get_desc(device, desc_type, length=256):
# Read USB descriptor of type specified by desc_type (index always 0).
# - device: a usb.core.Device
# - desc_type: uint8 value for the descriptor type field of wValue
# - returns: bytearray with results from ctrl_transfer()
# Exceptions: may raise USBError or USBTimeoutError
data = bytearray(length)
bmRequestType = 0x80
wValue = (desc_type << 8) | 0
wIndex = 0
device.ctrl_transfer(bmRequestType, 6, wValue, wIndex, data, 300)
return data
def split_desc(data):
# Split a combined descriptor into its individual sub-descriptors
# - data: a bytearray of descriptor data from ctrl_transfer()
# - returns: array of bytearrays (first byte of each is length)
slices = []
cursor = 0
limit = len(data)
data_mv = memoryview(data) # use memoryview to reduce heap allocations
for i in range(limit):
if cursor == limit:
break
length = data[cursor]
if length == 0:
break
if cursor + length > limit:
logger.error('Bad descriptor length: data[%d]=%d' % (i, length))
break
slices.append(data_mv[cursor:cursor+length])
cursor += length
return slices
class ConfigDesc:
def __init__(self, d):
# Parse a configuration descriptor
# - d: bytearray containing a 9 byte configuration descriptor
if len(d) != 9 or d[0] != 0x09 or d[1] != 0x02:
raise ValueError("Bad configuration descriptor")
self.bConfigurationValue = d[5] # for set_configuration()
self.bMaxPower = d[8] # units are 2 mA
def __str__(self):
return ' Config %d: %d mA' % (
self.bConfigurationValue, self.bMaxPower * 2)
class InterfaceDesc:
def __init__(self, d):
# Parse an interface descriptor
# - d: bytearray containing a 9 byte interface descriptor
if len(d) != 9 or d[0] != 0x09 or d[1] != 0x04:
raise ValueError("Bad interface descriptor")
self.bInterfaceNumber = d[2]
self.bNumEndpoints = d[4]
self.bInterfaceClass = d[5]
self.bInterfaceSubClass = d[6]
self.bInterfaceProtocol = d[7]
self.endpoint = []
def add_endpoint_descriptor(self, data):
self.endpoint.append(EndpointDesc(data))
def __str__(self):
fmt = (' Interface %d: '
'(class, subclass, proto): (0x%02x, 0x%02x, 0x%02x)')
chunks = [fmt % (
self.bInterfaceNumber,
self.bInterfaceClass,
self.bInterfaceSubClass,
self.bInterfaceProtocol)]
for e in self.endpoint:
chunks.append(str(e))
return '\n'.join(chunks)
class EndpointDesc:
def __init__(self, d):
# Parse an endpoint descriptor
# - d: bytearray containing a 7 byte endpoint descriptor
if len(d) != 7 or d[0] != 0x07 or d[1] != 0x05:
raise ValueError("Bad endpoint descriptor")
self.bEndpointAddress = d[2]
self.wMaxPacketSize = (d[5] << 8) | d[4]
self.bInterval = d[6]
def __str__(self):
return ' Endpoint 0x%02x: maxPacket: %d, interval: %d' % (
self.bEndpointAddress,
self.wMaxPacketSize,
self.bInterval)
class Descriptor:
def __init__(self, device):
# Read and parse USB device descriptor
# - device: usb.core.Device
#
device_desc = get_desc(device, 0x01, length=18)
length = device_desc[0]
if length != 18:
raise ValueError('Bad Device Descriptor Length: %d' % length)
d = device_desc
self.device_desc_bytes = d
self.bcdUSB = (d[ 3] << 8) | d[ 2]
self.bDeviceClass = d[4]
self.bDeviceSubClass = d[5]
self.bDeviceProtocol = d[6]
self.idVendor = (d[ 9] << 8) | d[ 8]
self.idProduct = (d[11] << 8) | d[10]
# Make an empty placeholder configuration
self.config_desc_list = []
self.configs = []
self.interfaces = []
def vid_pid(self):
return (self.idVendor, self.idProduct)
def dev_class_subclass_protocol(self):
# Get device descriptor;s class, subclass, and protocol
return (self.bDeviceClass, self.bDeviceSubClass, self.bDeviceProtocol)
def int0_class_subclass_protocol(self):
# Get Interface 0 descriptor's class, subclass, and protocol
for i in self.interfaces:
if i.bInterfaceNumber == 0:
return (
i.bInterfaceClass,
i.bInterfaceSubClass,
i.bInterfaceProtocol)
return (None, None, None)
def int0_output_endpoints(self):
# Get list of output endpoints for interface 0
arr = []
input_mask = 0x80
for i in self.interfaces:
if i.bInterfaceNumber == 0:
for e in i.endpoint:
if not (e.bEndpointAddress & input_mask):
arr.append(e)
return arr
def int0_input_endpoints(self):
# Get list of input endpoints for interface 0
arr = []
input_mask = 0x80
for i in self.interfaces:
if i.bInterfaceNumber == 0:
for e in i.endpoint:
if (e.bEndpointAddress & input_mask):
arr.append(e)
return arr
def read_configuration(self, device):
# Read and parse USB configuration descriptor
# - device: usb.core.Device
config_desc_list = split_desc(get_desc(device, 0x02, length=256))
if len(config_desc_list) == 0:
raise ValueError("Empty Configuration Descriptor")
self.config_desc_list = config_desc_list
self.configs = []
self.interfaces = []
interface_num = -1
for d in config_desc_list:
if len(d) < 2:
continue
bLength = d[0]
bDescriptorType = d[1]
tag = (bLength << 8) | bDescriptorType
if tag == 0x0902:
self.configs.append(ConfigDesc(d))
elif tag == 0x0904:
self.interfaces.append(InterfaceDesc(d))
interface_num += 1
elif tag == 0x0705:
if interface_num >= 0:
self.interfaces[interface_num].add_endpoint_descriptor(d)
else:
raise ValueError("Found endpoint before interface")
def to_bytes(self):
return self.device_desc_bytes
def __str__(self):
fmt = """Descriptor:
vid:pid: %04x:%04x
bcdUSB: 0x%04x
(class, subclass, proto): (0x%02x, 0x%02x, 0x%02x)"""
chunks = [fmt % (
self.idVendor,
self.idProduct,
self.bcdUSB,
self.bDeviceClass,
self.bDeviceSubClass,
self.bDeviceProtocol)]
for lst in [self.configs, self.interfaces]:
for item in lst:
chunks.append(str(item))
return "\n".join(chunks)
Usage
- Connect your board's DVI output into an HDMI monitor or USB capture card (EVGA XR1 Lite works)
- Connect your board to a computer over USB and use a serial monitor to connect to the board's USB serial port.
- Connect a USB input device to one of your board's USB host ports and mash some buttons.
- Unplug the device. If the board doesn't show the "Scanning USB bus..." message, it may not have noticed the unplug event. In that case, press Button 1 (or the boot button) to begin a new scan.
This page (Fruit Jam Gamepad Tester) was last updated on May 20, 2025.