Status: validated live (ticket 2150, 2026-08-17) on a Sony DualShock 4
($054C:$09CC, standard HID, kind=$05, 7 axes, 14 buttons) sitting on the
second host channel while a keyboard/mouse receiver used the first.
int_in is a boolean — joy_poll takes the payload size from
last_rx_len(), and bind does not pin the kbd/mouse hid_bus.
What you need
- A P2 board with the USB serial-host header (base pin 16 assumed below)
- A powered hub or direct connection, and any class-compliant USB gamepad or joystick (virtually every commodity pad; PS/Xbox pads work in their standard-HID modes, not their vendor modes)
- Build gates:
USB_HID+USB_JOY
pnut-ts -d -I <path-to>/src -D USB_HID -D USB_JOY my_pad.spin2
The smallest program
#IFDEF USB_HID
#PRAGMA EXPORTDEF USB_HID
#ENDIF
#IFDEF USB_JOY
#PRAGMA EXPORTDEF USB_JOY
#ENDIF
CON
_clkfreq = 200_000_000
OBJ
usb : "usb_app"
PUB main() | i
usb.begin(16) ' bring up the whole bus
repeat until usb.joy_present() ' pads may attach late - poll()
usb.poll() ' keeps scanning while you wait
waitms(50)
' -- discover: the pad tells you what it has --
debug("axes=", udec_(usb.joy_axes()), " buttons=", udec_(usb.joy_button_count()))
repeat i from 0 to usb.joy_axes() - 1
debug("axis ", udec_(i), " usage=", uhex_(usb.joy_axis_usage(i)), " range ", sdec_(usb.joy_axis_min(i)), "..", sdec_(usb.joy_axis_max(i)))
' -- read: poll, then use the accessors --
repeat
if usb.joy_poll()
debug("x=", sdec_(usb.joy_axis_scaled(0)), " y=", sdec_(usb.joy_axis_scaled(1)), " btn=", uhex_(usb.joy_buttons()), " hat=", sdec_(usb.joy_hat()))
usb.poll() ' hot-plug stays live
waitms(8)
What bring-up actually does
usb.begin(16)powers the port, starts the one USB cog, resets the bus, and enumerates everything it finds (hub or direct). No fixed delays — readiness is sampled.- When a HID interface with protocol 0 appears, its report
descriptor is fetched and parsed (this is the key step — pads have no
fixed layout; the descriptor says where every control lives). Only a
device whose descriptor declares a joystick/gamepad/multi-axis
collection binds to
joy_*; keyboards and mice are never touched. joy_present()goes true. From here everything is table lookups — the parse happened once, at bind.joy_poll()fetches one report (~1 ms when idle, nothing blocks); thejoy_*accessors read fields out of it.
Recognizing and mapping controls (the wizard)
You never hardcode "button 3 = fire" — you ask the user:
repeat until usb.joy_poll() ' one fresh report first
waitms(8)
usb.joy_mark() ' snapshot the resting state
debug("press the control you want for FIRE")
repeat
if usb.joy_poll()
code := usb.joy_changed() ' first control that moved
if code
quit
waitms(8)
' store `code`; forever after:
fire := usb.joy_control(code) ' 0/1 for buttons, -32768..32767
' for axes, 0..7/-1 for the hat
joy_changed() ignores stick noise (it needs a quarter of the axis span)
and reports buttons, axes, and the hat uniformly. Persist your stored
codes keyed by dev_vid()/dev_pid() and the same pad model maps itself
on every future attach.
Troubleshooting
| Symptom | Likely cause |
|---|---|
joy_present() never true |
Pad is in a vendor mode (PS/Xbox pairing state) — power-cycle it into standard HID; or it's low-speed (ticket 1190, not yet supported). |
| Axes read 0 | Call joy_poll() first — accessors read the last polled report. |
| Wrong stick moves | Nothing's wrong — check joy_axis_usage(i): sticks map X/Y/Z/Rz differently per vendor. That's why the wizard exists. |
Next
- Full worked example (discover → map → play):
examples/top_joy_example.spin2 - API reference: docs/api/usb-app-api.md §5 (gamepad section)
- How it works + v1 caps: docs/design/hid-gamepad-mapping.md