The Short Version
Nintendo's Switch 2 controllers use Bluetooth Low Energy with a proprietary application-layer pairing protocol. The pairing, command structure, and input report format all happen over standard GATT characteristics. There's nothing OS-specific about it. Any platform with a BLE stack can connect to these controllers.
This post documents the full protocol: GATT service and characteristic UUIDs, the four-step pairing handshake, the 63-byte input report format, SPI flash calibration, IMU data, charging detection, HD Rumble, and analog trigger normalization. We've verified everything against all four controller types: Joy-Con 2 (L and R), Pro Controller 2, and the NSO GameCube controller.
A working implementation is available as a web app that connects directly in Chrome via the Web Bluetooth API. No native code, no extensions. Source on GitHub.
The Controller Family
All four Switch 2 controllers share the same BLE protocol. They advertise with Nintendo's manufacturer ID 0x0553 and are distinguished by product ID in the advertisement data:
| Controller | Product ID | Features |
|---|---|---|
| Joy-Con 2 (R) | 0x2066 | Buttons, stick, motion, magnetometer, mouse sensor, HD Rumble |
| Joy-Con 2 (L) | 0x2067 | Buttons, stick, motion, magnetometer, mouse sensor, HD Rumble |
| Pro Controller 2 | 0x2069 | Buttons, sticks, motion, magnetometer, HD Rumble |
| NSO GameCube | 0x2073 | Buttons, sticks, analog triggers, motion, simple rumble |
The pairing protocol, command structure, and 63-byte input report format are identical across all four. The differences are in feature flags, rumble type, and which GATT characteristic is used for commands.
GATT Services and Characteristics
All four controller types expose the same two proprietary GATT service UUIDs:
00c5af5d-1964-4e30-8f51-1956f96bd280
ab7de9be-89fe-49ad-828f-118f09df7fd0
These must be known ahead of time on platforms that require service UUID whitelisting (e.g., Web Bluetooth's optionalServices). The characteristics within these services are:
| Handle | Purpose | Shared? |
|---|---|---|
0x0014 | Command output (init, pairing, features) | Yes |
0x000A | Input reports (63-byte packets via notify) | Yes |
0x001A | Command response / ACK | Yes |
0x0016 | Vibration + command combined (rumble, LED) | Per-controller UUID |
0x001E | Command response #2 | Per-controller UUID |
0x0012 | HD Rumble output (LRA packets) | Per-controller UUID |
Joy-Con uses 0x0014 for init and pairing commands. Pro Controller and GameCube use 0x0016 (the combined vibration+command characteristic) for most commands, falling back to 0x0014 if needed. Notifications on 0x000A must be enabled immediately at characteristic discovery.
The Pairing Protocol
Nintendo uses a proprietary application-layer pairing protocol on top of BLE. This is separate from BLE's own pairing and bonding. The protocol uses command 0x15 with four sequential steps, all written to the command characteristic.
Every command follows an 8-byte header:
[cmd] [0x91] [0x01] [subcmd] [0x00] [data_len] [0x00] [0x00] [data...]
0x91 is host-to-device direction. 0x01 is BLE transport.
Step 1: Exchange Addresses (subcmd 0x01)
The host sends two Bluetooth addresses. On a real Switch, these are the console's BT MAC. On iOS, Apple hides the device's Bluetooth MAC address entirely. We discovered that the controller does not validate this address against the BLE link-layer address, so a fixed placeholder works.
15 91 01 01 00 0E 00 00
00 02
[6 bytes: host address, big endian]
[6 bytes: same address, first byte decremented by 1]
Step 2: Exchange Public Keys (subcmd 0x04)
The host sends a fixed 16-byte public key. The controller responds with its own. Both keys are documented in the BlueRetro firmware source.
15 91 01 04 00 11 00 00 00 [16 bytes: public key]
Step 3: Confirm LTK (subcmd 0x02)
The host sends a 16-byte challenge. The Long Term Key is the XOR of both public keys.
15 91 01 02 00 11 00 00 00 [16 bytes: challenge]
Step 4: Finalize (subcmd 0x03)
15 91 01 03 00 01 00 00 00
The full handshake takes about 800ms. After this, the controller is paired.
Joy-Con Store Pairing (0x03)
Joy-Con 2 has an additional step after pairing: the host writes its address and LTK to the controller's storage using command 0x03 subcommand 0x07, followed by 0x03 0x09. Pro Controller and GameCube skip this.
Post-Pairing Initialization
After pairing completes, several commands configure the controller:
Feature Flags (cmd 0x0C)
Sets which input features are enabled. The value varies by controller type:
| Controller | Flags | Notes |
|---|---|---|
| Joy-Con 2 | 0xFF | All features including mouse sensor. Must be 0xFF or the mouse sensor stays off. |
| Pro Controller 2 | 0x2F | Buttons, sticks, IMU, current |
| NSO GameCube | 0x2F | Buttons, sticks, IMU, analog triggers. Using 0x27 disables IMU (bit 3). |
A 500ms delay after the feature mask command is critical. Without it, features may not initialize correctly.
LED (cmd 0x09)
Sets the player indicator LEDs using a successive bit pattern: player 1 = 0x01, player 2 = 0x03, player 3 = 0x07, player 4 = 0x0F.
Joy-Con sends this directly on 0x0014. Pro Controller and GameCube embed the LED command in a combined rumble+command packet on 0x0016. The Pro packet is 41 bytes (16 L rumble + 16 R rumble + command). The GameCube packet is 21 bytes (5 rumble state + command).
The 63-Byte Input Report
All four controllers stream the same 63-byte input report on characteristic 0x000A:
| Offset | Size | Content |
|---|---|---|
0x00 | 3 | Packet ID (24-bit LE) |
0x03 | 4 | Button bitmask (32-bit LE) |
0x0A | 3 | Left stick (two packed 12-bit values) |
0x0D | 3 | Right stick (two packed 12-bit values) |
0x10 | 4 | Mouse sensor X/Y (int16 each, Joy-Con only) |
0x18 | 6 | Magnetometer X/Y/Z (int16 each) |
0x1F | 2 | Battery voltage (uint16 LE, millivolts) |
0x21 | 1 | Charging indicator (>0 = charging) |
0x28 | 2 | Battery current |
0x2E | 2 | Temperature |
0x30 | 6 | Accelerometer X/Y/Z (int16 each) |
0x36 | 6 | Gyroscope X/Y/Z (int16 each) |
0x3C | 1 | Left trigger (analog, 0-255) |
0x3D | 1 | Right trigger (analog, 0-255) |
Sticks are packed as two 12-bit integers in 3 bytes:
X = byte[0] | ((byte[1] & 0x0F) << 8)
Y = (byte[1] >> 4) | (byte[2] << 4)
Neutral position is 0x800 (2048). Effective ranges differ by controller: the Pro Controller main stick reaches about 1610 units from center, the GameCube main stick about 1225, and the GameCube C-stick about 1120.
The analog triggers at 0x3C-0x3D are 0-255 on the GameCube controller (true analog). On the Pro Controller, the triggers are digital.
The mouse sensor at 0x10-0x13 is active only on Joy-Con 2 when feature flags are set to 0xFF. It reports signed 16-bit X/Y position data when the Joy-Con is held sideways as an air mouse.
Battery and Charging
Battery voltage at 0x1F is in millivolts. To calculate percentage: (voltage/1000 - 3.0) / 1.2 * 100, clamped to 0-100. The 3.0V floor and 4.2V ceiling represent the typical Li-ion discharge curve.
Byte 0x21 indicates charging state. A value greater than zero means the controller is currently charging. This byte is zero when running on battery.
IMU Orientation
The accelerometer reports gravity-relative orientation. With the controller held normally in front of you:
- accelX: roll (left/right tilt)
- accelY: pitch (forward/backward tilt)
- accelZ: gravity axis (roughly +-4096 at 1g)
The gyroscope reports angular velocity:
- gyroX: pitch rate (tilting forward/backward)
- gyroY: roll rate (steering wheel rotation)
- gyroZ: yaw rate (turning left/right while flat)
Raw gyro values of roughly +-4000 correspond to moderate rotation speed. All six IMU values are signed 16-bit integers.
SPI Flash Calibration
The controllers store stick and trigger calibration data in SPI flash. This data can be read during initialization using command 0x02 (SPI read) via the write characteristic (0x0014):
02 91 01 04 00 08 00 00 [len] 7E 00 00 [addr LE 4 bytes]
The response arrives on the command response characteristic with command ID 0x02, payload starting at byte 4.
Stick Calibration Addresses
| Address | Size | Content |
|---|---|---|
0x00013080 | 0x40 | Factory calibration, left stick (data at offset 52) |
0x000130C0 | 0x40 | Factory calibration, right stick (data at offset 52) |
0x001FC040 | 0x40 | User calibration (left at offset 14, right at offset 46). Overrides factory if present (first byte != 0xFF). |
Calibration Data Format
Each stick axis is packed as 9 bytes containing three 12-bit values for X and Y:
x.neutral = d[0] | ((d[1] & 0x0F) << 8)
y.neutral = (d[1] >> 4) | (d[2] << 4)
x.rel_max = d[3] | ((d[4] & 0x0F) << 8)
y.rel_max = (d[4] >> 4) | (d[5] << 4)
x.rel_min = d[6] | ((d[7] & 0x0F) << 8)
y.rel_min = (d[7] >> 4) | (d[8] << 4)
Dead zone is at offset 19 of the calibration block, packed as a 12-bit value: d[19] | ((d[20] & 0x0F) << 8).
To apply calibration:
centered = raw - neutral
range = (centered >= 0) ? rel_max : rel_min
if (abs(centered) < deadzone) return 0
scaled = centered * 32767 / range // clamp to +-32767
Joy-Con Calibration Quirk
Solo Joy-Cons have only one stick, but the stick data in the input report is always in the correct field (left stick for Joy-Con L, right stick for Joy-Con R). However, the calibration data is stored at the left stick SPI address (0x13080) on both Joy-Con L and Joy-Con R. When applying calibration for a Joy-Con R, use the left SPI calibration data but apply it to the right stick input.
Trigger Calibration
The GameCube analog triggers use default calibration values (from BlueRetro): neutral = 30, max = 195. Normalization:
if (raw <= neutral) return 0
calibrated = (raw - neutral) * 255 / (max - neutral) // clamp to 0-255
Button Bitmask
| Bit | Button | Bit | Button |
|---|---|---|---|
| 31 | ZL | 15 | ZR |
| 30 | L | 14 | R |
| 29 | SL (L) | 13 | SL (R) |
| 28 | SR (L) | 12 | SR (R) |
| 27 | D-Pad Left | 11 | A |
| 26 | D-Pad Right | 10 | B |
| 25 | D-Pad Up | 9 | X |
| 24 | D-Pad Down | 8 | Y |
| 22 | Chat / C | 20 | Home |
| 21 | Capture | 19 | L Stick Click |
| 18 | R Stick Click | 17 | Start / + |
| 16 | Select / - |
Rumble
The Switch 2 controller family has two distinct rumble systems: HD Rumble with LRA motors on the Joy-Con 2 and Pro Controller 2, and a simple on/off ERM motor on the NSO GameCube controller.
HD Rumble Packet Layout (Joy-Con 2, Pro Controller 2)
HD Rumble uses Linear Resonant Actuators with independent left and right motors. Packets are 42 bytes, written to the 0x0012 characteristic:
State Byte
Each motor block starts with a state byte that controls the transaction and enable state:
Active rumble: 0x70 | tid (enable=1, ops_cnt=3). Stop: 0x30 | tid (enable=0, ops_cnt=3). The transaction ID increments 0 through F and wraps.
LRA Operation (5 bytes)
Each operation controls both the low-frequency and high-frequency components of the LRA motor:
Default Frequencies
BlueRetro defines per-motor default frequencies that match the physical resonance of each LRA:
| Motor | LF Frequency | HF Frequency |
|---|---|---|
| Left | 0x100 (256) | 0x0E1 (225) |
| Right | 0x180 (384) | 0x1E1 (481) |
The left and right motors have different resonant frequencies. Sending the same frequency to both produces a richer combined vibration than tuning them identically.
Amplitude Scaling
BlueRetro converts a generic power value (0-255) to LRA amplitudes using these ratios:
- HF amplitude (8-bit):
power / 2.68(max ~95 at full power) - LF amplitude (10-bit):
power / 0.3156(max ~808 at full power)
The LF component carries most of the perceived vibration intensity. HF adds texture on top.
Idle State
When rumble is inactive, ops should be set to the idle value 0x1e100000 (32-bit) with HF amplitude 0x00. This puts the LRA in a known safe state rather than leaving the previous values in place.
Simple Rumble (NSO GameCube)
The GameCube controller uses a basic on/off ERM motor. No frequency or amplitude control. A 21-byte packet on 0x0016:
byte[0] = 0x00 // padding
byte[1] = 0x50 | tid // state byte (enable + ops_cnt=3 + tid)
byte[2] = 0x01 or 0x00 // motor on / off
byte[3-20] = 0x00 // padding
Sustained Vibration
Both rumble types require continuous packets. A single packet produces only a brief pulse before the motor spins down. Packets must be sent every 5ms from a dedicated background thread using a high-priority dispatch timer. Using the main thread or a lower-priority timer introduces jitter that causes audible stuttering in the vibration.
Implementation Notes
The connection flow on any platform is:
- Scan for BLE devices with manufacturer ID
0x0553. - Connect and discover the two GATT services. Subscribe to input (
0x000A) and command response (0x001A) notifications. - Read calibration from SPI flash (left stick, right stick, user overrides).
- Pair using the four-step
0x15command sequence. - Configure feature flags (sent to
0x0014, not0x0016), vibration, and LEDs. - Read input from 63-byte notifications on
0x000A.
Host Address
The pairing handshake requires a host Bluetooth MAC address, but testing confirmed the controllers do not validate it against the BLE link-layer address. A fixed placeholder (0xAABBCCDDEEFF) works on all four controller types. This is important for platforms that use randomized private addresses (iOS) or don't expose the real MAC (Web Bluetooth).
Feature Flags Target
The feature flags command (0x0C) must be written to 0x0014 (the write characteristic), not 0x0016 (the vibration+command characteristic). Sending it to 0x0016 silently fails and IMU data won't be enabled. This applies to Pro Controller and GameCube, which use 0x0016 for most other commands.
Web Bluetooth
Web Bluetooth requires service UUIDs declared in optionalServices at scan time. The two service UUIDs listed above are identical across all four controller types. Supported in Chrome, Edge, and Chromium-based browsers on desktop and Android. Not supported in Safari or Firefox.
Try It
Acknowledgments
This work builds on the protocol documentation from BlueRetro by Jacques Gagnon, which provided the pairing keys, command structures, and controller mappings for the Switch 2 family.