Skip to content

Instantly share code, notes, and snippets.

@Kos
Created March 7, 2026 16:27
Show Gist options
  • Select an option

  • Save Kos/c33b82812a7ae9ced32526e613c5efb9 to your computer and use it in GitHub Desktop.

Select an option

Save Kos/c33b82812a7ae9ced32526e613c5efb9 to your computer and use it in GitHub Desktop.
Pinball controller for Seeed Studio XIAO SAMD21
/*
Pinball controller for Seeed Studio XIAO SAMD21
Pins:
6, 7: flippers
4: plunger
3, 5, 8: extra GND
Upload:
PORT=... # substitute your serial port, e.g. COM11 on windows or /dev/ttyUSB0 on linux
arduino-cli compile *.ino -b Seeeduino:samd:seeed_XIAO_m0:usbstack=tinyusb -v -up $PORT
TODO: arcade button LEDs
*/
#include <Arduino.h>
#include <Adafruit_TinyUSB.h>
#define LEFT_FLIPPER 6
#define RIGHT_FLIPPER 7
#define PLUNGER 4
Adafruit_USBD_HID usb_hid;
// Full 467-byte DS4 HID report descriptor (USB variant)
// https://gimx.fr/wiki/index.php?title=DualShock_4#HID_Report_Descriptor
uint8_t const hid_report_descriptor[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x09, 0x32, // Usage (Z)
0x09, 0x35, // Usage (Rz)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x04, // Report Count (4)
0x81, 0x02, // Input (Data,Var,Abs)
0x09, 0x39, // Usage (Hat switch)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x07, // Logical Maximum (7)
0x35, 0x00, // Physical Minimum (0)
0x46, 0x3B, 0x01, // Physical Maximum (315)
0x65, 0x14, // Unit (English Rotation)
0x75, 0x04, // Report Size (4)
0x95, 0x01, // Report Count (1)
0x81, 0x42, // Input (Data,Var,Abs,Null)
0x65, 0x00, // Unit (None)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x0E, // Usage Maximum (14)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x0E, // Report Count (14)
0x81, 0x02, // Input (Data,Var,Abs)
0x06, 0x00, 0xFF, // Usage Page (Vendor 0xFF00)
0x09, 0x20, // Usage (0x20)
0x75, 0x06, // Report Size (6)
0x95, 0x01, // Report Count (1)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x7F, // Logical Maximum (127)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x33, // Usage (Rx)
0x09, 0x34, // Usage (Ry)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs) -- L2/R2 analog
0x06, 0x00, 0xFF, // Usage Page (Vendor 0xFF00)
0x09, 0x21, // Usage (0x21)
0x95, 0x36, // Report Count (54)
0x81, 0x02, // Input (Data,Var,Abs) -- remainder of 64-byte report
0x85, 0x05, // Report ID (5)
0x09, 0x22, // Usage (0x22)
0x95, 0x1F, // Report Count (31)
0x91, 0x02, // Output (Data,Var,Abs)
0x85, 0x04, // Report ID (4)
0x09, 0x23, // Usage (0x23)
0x95, 0x24, // Report Count (36)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x02, // Report ID (2)
0x09, 0x24, // Usage (0x24)
0x95, 0x24, // Report Count (36)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x08, // Report ID (8)
0x09, 0x25, // Usage (0x25)
0x95, 0x03, // Report Count (3)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x10, // Report ID (16)
0x09, 0x26, // Usage (0x26)
0x95, 0x04, // Report Count (4)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x11, // Report ID (17)
0x09, 0x27, // Usage (0x27)
0x95, 0x02, // Report Count (2)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x12, // Report ID (18)
0x06, 0x02, 0xFF, // Usage Page (Vendor 0xFF02)
0x09, 0x21, // Usage (0x21)
0x95, 0x0F, // Report Count (15)
0xB1, 0x02, // Feature (Data,Var,Abs)
0x85, 0x13, // Report ID (19)
0x09, 0x22, // Usage (0x22)
0x95, 0x16, // Report Count (22)
0xB1, 0x02, // Feature (Data,Var,Abs)
0xC0 // End Collection
};
// USB Report ID 0x01 layout (64 bytes, excludes the report ID byte itself)
// byte[0] = lx
// byte[1] = ly
// byte[2] = rx
// byte[3] = ry
// byte[4] = hat (low nibble) | buttons_hi (high nibble: SQR,X,CIR,TRI)
// byte[5] = L1,R1,L2,R2,SHARE,OPT,L3,R3
// byte[6] = PS,TPAD | counter (bits 2-7)
// byte[7] = L2 analog
// byte[8] = R2 analog
// byte[9..63] = zeros (gyro/accel/trackpad all idle)
typedef struct __attribute__((packed)) {
uint8_t lx, ly, rx, ry;
uint8_t hat_buttons; // bits[3:0]=hat, bits[7:4]=SQR/X/CIR/TRI
uint8_t buttons1; // L1 R1 L2 R2 SHARE OPT L3 R3
uint8_t counter_ps; // bits[7:2]=counter, bit1=TPAD, bit0=PS
uint8_t l2, r2;
uint8_t rest[55]; // gyro, accel, trackpad, padding — all zero
} ds4_report_t;
static_assert(sizeof(ds4_report_t) == 64, "Report must be 64 bytes");
ds4_report_t report;
uint8_t reportCounter = 0;
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
pinMode(LEFT_FLIPPER, INPUT_PULLUP);
pinMode(RIGHT_FLIPPER, INPUT_PULLUP);
pinMode(PLUNGER, INPUT_PULLUP);
// GND pins
pinMode(5, OUTPUT); digitalWrite(5, LOW);
pinMode(8, OUTPUT); digitalWrite(8, LOW);
pinMode(3, OUTPUT); digitalWrite(3, LOW);
usb_hid.setPollInterval(1); // 1ms poll
usb_hid.setReportDescriptor(hid_report_descriptor,
sizeof(hid_report_descriptor));
TinyUSBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
TinyUSBDevice.setProductDescriptor("Wireless Controller");
TinyUSBDevice.setID(0x054C, 0x05C4);
usb_hid.begin();
while (!TinyUSBDevice.mounted()) delay(1);
// Initialize sticks to center, hat to neutral (0x08 = no direction)
report.lx = 128; report.ly = 128;
report.rx = 128; report.ry = 128;
report.hat_buttons = 0x08; // low nibble = hat neutral
}
void loop() {
if (!usb_hid.ready()) return;
bool leftPressed = !digitalRead(LEFT_FLIPPER);
bool rightPressed = !digitalRead(RIGHT_FLIPPER);
bool plungerPressed = !digitalRead(PLUNGER);
// Hat stays neutral (0x08);
report.hat_buttons = 0x08;
// upper nibble = face buttons; "X" for plunger
report.hat_buttons |= (plungerPressed ? (1<<5) : 0);
// byte[5]: L1=bit7, R1=bit6, L2=bit5, R2=bit4, SHARE=bit3, OPT=bit2, L3=bit1, R3=bit0
// Map left flipper → L1 (bit7), right flipper → R1 (bit6)
// Commenting out - mapping is incorrect, l2/r2 is enough
// report.buttons1 = (leftPressed ? 0x80 : 0x00)
// | (rightPressed ? 0x40 : 0x00);
// Counter increments every report (bits 7:2); PS and TPAD stay 0
report.counter_ps = (reportCounter++ << 2) & 0xFC;
// L2/R2 analog: fully pressed when flipper held, else 0
report.l2 = leftPressed ? 0xFF : 0x00;
report.r2 = rightPressed ? 0xFF : 0x00;
usb_hid.sendReport(1, &report, sizeof(report));
digitalWrite(LED_BUILTIN, (leftPressed || rightPressed || plungerPressed) ? LOW : HIGH);
delay(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment