PROTOCOL.md 14 KB

OpenPuck Controller Protocol

This document describes the complete controller-to-puck protocol implemented by OpenPuck.ino.

1. Scope

OpenPuck emulates the Valve Steam Controller 2 "puck" dongle. There are two separate protocols:

  1. USB host-facing protocol between the puck and the computer.
  2. 2.4 GHz radio protocol between the puck and the controller.

Both are required for a fully compatible implementation.

2. Bond record

A bond slot is 24 bytes:

offset  size  meaning
0x00    4     proteus_uuid / token, little-endian
0x04    4     ibex_uuid, little-endian
0x08    16    controller serial, ASCII, not null-terminated if fully filled

The puck stores four independent bond slots. Slot N is exposed as USB HID interface N.

An empty slot is all zeroes.

3. USB protocol

3.1 Steam-mode USB layout

Steam mode enumerates as Valve 28DE:1304 and exposes four HID interfaces with the same report descriptor as the real puck. Each interface owns one bond slot.

Relevant report IDs:

  • Input: 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x79, 0x7B
  • Output: 0x80 to 0x89
  • Feature: 0x01, 0x02

3.2 Feature commands

Feature payload framing is:

[cmd][len][payload...]

Implemented commands:

  • 0x83: attribute block
  • 0xAE: string attribute
  • 0xB4: connection state for the interface's slot
  • 0xAD: pairing mode enable/disable
  • 0xA2: write or clear the interface's 24-byte bond slot
  • 0xA3: read the interface's 24-byte bond slot

0xB4 response

[0xB4][0x01][state]
  • 0x02: controller connected on this slot
  • 0x01: not connected

0xA2 write

len must be at least 24. A zero-filled 24-byte payload clears the slot. Any non-zero valid payload writes the bond verbatim.

0xA3 read

Returns:

[0xA3][0x18][24-byte bond]

3.3 Host-to-controller relay

When Steam writes feature report 0x01, OpenPuck forwards it over RF to the controller as an E3 frame containing a sub-TLV. CONFIRMED from a passive sniff of a real puck↔controller session (puck_sniffer, docs/sniffer.html):

[E3][tlvlen][0x01][report_id][innerlen][data...]
  • tlvlen counts the value after it = report_id + innerlen + data = 2 + len(data).
  • The type byte is 0x01 — the same type as the GET poll (E3 02 01 45 <param>); the report_id selects the action. innerlen is the report's own declared length (the [len] byte of the feature payload).
  • [report_id][innerlen][data] is exactly the inner [cmd][len][payload] Steam wrote to feature 0x01, forwarded intact.

Confirmed examples from the sniff:

brightness   E3 05 01 87 03 2D <val> 00      (report 0x87, reg 0x2D)
LED color    E3 12 01 C1 10 <R G B W> 03 09 05 FF*9   (+ E3 04 01 DC 02 01 02, E3 04 01 E2 02 01 20)
haptic re-init E3 02 01 81 00 ,  E3 11 01 87 0F 30 …

This is how settings writes, haptics, LED, lizard-disable, and the controller power-off reach the controller. Earlier OpenPuck builds used [E3][len][0x05][rid][data] — wrong type byte AND the innerlen was dropped, so the controller read the first data byte as the length and discarded every relayed command (LED / brightness / power-off all silently failed). Fixed to the form above.

Controller power-off: Steam's "turn off controller" is the feature-0x01 command 9F 04 6F 66 66 21 — cmd 0x9F, len 4, payload 6F 66 66 21 = ASCII "off!". Relayed (correctly framed) as E3 06 01 9F 04 6F 66 66 21. (The exact RF frame still needs a clean sniff — it's a one-shot and the lossy sniffer missed it in the first capture; the framing above is the format every other command follows.)

The relay carries the command's declared length, up to 60 bytes — the most one RF frame fits ([E3][tlvlen][01][rid][innerlen] + payload ≤ MAXLEN 64). Relays are staged in a small ring (not a single buffer): the USB SET callbacks run in ISR context and Steam sends settings/calibration as back-to-back bursts, so a single pending slot both drops reports and can be torn mid-flush. One queued relay is emitted per poll cycle.

3.4 Connection presentation (input reports 0x79 / 0x7B)

  • 0x79 (1 byte): connection state, 0x02 = connected, 0x01 = disconnected. Edge-triggered, like the real puck. OpenPuck additionally re-sends it every 750 ms after a connect edge until Steam reacts (its first OUTPUT/settings write after the edge), covering a missed edge. It must not be re-sent unconditionally: each 0x79=02 can re-trigger Steam's connect handling (connect chime) and loop the haptic before Steam starts consuming 0x45.
  • 0x7B (12 bytes): periodic status, sent every 2 s while connected. Byte 8 is the controller→puck signal strength as signed dBm (live-captured example 0xDD = −35 dBm). OpenPuck fills it with the smoothed radio RSSI sampled on each CRC-good controller reply (RSSISAMPLE, started by an ADDRESS→RSSISTART short during the poll RX window). The other bytes are replayed from the live capture (F7 01 89 00 00 00 03 00 __ 00 3A 02); their semantics are unconfirmed.

4. Radio physical layer

OpenPuck uses Nordic raw radio mode configured to match the puck firmware:

  • PHY: Ble_2Mbit
  • Dynamic-length packet format
  • PCNF0 = 0x00030008
  • PCNF1 = 0x01040040
  • 5-byte address (BALEN=4)
  • Big-endian
  • Whitening disabled
  • CRC16, address included
  • Polynomial 0x11021
  • Init 0xFFFF

CRC is standard CRC-16-CCITT over address plus payload, MSB-first.

4.1 Address transform

Stored address bytes are converted to on-air address bytes by bit-reversing each byte.

For the nRF BASE0 register, the bit-reversed base bytes are then packed big-endian:

BASE0 = bitrev8(base[0]) << 24 |
        bitrev8(base[1]) << 16 |
        bitrev8(base[2]) <<  8 |
        bitrev8(base[3])

PREFIX0 = bitrev8(prefix)

5. Discovery / reconnect

Discovery uses a fixed address and fixed channel:

  • Base bytes: ASCII "ibex" = 69 62 65 78
  • Prefix: 0x10
  • Channel: 2

The puck transmits a host frame on that address until the controller connects.

6. Host frame (E1)

OpenPuck's discovery host frame is a dynamic-length ESB payload:

RAM buffer:
[0]  = 0x12                     ; payload length
[1]  = S1 byte                  ; PID in bits 1..2, no-ack bit clear
[2]  = 0xE1                     ; host-frame opcode
[3..6]   = proteus_uuid         ; 4 bytes, little-endian
[7..10]  = ibex_uuid            ; 4 bytes, little-endian
[11]     = session channel
[12..14] = 0x00 0x00 0x00
[15..18] = session base         ; 4 bytes, stored form
[19]     = session prefix

Semantics:

  • proteus_uuid is the first 4 bytes of the bond record.
  • ibex_uuid is the next 4 bytes of the bond record.
  • The host chooses the connected-session channel and address and advertises them here.

7. Connected session

After discovery, the puck becomes the poll master.

7.1 Controller wake / version frame (E7)

OpenPuck sends:

E7 00 00

This is the normal "host awake" form.

7.2 Input poll (E3)

OpenPuck polls for report 0x45 using:

E3 [len] 0x01 0x45 [param]
  • 0x01: GET subtype
  • 0x45: controller input report
  • param: implementation tunable, usually 0x00 or 0x2D

The controller replies in the ACK window.

7.3 Controller replies

Observed reply opcodes:

  • 0xF1: full TLV container carrying input data
  • 0xF2: disconnect / shutdown
  • 0xF3: status

0xF1 container

The TLV scan starts at byte 6. Each record is:

[len][tag][value...]

Known tags:

  • 0x02: 4-byte control/status field
  • 0x04: bulk data blob
  • 0x06: embedded report wrapper

For controller input, the embedded report is 0x45.

8. Report 0x45 layout

Report 0x45 is 46 bytes:

offset  size  meaning
0x00    1     report id = 0x45
0x01    1     sequence
0x02    4     buttons, little-endian u32
0x06    2     left trigger, u16
0x08    2     right trigger, u16
0x0A    2     left stick X, s16
0x0C    2     left stick Y, s16
0x0E    2     right stick X, s16
0x10    2     right stick Y, s16
0x12    2     left pad X, s16
0x14    2     left pad Y, s16
0x16    2     left pad press, s16
0x18    2     right pad X, s16
0x1A    2     right pad Y, s16
0x1C    2     right pad press, s16
0x1E    4     IMU timestamp, u32
0x22    2     accel X, s16
0x24    2     accel Y, s16
0x26    2     accel Z, s16
0x28    2     gyro X, s16
0x2A    2     gyro Y, s16
0x2C    2     gyro Z, s16

8.1 Button bitfield

0x00000001 A
0x00000002 B
0x00000004 X
0x00000008 Y
0x00000010 QAM
0x00000020 R3
0x00000040 View
0x00000080 R4
0x00000100 R5
0x00000200 RB
0x00000400 D-pad down
0x00000800 D-pad right
0x00001000 D-pad left
0x00002000 D-pad up
0x00004000 Menu
0x00008000 L3
0x00010000 Steam
0x00020000 L4
0x00040000 L5
0x00080000 LB
0x00100000 right stick touch
0x00200000 right pad touch
0x00400000 right pad click
0x00800000 right trigger click
0x01000000 left stick touch
0x02000000 left pad touch
0x04000000 left pad click
0x08000000 left trigger click
0x10000000 right grip touch
0x20000000 left grip touch

9. USB presentation modes

The RF side stays the same across modes. Only USB enumeration changes.

There are three switchable modes (0=Steam 1=Xbox 2=Switch). Steam mode is a CDC + WebUSB composite; Xbox and Switch are clean controller-only devices — the auto-added CDC/WebUSB interfaces are torn down (clearConfiguration) and bcdUSB stays 0x0200 (no USB-2.1 BOS). This is required because Windows' xusb driver expects the Xbox 360 controller on the primary device/interface, and a real Switch console rejects composite devices. Every boot/mode-switch does a detach -> rebuild -> attach so the host re-reads the descriptor cleanly.

9.1 Steam mode

  • VID:PID 28DE:1304
  • Four puck HID interfaces (CDC + WebUSB also present)
  • 0x45 reports are forwarded to the connected slot's HID interface
  • Seamless lizard: when Steam is driving the device, 0x45 is forwarded; when it isn't (Steam closed, 7 s watchdog) the same 0x45 is translated into mouse (0x40) + keyboard (0x41) reports on the same puck interface, so the device is a driverless desktop keyboard+mouse with no mode switch. This is purely USB-side; the RF poll and relay are unchanged. (There is no standalone lizard mode.)
  • "Steam is driving" signal: any Steam OUTPUT/settings report (0x80–0x89, e.g. the 0x87 lizard-off heartbeat, LED, or a 0x82 haptic) refreshes the watchdog — not just 0x87. This makes the puck leave lizard for gamepad on Steam's first contact, even if that first packet is a haptic that arrives before the heartbeat.
  • Haptics are gated on this decision: haptic reports are never relayed to the controller while the puck is presenting lizard. Relaying haptics while Steam isn't reading 0x45 back made Steam loop the same command, leaving the controller buzzing; suppressing them keeps the lizard state clean. The same gate applies during the post-resume input mute (POST_RESUME_MUTE_MS): Steam can't read 0x45 back in that window either, so a wake-time haptic would loop identically.
  • Which OUTPUT reports are relayed: the haptic/actuator reports 0x80–0x86 are forwarded to the connected slot only (the slot gate is what stops a haptic aimed at another of the four exposed slots from buzzing the single controller). The 63-byte settings/config reports 0x87/0x88/0x89 are not haptics and are not pushed on this path (0x87 lizard-off reaches the controller via the feature 0x01 passthrough). Restricting this to 0x82 alone silently dropped the ping/grip/test haptics, which use other report IDs.

9.2 Xbox mode

  • VID:PID 045E:028E, clean device (no CDC/WebUSB)
  • Custom XInput-compatible vendor interface on MI_00 + HID boot mouse on MI_01 (right-pad emulation)
  • 0x45 is converted into a 20-byte XInput report

9.3 Switch mode

  • VID:PID 0F0D:0092 (HORI Pokkén Tournament Pro Pad), clean device (no CDC/WebUSB)
  • Single HID interface with the canonical HORIPAD descriptor (interrupt IN + OUT endpoints), accepted by a real Switch console with no handshake; an 8-byte report is streamed at ~250 Hz

10. WebUSB control channel

The WebUSB vendor interface is present only in Steam mode (Xbox/Switch are clean controllers with no config interface — configure them from Steam mode or switch via the back-paddle chord).

Messages:

  • Host to device:
    • 0x01: get status blob
    • 0x02 <field> <value>: set one field
    • 0x03 <mode>: switch mode and reboot
  • Device to host:
    • 0xA5 <len> <payload>

Status blob payload:

[0]  version
[1]  usb mode
[2]  xbox mouse sensitivity divisor
[3]  xbox mouse friction
[4]  steam pad smoothing
[5]  A/B + X/Y swap
[6]  back mapping 0
[7]  back mapping 1
[8]  back mapping 2
[9]  back mapping 3
[10] connected slot or 0xFF
[11] link up flag
[12] delivered reports/s low
[13] delivered reports/s high
[14] poll interval / 100
[15] new reports/s low
[16] new reports/s high
[17] E7 mode byte
[18] relay opcode
[19] relay subtype
[20] forward-new-only flag
[21] QoS auto-hop flag
[22] persist-mode flag

11. Timing notes

  • Default poll interval: 4000 microseconds (250 Hz, matches SC2 report rate)
  • Default RX window: 1200 microseconds
  • Discovery beacon continues on channel 2 even after a connected session exists
  • Connected session can be moved to a cleaner channel such as 18 or 52

12. Reimplementation checklist

To build a compatible puck from scratch:

  1. Expose four independent bond slots over USB.
  2. Implement 0x83, 0xAE, 0xB4, 0xAD, 0xA2, 0xA3.
  3. Persist 24-byte bond records exactly.
  4. Configure the radio exactly as described above.
  5. Beacon E1 host frames on "ibex" / prefix 0x10 / channel 2.
  6. Poll with E7 then E3 GET 0x45.
  7. Parse F1 and unpack report 0x45.
  8. Relay host feature writes to the controller as E3 SET sub-TLVs.
  9. Re-enumerate USB cleanly when switching Steam/Xbox/Switch modes (Xbox/Switch as clean non-composite devices).

If those pieces match, the controller-to-puck protocol is fully reimplemented.