This is my third iteration of a CircuitPython USB host gamepad tester, now with support for two controllers. The main loop in code.py uses asyncio to improve code readability. There's a new boot keyboard to gamepad mapper. For hot-swapping controllers, I devised an unplug detection heuristic to work around limitations in the current CircuitPython USB host implementation. Player numbers get assigned according to Fruit Jam top plate silkscreen port numbers. Player 1 gets USB 1, and Player 2 gets USB 2.
This demo project is meant to help folks who want to make Fruit Jam libraries for writing games. I don't plan to make a library on my own, but I wrote this code with library-making in mind in case somebody else wants to. Note that some of the performance and stability workarounds included here may become unnecessary once USB host implementation bugs get fixed.
The code here was written and tested with CircuitPython 10.0.0-beta.2 on a rev D Fruit Jam that I bought from the first production batch that went up in the shop.
Related work:
- Fruit Jam Gamepad Tester guide (my previous gamepad tester)
- Fruit Jam Fruitris (Tetris) game guide by @relic-se
- Feather TFT Gamepad Tester with Sprites guide (my original gamepad tester)
Parts
For the board, you'll need a Fruit Jam, or a Metro RP2350 with breakout boards for HSTX DVI and a USB hub. For game controllers, you can use Adafruit's generic SNES compatible gamepad, XInput style gamepads, Switch Pro compatible gamepads, certain HID gamepads (check the code), and wired USB keyboards that provide an HID boot keyboard interface. Note that USB wireless receivers for mouse and keyboard combos may not work. Also, fancy gaming keyboards with N-key rollover may not work (boot keyboards only support 6-key rollover).
For more information on setting up a Metro RP2350 as a pseudo-Fruit Jam with DVI output and a USB hub, you can read some of the guides by Tim C. and M. LeBlanc-Williams.
Updating CircuitPython
As I write this (August 13, 2025), CircuitPython 10.0.0-beta.2 is the most recent build for Fruit Jam. To keep your board up to date, you can use the "DOWNLOAD .UF2 NOW" buttons on the appropriate download page of circuitpython.org:
- Fruit Jam Download page
- Metro RP2350 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-two-gamepad-demo 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 10.x folder.
- Open the CircuitPython 10.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.
Usage
- Update CircuitPython and install the code as described above
- Connect the Fruit Jam board to an HDMI display or capture card
- Plug the Player 1 controller into the Fruit Jam USB 1 port
- Plug the Player 2 controller into the Fruit Jam USB 2 port
- Mash some buttons
On the DVI display, you should see a gamepad status visualizer for two gamepads. The status line below each gamepad indicates its USB connection status. The button sprites on the on-screen gamepads should change color as you press actual buttons on your controllers.
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
import asyncio
from board import CKP, CKN, D0P, D0N, D1P, D1N, D2P, D2N
import displayio
from displayio import Bitmap, Group, OnDiskBitmap, Palette, TileGrid
import framebufferio
import gc
import picodvi
import supervisor
from terminalio import FONT
import time
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 sb_gamepad import (
find_usb_device, InputDevice,
UP, DOWN, LEFT, RIGHT, START, SELECT, L, R, A, B, X, Y)
def init_display(width, height, color_depth):
# Initialize the picodvi display
# Video mode compatibility:
# | Video Mode | Fruit Jam | Metro RP2350 No PSRAM |
# | -------------- | --------- | ------------------------ |
# | (320, 240, 8) | Yes! | Yes! |
# | (320, 240, 16) | Yes! | Yes! |
# | (320, 240, 32) | Yes! | MemoryError exception :( |
# | (640, 480, 8) | Yes! | MemoryError exception :( |
displayio.release_displays()
gc.collect()
fb = picodvi.Framebuffer(width, height, 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=color_depth)
display = framebufferio.FramebufferDisplay(fb)
supervisor.runtime.display = display
return display
class GamepadVisualizer:
def __init__(self, display, group):
# load spritesheet and palette
(bitmap, palette) = adafruit_imageload.load("sprites2x.bmp",
bitmap=Bitmap, palette=Palette)
# assemble TileGrid with gamepad using sprites from the spritesheet
scene_1 = TileGrid(bitmap, pixel_shader=palette, width=10, height=5,
tile_width=16, tile_height=16, default_tile=9, x=16, y=16)
scene_2 = TileGrid(bitmap, pixel_shader=palette, width=10, height=5,
tile_width=16, tile_height=16, default_tile=9, x=142, y=128)
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_1[x, y] = sprite
scene_2[x, y] = sprite
group.append(scene_1)
group.append(scene_2)
# Make a text label for status messages
status_1 = bitmap_label.Label(FONT, text="", color=0xFFFFFF, scale=1)
status_1.anchor_point = (0, 0)
status_1.anchored_position = (22, 100)
group.append(status_1)
# Make a separate text label for input event report data
status_2 = bitmap_label.Label(FONT, text="", color=0xFFFFFF, scale=1)
status_2.anchor_point = (0, 0)
status_2.anchored_position = (148, 212)
group.append(status_2)
self.display = display
self.group = group
self.bitmap = bitmap
self.palette = palette
self.scene_1 = scene_1
self.scene_2 = scene_2
self.tilemap = tilemap
self.status_1 = status_1
self.status_2 = status_2
def input_event(self, buttons, diff, player=1):
# 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 player == 2:
scene = self.scene_2
else:
scene = self.scene_1
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
self.display.refresh()
def set_status(self, msg, player=1):
# Status label updater
if player == 2:
self.status_2.text = msg
else:
self.status_1.text = msg
self.display.refresh()
async def gamepad_loop(gpviz, player=1):
# Find a gamepad, poll for input, dispatch events to visualizer.
# - gpviz: a GamepadVisualizer instance to receive input events
# - player: 1 or 2 (which usb port to use)
while True:
if player == 1:
gc.collect()
gpviz.set_status("Player %d: [No Controller]" % player, player=player)
await asyncio.sleep(0.002)
try:
dev = find_usb_device(player=player)
if dev is None:
# No connection yet, so sleep briefly then try the find again
await asyncio.sleep(0.4)
continue
# Found an input device, so update display with device info
info = dev.tag if dev.tag else "%04X:%04X" % (dev.vid, dev.pid)
gpviz.set_status("Player %d: %s" % (player, info), player=player)
# Poll for input events until USB exception (device unplug)
prev = 0
for data in dev.input_event_generator():
if data is None:
# This means polling was rate limited or USB timed out
await asyncio.sleep(0.001)
continue
# At this point, data should be a uint16 bitfield
diff = prev ^ data
prev = data
gpviz.input_event(data, diff, player=player)
except USBError as e:
# This sometimes happens when devices are unplugged.
print("USBError:", e)
except USBTimeoutError as e:
# This sometimes happens when devices are unplugged.
print("USBTimeoutError:", e)
except ValueError as e:
# This can happen if an initialization handshake glitches
print("ValueError:", e)
async def main():
# Configure display with requested picodvi video mode
display = init_display(320, 240, 16)
display.auto_refresh = False
group = Group(scale=1) # 2x zoom
display.root_group = group
# This manages all the graphics stuff for the gamepad visualizers
gpviz = GamepadVisualizer(display, group)
# Start the 2-player input event loops
await asyncio.gather(
asyncio.create_task(gamepad_loop(gpviz, player=1)),
asyncio.create_task(gamepad_loop(gpviz, player=2)),
)
asyncio.run(main())
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2025 Sam Blenny
#
# Gamepad driver for various USB wired gamepads.
#
# Related docs:
# - 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 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 sb_usb_descriptor
# 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_KEYBOARD = const(5)
TYPE_POWERA_WIRED = const(6) # 20d6:a711 PowerA Wired Controller (for Switch)
# As of CircuitPython 10.0.0-beta.2, there's not a good way to tell if a device
# has been unplugged. The best we can do is count consecutive timouts during
# calls to usb.core.Device.read() and guess that too many of them means the
# device was unplugged.
TOO_MANY_GAMEPAD_TIMEOUTS = const(99)
TOO_MANY_KEYBOARD_TIMEOUTS = const(9999)
def find_usb_device(player=None):
# Find a USB wired gamepad by inspecting usb device descriptors
# - player: can be None, 1, or 2. None finds on all USB ports. 1 finds on
# the root port or port (1,). 2 finds on port (2,).
# - return: ScanResult object for success or None for failure.
# Exceptions: may raise USBError, USBTimeoutError, ValueError
#
for device in core.find(find_all=True):
# Player number filter
pn = device.port_numbers
if player == 1 and (pn is not None) and (pn != (1,)):
# Board has USB hub, but device is not plugged into port 1. That
# won't work for Player 1, so skip it.
continue
if player == 2 and ((pn is None) or (pn != (2,))):
# Board doesn't have a USB hub, or it has a hub but the device is
# not plugged into port 2. Won't work for Player 2, so skip it.
continue
# Read descriptor to identify device by vid:pid or class:subclass
desc = sb_usb_descriptor.Descriptor(device)
# Check for an all zeros descriptor. As of CircuitPython 10.0.0-beta.2,
# there's a bug where unplugging a device can cause usb.core.find() to
# always generate a device with an invalid descriptor. If that happens,
# bail out.
desc_bytes = desc.to_bytes()
if all((byte_ == 0 for byte_ in desc_bytes)):
raise ValueError("usb.core.find() returned all-zeros descriptor")
# 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
d = desc.dev_class_subclass() # device (class, subclass)
i0 = desc.int_class_subclass(0) # interface 0 (class, subclass)
d_i0 = d + i0 # both of them in one tuple
dev = device
# Decide if this device is one of the gamepads we have a driver for. If
# so, the loop ends here. If not, the loop continues to see if there
# are other supported devices.
if (vid, pid) == (0x057e, 0x2009):
return InputDevice(dev, TYPE_SWITCH_PRO, 'SwitchPro', desc)
elif (vid, pid) == (0x081f, 0xe401):
# Generic SNES layout HID gamepad sold by Adafruit
return InputDevice(dev, TYPE_ADAFRUIT_SNES, 'AdafruitSNES', desc)
elif (vid, pid) == (0x2dc8, 0x9018):
# This one is HID but quirky, so it needs special handling
return InputDevice(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 InputDevice(dev, TYPE_POWERA_WIRED, 'PowerAWired', desc)
elif d_i0 == (0xff, 0xff, 0xff, 0x5d):
return InputDevice(dev, TYPE_XINPUT, 'XInput', desc)
elif d_i0 == (0x00, 0x00, 0x03, 0x01):
return InputDevice(dev, TYPE_BOOT_KEYBOARD, 'BootKeyboard', desc)
else:
# Ignore unknown devices
continue
return None
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, device, dev_type, tag, descriptor):
# Initialize buffers used in polling USB gamepad events
# - scan_result: a ScanResult instance
# Exceptions: may raise usb.core.USBError
#
self._prev = 0
self.buf64 = bytearray(64)
self.device = device
self.dev_type = dev_type
self.tag = tag
self.vid = descriptor.idVendor
self.pid = descriptor.idProduct
self.dev_info = descriptor.dev_class_subclass()
self.int0_info = descriptor.int_class_subclass(0)
self.player = 2 if (device.port_numbers == (2,)) else 1
# Make sure CircuitPython core is not claiming the device
interface = 0
if device.is_kernel_driver_active(interface):
device.detach_kernel_driver(interface)
# Set configuration
device.set_configuration(interface)
# Figure out which endpoints to use
int0_ins = descriptor.input_endpoints(interface)
int0_outs = descriptor.output_endpoints(interface)
endpoint_in = None if (len(int0_ins) < 1) else int0_ins[0]
endpoint_out = None if (len(int0_outs) < 1) else int0_outs[0]
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(self.player)
elif dev_type == TYPE_ADAFRUIT_SNES:
pass
elif dev_type == TYPE_8BITDO_ZERO2:
pass
elif dev_type == TYPE_POWERA_WIRED:
pass
elif dev_type == TYPE_XINPUT:
self.init_xinput(self.player)
elif dev_type == TYPE_BOOT_KEYBOARD:
pass
else:
raise ValueError('Unknown dev_type: %d' % dev_type)
def init_switch_pro_gamepad(self, player=1):
# Prepare Switch Pro compatible gamepad for use.
# Exceptions: may raise usb.core.USBError and usb.core.USBTimeoutError
#
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)
# Pick LED byte, default is 1 LED lit for player 1
leds = bytes(b'\x01\x0a\x00\x00\x00\x00\x00\x00\x00\x00\x30\x01')
if player == 2:
leds = bytes(b'\x01\x0a\x00\x00\x00\x00\x00\x00\x00\x00\x30\x03')
# Build handshake bytes
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 LEDs to on (for LED1+LED2 do 30 03, etc.)
leds,
# set home LED
bytes(b'\x01\x0b\x00\x00\x00\x00\x00\x00\x00\x00\x38\x01\x00\x00\x11\x11'),
)
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)
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, player=1):
# Prepare XInput gamepad for use.
# Exceptions: may raise USBError
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
if player == 2:
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: (2 possibilities)
# 1. Normalized 16-bit integer with XInput style button bitfield
# 2. None in the case of a timeout or rate limit throttle
# Exceptions: may raise USBError, USBTimeoutError
#
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_KEYBOARD:
# Keyboard to gamepad mapping using US QWERTY layout boot keyboard:
# WASD => d-pad up, left, down, right
# arrows => alternate d-pad up, left, down, right
# ZXCV => ABXY cluster buttons (SNES style, A on the right)
# Space => alternate A
# QE => L and R shoulder buttons
# Enter => Start
# Esc => Select
def normalize_boot_keyboard(data):
for d in data:
if d is None:
yield None
continue
v = 0
# This is a keyscan code decoder that totally ignores all
# the modifier keys. For d-pad conflicts, up and left take
# priority over down and right.
codes = (d[2], d[3], d[4], d[5], d[6], d[7])
if 0x1a in codes or 0x52 in codes: # W, up-arrow
v |= UP
elif 0x16 in codes or 0x51 in codes: # S, down-arrow
v |= DOWN
if 0x04 in codes or 0x50 in codes: # A, left-arrow
v |= LEFT
elif 0x07 in codes or 0x4f in codes: # D, right-arrow
v |= RIGHT
if 0x1d in codes or 0x2c in codes: # Z, spacebar
v |= A
if 0x1b in codes: # X
v |= B
if 0x06 in codes: # C
v |= X
if 0x19 in codes: # V
v |= Y
if 0x14 in codes: # Q
v |= L
if 0x08 in codes: # E
v |= R
if 0x28 in codes: # Enter
v |= START
if 0x29 in codes: # Esc
v |= SELECT
yield v
return normalize_boot_keyboard(int0_gen())
else:
# Ignore any other devices
return
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, USBTimeoutError
#
# 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_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
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
# Counter and max consecutive timeouts limit for guessing when the USB
# device has been unplugged
timeouts = 0
max_timeouts = TOO_MANY_GAMEPAD_TIMEOUTS
if self.dev_type == TYPE_BOOT_KEYBOARD:
max_timeouts = TOO_MANY_KEYBOARD_TIMEOUTS
# 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])
timeouts = 0
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])
timeouts = 0
if (report is None) or (report == prev_report):
yield None
else:
prev_report = report
odd = True
yield report
except USBTimeoutError as e:
# This might be okay. Timeouts happen often for some gamepads
# and quite a lot (no key pressed) for boot keyboards.
timeouts += 1
if timeouts > max_timeouts:
# Too many consecutive timeouts; assume device is unplugged
raise e
else:
# Nothing to worry about yet
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
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:
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.bNumInterfaces = d[4]
self.bConfigurationValue = d[5] # for set_configuration()
self.bMaxPower = d[8] # units are 2 mA
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))
class EndpointDesc:
def __init__(self, d):
# Parse an endpoint descriptor
# - d: bytearray containing a 7-9 byte endpoint descriptor
if len(d) < 7 or d[0] < 0x07 or d[1] != 0x05:
raise ValueError("Bad endpoint descriptor")
self.bEndpointAddress = d[2]
# bmAttributes low 2 bits: 0:control, 1:iso., 2:bulk, 3:interrupt
self.bmAttributes = d[3]
self.wMaxPacketSize = (d[5] << 8) | d[4]
self.bInterval = d[6]
def attribute_str(self):
a = self.bmAttributes & 0x3
if a == 0:
return 'control'
elif a == 1:
return 'iso'
elif a == 2:
return 'bulk'
elif a == 3:
return 'interrupt'
return ''
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.bMaxPacketSize0 = d[7]
self.idVendor = (d[ 9] << 8) | d[ 8]
self.idProduct = (d[11] << 8) | d[10]
self.bNumConfigurations = d[17]
# 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(self):
# Get device descriptor's class and subclass
return (self.bDeviceClass, self.bDeviceSubClass)
def int_class_subclass(self, interface):
# Get requested interface descriptor's class and subclass
for i in self.interfaces:
if i.bInterfaceNumber == interface:
return (i.bInterfaceClass, i.bInterfaceSubClass)
return (None, None)
def output_endpoints(self, interface):
# Get list of output endpoints for requested interface
arr = []
input_mask = 0x80
for i in self.interfaces:
if i.bInterfaceNumber == interface:
for e in i.endpoint:
if not (e.bEndpointAddress & input_mask):
arr.append(e)
return arr
def input_endpoints(self, interface):
# Get list of input endpoints for interface 0
arr = []
input_mask = 0x80
for i in self.interfaces:
if i.bInterfaceNumber == interface:
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:
# Configuration
self.configs.append(ConfigDesc(d))
elif tag == 0x0904:
# Interface
self.interfaces.append(InterfaceDesc(d))
interface_num += 1
elif 7 <= bLength <= 9 and bDescriptorType == 0x05:
# Endpoint
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
This page (Fruit Jam Two Gamepad Demo) was last updated on August 13, 2025.
Text editor powered by tinymce.