This demonstrates a visual USB gamepad tester using using an Adafruit USB Host FeatherWing, an ESP32-S3 TFT Feather, and sprites created in Krita.
Tools and Consumables
Pinouts
| TFT feather | USB Host | ST7789 TFT |
|---|---|---|
| SCK | SCK | |
| MOSI | MOSI | |
| MISO | MISO | |
| D9 | IRQ | |
| D10 | CS | |
| TFT_CS | CS | |
| TFT_DC | DC |
Assemble the Hardware
If you are unfamiliar with soldering headers, you might want to read:
Order of Soldering
The TFT Feather and USB Host Featherwing each come with two strips of 16-position male header. Since feather boards have 16 holes on one side and 12 holes on the other, use your flush cutters to trim 4 pins off the header strips for the 12-hole sides.
Assemble the USB Host Featherwing with pin headers on a breadboard, then solder the headers in place.
Locate the set of regular (non-stacking) female headers from your Featherwing Doubler kit. Remove the USB host featherwing from the breadboard, then put female headers onto the pins of the USB host featherwing.
Using the USB host featherwing to hold the female headers in place, put the female header pins into one of the silkscreened Feather footprints of the Doubler. Tape the ends of the USB host featherwing to the Doubler, being careful not to cover any of the pins.
Clamp the Doubler in a vise and solder the female headers in place.
Locate the set of female stacking headers from your Doubler kit. Remove the USB host featherwing from the Doubler, then put the stacking headers onto the pins of the USB host board.
Put the stacking header pins into the other silkscreen footprint of your Doubler board, then prepare the assembly as before with tape and a vise.
Solder the stacking header pins in place, then trim the excess length off the pins with your flush cutters.
Carefully assemble your ESP32-S3 TFT Feather with header pins on a breadboard. Leave the protective film in place to protect the display from flux splatter. Solder the header pins in place. You can use the solder wire to bend the pull tab of the protective film out of the way so it does not touch your soldering iron.
Smoke Test and Final Assembly
(optional) Mount your Doubler board on a backplate (such as a Tamiya Universal Plate) so the board is easier to handle without shorts or static discharges.
Assemble the TFT feather and USB host featherwing onto the Doubler.
Try plugging your board into a USB charger to make sure the LEDs light up.
If the LEDs light up, unplug the USB power cable, then plug the USB gamepad into the Host featherwing's USB A port.
Updating CircuitPython
NOTE: To update CircuitPython on the ESP32-S3 TFT Feather with 2MB PSRAM and 4MB Flash, you need to use the .BIN file (combination bootloader and CircuitPython core)
Download the CircuitPython 9.1.1 .BIN file from the Feather ESP32-S3 TFT PSRAM page on circuitpython.org
Follow the instructions in the Web Serial ESPTool section of the "CircuitPython on ESP32 Quick Start" learn guide to update your board with CircuitPython 9.1.1. First erasing the board's contents, then programming it with the .BIN file. (CAUTION: the normal UF2 file method does not work on this board because it does not have a large enough flash drive to hold the CircuitPython UF2 file)
Installing CircuitPython Code
To copy the project bundle files to your CIRCUITPY drive:
Download the project bundle .zip file using the Download Project Bundle button below.
Expand the zip file by opening it, or use
unzipin a Terminal. You should end up with a folder named prox-sensor-encoder-menu, which should contain aREADME.txtfile and aCircuitPython 9.xfolder.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.
Running the Code
Connect the USB gamepad to the MAX3421E USB Host Featherwing before applying power to the Feather TFT. (USB hot plugging may be unreliable)
Plug a computer or charger into the Feather TFT ESP32-S3 USB C port. You should see some boot messages followed by a pixel art gamepad with gray buttons.
When you press buttons on the USB gamepad, you should see the corresponding button sprites on the Feather TFT change from gray to white.
CAUTION: This code was tested with an 8BitDo SN30 Pro USB wired gamepad, which uses the XInput protocol and identifies itself with the vendor and product IDs of an Xbox 360 gamepad (045e:028e). This code may not work properly with other gamepads.
If you want to see debug messages about gamepad connection status, you can connect to the serial console the Feather TFT's serial port.
Understanding How the Code Works
The main() function of code.py is divided into three sections:
Initialize the TFT display's ST7789 controller and set up a
displayio.TileGridobject to draw the gamepad image using sprites.Initialize the MAX3421E USB host controller.
Start the
while True: ...event loop that connects to a USB gamepad, reads input events from the gamepad, and updates the display as needed.
For details on how the gamepad driver works, take a look at gamepad.py.
Important Concept #1: Bitfields and Digital Logic
The method used by the update_GUI(scene, prev, buttons) function of code.py
to decode button presses might be a bit mysterious if you are unfamiliar with
the concepts of bitfields and
digital logic
There's a whole lot of background material you could read about if you wanted to. But, short version, understanding a few simple techniques makes it possible to work with bitfields efficiently:
-
What is a bitfield? Suppose you have 8 buttons that can each have a value of pressed (1) or not pressed (0). You could store each value as its own integer, but that would use many more bytes than necessary. To save space, you could instead treat one 8-bit integer as a bitfield, assigning each of its eight bits to represent a different button.
For example, maybe button A is the least significant bit. When only button A is pressed, the integer's value would be 1 (decimal), 0x01 (hex), or 0b00000001 (binary). Maybe you assign button B to the second least significant bit. In that case, if only button B is pressed, the integer's value would be 2 (decimal), 0x02 (hex), or 0b00000010 (binary). If you pressed buttons A and B at the same time, the value would be 3, 0x03, or 0b00000011.
-
To detect when one or more of the bits in a bitfield have changed, you can write a loop to track the previous and current values of a bitfield, then compare the previous and current values in an if statement, like this:
prev = thing.get_bitfield() while True: bits = thing.get_bitfield() if prev != bits: # check if any bits changed do_stuff(bits) # do what needs doing when a bit has changed prev = bits # update the previous valueA variation on this technique is used by
gamepad.XInputGamepad.poll()to calculate the value that it returns forchanged. -
Once you know that one or more bits have changed, you can efficiently decode the bitfield using the XOR (
^in python) and AND (&in Python) bitwise digital logic operators.If you want to understand why this works, you could try reading about digital logic and experimenting in a Python REPL. For the how part, you can use code like this to identify which bits changed and then do some appropriate action based on the current value:
def update_GUI(scene, prev, buttons): # These are bitmask constants mapping buttons to their # corresponding bit (each of these is a 16-bit integer where # only 1 bit is set) B = 0x1000 A = 0x2000 Y = 0x4000 X = 0x8000 # Use XOR (^) to identify the specific bits that changed # (try `"0b{:03b}".format(0b111 ^ 0b101)` in a Python REPL) diff = prev ^ buttons # Use AND (&) to calculate a boolean value that is True when a # specific button has changed (`diff & A`, `diff & B`, etc), or # when a button is currently pressed (`buttons & A`, # `buttons & B`, etc) 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
Important Concept #2: Sprite Sheets and TileGrid
Many people have written a whole lot of stuff about using sprites to make graphics for old video game consoles, emulators, fantasy consoles, and so on. That stuff is interesting and worth reading if you have the time. The short, drastically simplified version is there are two main approaches to drawing computer graphics:
Make images where you set colors for each display pixel individually (good for realistic looking images, but uses lots of RAM and clock cycles)
Make images in the style of a mosaic, where you set groups of pixels at the same time using patterns (sprites or tiles) from a spritesheet (works on hardware with limited RAM and clock speed)
This image shows two different magnified views of the spritesheet I made in Krita for my gamepad tester:
The top left portion came from a Krita screenshot with 800% zoom and 8px grid
divisions. The spritesheet has 28 sprites arranged into 4 rows of 7 columns
each. The sprites are 8 pixels wide by 8 pixels high. The bottom right
portion shows the same spritesheet overlaid with numbers that match the
numbering system used by displayio.TileGrid.
In Krita, I didn't do anything special to manage the color palette other than just making a swatch in the "Palette" Docker (Krita jargon, not to be confused with the container management software). For the swatch, I used white, black, gray, and five shades of blue (same hue with gradient of values). For the brush, I used the brush preset called "Pixel Art" set to a size of 1px. This is what my Palette and Brush Preset dockers looked like:
These are the settings from my "Grid and Guides" docker that I used to make the 8px grid overlay:
After exporting to PNG from Krita, I converted my spritesheet to BMP using the GraphicsMagick gm convert terminal shell command, like this:
gm convert gamepad_7x4_8px_sprites.png BMP3:sprites.bmp
This is how I taught TileGrid to make a full gamepad image (5 rows tall by 10 columns wide) by assembling a grid of sprites:
# 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)
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 = Group(scale=3) # 3x zoom
grp.append(scene)
display.root_group = grp
For animating buttons to change from gray to white, update_GUI() changes the
TileGrid object called scene by setting new sprite numbers at the x,y
coordinates for the various buttons.
For example, this code excerpt from update_GUI() sets the tile at coordinate
(8,2) to sprite 15 or sprite 17 depending on whether button A is pressed:
diff = prev ^ buttons
if diff & A:
scene[8, 2] = 15 if (buttons & A) else 17
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# feather-tft-gamepad
#
# Hardware:
# - Adafruit ESP32-S3 TFT Feather - 4MB Flash, 2MB PSRAM (#5483)
# - Adafruit USB Host FeatherWing with MAX3421E (#5858)
# - 8BitDo SN30 Pro USB gamepad
#
# Pinouts:
# | TFT feather | USB Host | ST7789 TFT |
# | ----------- | -------- | ---------- |
# | SCK | SCK | |
# | MOSI | MOSI | |
# | MISO | MISO | |
# | D9 | IRQ | |
# | D10 | CS | |
# | TFT_CS | | CS |
# | TFT_DC | | DC |
#
# Related Documentation:
# - https://learn.adafruit.com/adafruit-esp32-s3-tft-feather
# - https://learn.adafruit.com/adafruit-1-14-240x135-color-tft-breakout
# - https://learn.adafruit.com/adafruit-usb-host-featherwing-with-max3421e
#
from board import D9, D10, SPI, TFT_CS, TFT_DC
from digitalio import DigitalInOut, Direction
from displayio import (Bitmap, Group, OnDiskBitmap, Palette, TileGrid,
release_displays)
from fourwire import FourWire
import gc
from max3421e import Max3421E
from time import sleep
from usb.core import USBError
import adafruit_imageload
from adafruit_st7789 import ST7789
from gamepad import (
XInputGamepad, UP, DOWN, LEFT, RIGHT, START, SELECT, L, R, A, B, X, Y)
def update_GUI(scene, prev, buttons):
# 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
#
diff = prev ^ buttons
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
#print(f"{buttons:016b}")
def main():
release_displays()
gc.collect()
spi = SPI()
# Initialize display
bus = FourWire(spi, command=TFT_DC, chip_select=TFT_CS)
display = ST7789(bus, rotation=270, width=240, height=135, rowstart=40,
colstart=53, auto_refresh=False)
gc.collect()
# 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)
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 = Group(scale=3) # 3x zoom
grp.append(scene)
display.root_group = grp
display.refresh()
# Initialize MAX3421E USB host chip which is needed by usb.core.
# The link between usb.core and Max3421E happens by way of invisible
# magic in the CircuitPython core, kinda like with displayio displays.
print("Initializing USB host port...")
usbHost = Max3421E(spi, chip_select=D10, irq=D9)
sleep(0.1)
# MAIN EVENT LOOP
# Establish and maintain a gamepad connection
gp = XInputGamepad()
print("Looking for USB gamepad...")
while True:
gc.collect()
try:
if gp.find_and_configure(retries=25):
# Found a gamepad, so configure it and start polling
print(gp.device_info_str())
connected = True
prev = 0
while connected:
(connected, changed, buttons) = gp.poll()
if connected and changed:
update_GUI(scene, prev, buttons)
display.refresh()
prev = buttons
sleep(0.002)
gc.collect()
# If loop stopped, gamepad connection was lost
print("Gamepad disconnected")
print("Looking for USB gamepad...")
else:
# No connection yet, so sleep briefly then try again
sleep(0.1)
except USBError as e:
# This might mean gamepad was unplugged, or maybe some other
# low-level USB thing happened which this driver does not yet
# know how to deal with. So, log the error and keep going
print(e)
print("Gamepad connection error")
print("Looking for USB gamepad...")
main()
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: Copyright 2024 Sam Blenny
#
# Gamepad driver for XInput compatible USB wired gamepad with MAX421E.
#
# The button names used here match the Nintendo SNES style button
# cluster layout, but the USB IDs and protocol match the Xbox 360 USB
# wired controller. This is meant to work with widely available USB
# wired xinput compatible gamepads for the retrogaming market. In
# particular, I tested this package using my 8BitDo SN30 Pro USB wired
# gamepad.
#
# CAUTION: If you try to use a USB adapter with a wireless xinput
# compatible gamepad, it probably won't work with this driver in its
# current form. In my testing, compared to wired gamepads, USB wireless
# adapters have extra delays and errors that require retries and
# low-level error handling. I haven't been able to get a USB wireless
# gamepad adapter working yet in CircuitPython yet.
#
from time import sleep
from struct import unpack
from usb import core
from usb.core import USBError
# Gamepad button bitmask constants
UP = 0x0001 # dpad: Up
DOWN = 0x0002 # dpad: Down
LEFT = 0x0004 # dpad: Left
RIGHT = 0x0008 # dpad: Right
START = 0x0010
SELECT = 0x0020
L = 0x0100 # Left shoulder button
R = 0x0200 # Right shoulder button
B = 0x1000 # button cluster: bottom button (Nintendo B, Xbox A)
A = 0x2000 # button cluster: right button (Nintendo A, Xbox B)
Y = 0x4000 # button cluster: left button (Nintendo Y, Xbox X)
X = 0x8000 # button cluster: top button (Nintendo X, Xbox Y)
class XInputGamepad:
def __init__(self):
# Initialize buffers used in polling USB gamepad events
self._prev = 0
self.buf64 = bytearray(64)
# Variable to hold the gamepad's usb.core.Device object
self.device = None
def find_and_configure(self, retries=25):
# Connect to a USB wired Xbox 360 style gamepad (vid:pid=045e:028e)
#
# retries: max number of attempts to find device (100ms retry interval)
#
# Returns: True = success, False = device not found or config failed
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
for _ in range(retries):
device = core.find(idVendor=0x045e, idProduct=0x028e)
if device:
sleep(0.1)
self._configure(device) # may raise usb.core.USBError
return True # end retry loop
else:
sleep(0.1)
# Reaching this point means no matching USB gamepad was found
self._reset()
return False
def _configure(self, device):
# Prepare USB gamepad for use (set configuration, drain buffer, etc)
#
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
interface = 0
timeout_ms = 5
try:
# Make sure CircuitPython core is not claiming the device
if device.is_kernel_driver_active(interface):
device.detach_kernel_driver(interface)
# Make sure that configuration is set
device.set_configuration()
except USBError as e:
print("[E1]: '%s', %s, '%s'" % (e, type(e), e.errno))
self._reset()
raise e
# Initial reads may give old data, so drain gamepad's buffer. This
# may raise an exception (with no string description nor errno!)
# when buffer is already empty. If that happens, ignore it.
try:
sleep(0.1)
for _ in range(8):
__ = device.read(0x81, self.buf64, timeout=timeout_ms)
self._prev = 0
except USBError as e:
if e.errno is None:
pass # this is okay
else:
print("[E2]: '%s', %s, '%s'" % (e, type(e), e.errno))
self._reset()
raise e
# All good, so save a reference to the device object
self.device = device
def poll(self):
# Poll gamepad for button changes (ignore sticks and triggers)
#
# Returns a tuple of (valid, changed, buttons):
# connected: True if gamepad is still connected, else False
# changed: True if buttons changed since last call, else False
# buttons: Uint16 containing bitfield of individual button values
# Exceptions: may raise usb.core.USBError or usb.core.USBTimeoutError
#
# Expected endpoint 0x81 report format:
# bytes 0,1: prefix that doesn't change [ignored]
# bytes 2,3: button bitfield for dpad, ABXY, etc (uint16)
# byte 4: L2 left trigger (analog uint8) [ignored]
# byte 5: R2 right trigger (analog uint8) [ignored]
# bytes 6,7: LX left stick X axis (int16) [ignored]
# bytes 8,9: LY left stick Y axis (int16) [ignored]
# bytes 10,11: RX right stick X axis (int16) [ignored]
# bytes 12,13: RY right stick Y axis (int16) [ignored]
# bytes 14..19: ???, but they don't change
#
if self.device is None:
# caller is trying to poll when gamepad is not connected
return (False, False, None)
timeout_ms = 5
endpoint = 0x81
try:
# Poll gamepad endpoint to get button and joystick status bytes
n = self.device.read(endpoint, self.buf64, timeout=timeout_ms)
if n < 14:
# skip unexpected responses (too short to be a full report)
return (True, False, None)
# Only bytes 2 and 3 are interesting (ignore sticks/triggers)
(buttons,) = unpack('<H', self.buf64[2:4])
if buttons != self._prev:
# button state has changed since previous polling
self._prev = buttons
return (True, True, buttons)
else:
# button state is the same as it was last time
return (True, False, buttons)
except USBError as e:
print("[E3]: '%s', %s, '%s'" % (e, type(e), e.errno))
self._reset()
raise e
def device_info_str(self):
# Return string describing gamepad device (or lack thereof)
d = self.device
if d is None:
return "[Gamepad not connected]"
(v, pi, pr, m) = (d.idVendor, d.idProduct, d.product, d.manufacturer)
if (v is None) or (pi is None):
# Sometimes the usb.core or Max3421E will return 0000:0000 for
# reasons that I do not understand
return "[bad vid:pid]: vid=%s, pid=%s, prod='%s', mfg='%s'" % (
v, pi, pr, m)
else:
return "Connected: %04x:%04x prod='%s' mfg='%s'" % (v, pi, pr, m)
def _reset(self):
# Reset USB device and gamepad button polling state
self.device = None
self._prev = 0
This page (Feather TFT Gamepad Tester with Sprites) was last updated on August 19, 2024.