This document describes the complete controller-to-puck protocol implemented by OpenPuck.ino.
OpenPuck emulates the Valve Steam Controller 2 "puck" dongle. There are two separate protocols:
Both are required for a fully compatible implementation.
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.
Steam mode enumerates as Valve 28DE:1142 and exposes four HID interfaces with the same report descriptor as the real puck. Each interface owns one bond slot. The firmware uses the classic Steam Controller dongle PID because SDL's Steam HIDAPI driver reliably treats 0x1142 as the four-slot wireless receiver; newer puck PIDs may work in Steam but are not consistently surfaced as Steam Controllers to SDL apps. SDL also expects the Steam slot HIDs on USB interface numbers 1..4, so WebUSB is registered before the slot HIDs in puck mode to reserve interface 0.
Relevant report IDs:
0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x79, 0x7B0x80 to 0x890x01, 0x02Feature payload framing is:
[cmd][len][payload...]
Implemented commands:
0x83: attribute block0xAE: string attribute0xB4: connection state for the interface's slot0xAD: pairing mode enable/disable0xA2: write or clear the interface's 24-byte bond slot0xA3: read the interface's 24-byte bond slot0xB4 response[0xB4][0x01][state]
0x02: controller connected on this slot0x01: not connected0xA2 writelen must be at least 24. A zero-filled 24-byte payload clears the slot. Any non-zero valid payload writes the bond verbatim.
0xA3 readReturns:
[0xA3][0x18][24-byte bond]
When Steam writes feature report 0x01, OpenPuck forwards it over RF to the controller as an E3 frame
containing a sub-TLV. There are two on-air forms:
legacy [E3][1+len][0x05][report_id][data...] (no inner-len)
landing [E3][2+len][0x01][report_id][innerlen][data...] (type 01, KEEPS inner-len; controller ACTS on it)
A command only lands on the controller in the landing form (type 0x01 — the same type byte the GET poll
uses, E3 02 01 45 <param>; the report_id selects the action, innerlen is the report's own [len]). In the
legacy form the controller discards any 0x87+ command (it reads the first data byte as the length).
OpenPuck sends the legacy form for everything EXCEPT a tight whitelist — only LED brightness (0x87 whose
first register byte is 0x2D) and controller power-off (0x9F) use the landing form:
haptic on E3 04 05 82 01 01 F7 (report 0x82, legacy)
brightness E3 05 01 87 03 2D <val> 00 (report 0x87 reg 0x2D, LANDING -- whitelisted)
power-off E3 06 01 9F 04 6F 66 66 21 (report 0x9F "off!", LANDING -- whitelisted)
Steam 0x87 cfg E3 .. 05 87 .. (haptic/IMU config: legacy -> DISCARDED, by design)
Why the whitelist is tight, not "land everything 0x87+": Steam continuously writes its own 0x87
passthrough during normal play — the haptic-config block including reg 0x30 (IMU/subsystem enable) and
0x34/0x35 (haptic amplitude). If those land, reg 0x30 freezes the controller's gyro stream and
0x34/0x35 drive the connect-time buzz. The long-working build never landed any 0x87, so neither happened.
Landing the whole class regresses both (stuck gyro + buzz); landing only the brightness register and power-off
adds those two features while leaving Steam's config writes discarded exactly as before.
The relay carries the command's declared length, up to 60 bytes — the most one RF frame fits. 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.
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.OpenPuck uses Nordic raw radio mode configured to match the puck firmware:
Ble_2MbitPCNF0 = 0x00030008PCNF1 = 0x01040040BALEN=4)0x110210xFFFFCRC is standard CRC-16-CCITT over address plus payload, MSB-first.
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)
Discovery uses a fixed address and fixed channel:
"ibex" = 69 62 65 780x102The puck transmits a host frame on that address until the controller connects.
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.After discovery, the puck becomes the poll master.
E7)OpenPuck sends:
E7 00 00
This is the normal "host awake" form.
E3)OpenPuck polls for report 0x45 using:
E3 [len] 0x01 0x45 [param]
0x01: GET subtype0x45: controller input reportparam: implementation tunable, usually 0x00 or 0x2DThe controller replies in the ACK window.
Observed reply opcodes:
0xF1: full TLV container carrying input data0xF2: disconnect / shutdown0xF3: status0xF1 containerThe TLV scan starts at byte 6. Each record is:
[len][tag][value...]
Known tags:
0x02: 4-byte control/status field0x04: bulk data blob0x06: embedded report wrapperFor controller input, the embedded report is 0x45.
0x45 layoutReport 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
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
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.
28DE:11420x45 reports are forwarded to the connected slot's HID interface0x45 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.)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.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.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.045E:028E, clean device (no CDC/WebUSB)MI_00 + HID boot mouse on MI_01 (right-pad emulation)0x45 is converted into a 20-byte XInput report0F0D:0092 (HORI Pokkén Tournament Pro Pad), clean device (no CDC/WebUSB)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:
0x01: get status blob0x02 <field> <value>: set one field0x03 <mode>: switch mode and reboot0x07: re-init haptics (clear a stuck buzz)0x08: send controller power-off0x09: export all bond slots (reply: 0xA7 frame) — see §10.10x0A 0x45 0x52 0x53: factory erase ("ERS" magic), then reboot0x0B / 0x0C: reboot into serial DFU / UF2 bootloader0x0D <slot> <used> <24-byte rec>: write one bond slot into RAM — see §10.10x0E <mode>: commit imported bonds (+ apply mode, 0xFF = unchanged), then reboot0x11: get the lizard binding map (device replies 0xAA)0x13 <count>: begin a lizard-map edit — set the binding count0x12 <idx> <16-byte binding>: set one lizard binding (see below)0x14: commit the edited lizard map to flash (device echoes 0xAA)0x15: reset the lizard map to built-in defaults (device echoes 0xAA)0x20–0x24: staged firmware update (begin/data/end/reboot/abort), acked with 0xAB frames0xA5 <len> <payload>: status blob0xA7 <len> <payload>: bond export (§10.1)0xA8 ...: flight-recorder stream0xA9 ...: live wedge report0xAA <count> <count×16-byte bindings>: lizard binding map0xAB 5 <status> <nextOff u32 LE>: firmware-update ackLizard binding wire format (16 bytes), matching LizardBinding in lizard_map.h:
[0] outType (0 none, 1 keyboard chord, 2 mouse btn, 3 mouse axis, 4 scroll, 5 consumer)
[1..7] outData[0..6] (type-specific payload)
[8..11] trigMask u32 LE (button bits: any-of)
[12..15] holdMask u32 LE (button bits: all-of guard)
Lizard-map commands and the
0xAAreply were renumbered from0x0D–0x11/0xA7on the merges withmain, which had independently claimed0x0D–0x10,0xA7(bond export) and0xA8(flight recorder).
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
A puck's whole portable identity is its four 24-byte bond records ([proteus_uuid 4][ibex_uuid 4][serial 16],
§2). The per-bond on-air session address is not stored or transferred: each puck re-derives it from the
bond UUID mixed with its own FICR DEVICEID, and the bonded controller relearns it from the E1 host beacon on
every reconnect. So copying the bond records onto a second puck is sufficient for any controller paired to the
first to connect to the second with no re-pairing — the two pucks just derive different (non-colliding)
session addresses for the same controller.
This makes a puck cloneable. The WebUSB panel exposes it as Export to file / Import from file.
Export — 0x09 → 0xA7 frame:
[0] format version (1)
[1] used mask (bit s = slot s bonded)
[2..] slot 0..3 records, 24 bytes each (96 bytes) (len = 2 + 4*24 = 98)
Import — 0x0D (per slot) then 0x0E (commit):
0x0D <slot 0..3> <used 0|1> <24-byte record> writes one slot into RAM (used=0 clears it)
0x0E <mode> persist all slots, apply mode (0xFF=unchanged), reboot
Bonds are written one slot at a time so no command exceeds a single USB-FS packet, and each 0x0D/0x02 is
acked with a status blob so the host's read-after-write keeps the OUT pipe flowing. The panel sends all four
0x0D slot writes plus every config field via 0x02, then a single 0x0E to persist and reboot — so the
reboot regenerates the session addresses from the new UUIDs and the restored puck reproduces both the pairings
and all settings. Other settings (mouse, rumble, chords, per-type button maps, Switch Pro motion) ride in the
backup file as plain 0x02 field values, not in the bond commands.
4000 microseconds (250 Hz, matches SC2 report rate)1200 microsecondsTo build a compatible puck from scratch:
0x83, 0xAE, 0xB4, 0xAD, 0xA2, 0xA3.E1 host frames on "ibex" / prefix 0x10 / channel 2.E7 then E3 GET 0x45.F1 and unpack report 0x45.E3 SET sub-TLVs.If those pieces match, the controller-to-puck protocol is fully reimplemented.