Created
March 7, 2026 16:27
-
-
Save Kos/c33b82812a7ae9ced32526e613c5efb9 to your computer and use it in GitHub Desktop.
Pinball controller for Seeed Studio XIAO SAMD21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| 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