The Short Version
Nintendo's Switch 2 controllers use Bluetooth Low Energy. iOS fully supports BLE through the public CoreBluetooth framework. Nintendo's proprietary pairing protocol happens entirely at the application layer: just bytes written to standard GATT characteristics. iOS doesn't know or care that those bytes represent a Nintendo pairing sequence. No private APIs, no jailbreak, no special entitlements.
We built a working iOS app that connects to all four Switch 2 BLE controller types: Joy-Con 2 (L and R), Pro Controller 2, and the NSO GameCube controller. Full input (buttons, sticks, triggers, motion, mouse sensor) and output (HD Rumble, simple motor rumble, LEDs) all working over CoreBluetooth. The source is available 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 Characteristics
Each controller exposes a set of GATT characteristics. Some are shared across all types, others are controller-specific with unique UUIDs per device:
| 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 | 0x27 | Buttons, sticks, IMU |
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 |
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.
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.
iOS Implementation
CoreBluetooth handles all standard BLE operations. The flow is:
- Scan for peripherals, filtering by manufacturer ID
0x0553and product ID in the advertisement data. - Connect and discover services and characteristics. Subscribe to
0x000Aand0x001Anotifications immediately at discovery. - Pair using the four-step 0x15 command sequence.
- Configure feature flags, vibration sample, and LEDs.
- Read input from notification callbacks on
0x000A.
The Fake Address
The biggest unknown was whether the controller would validate the host Bluetooth address against the BLE link-layer address. iOS uses randomized private addresses at the link layer, and Apple provides no API to read the real Bluetooth MAC.
Testing confirmed the controllers do not cross-reference these. A fixed placeholder address passes through the pairing handshake on all four controller types. The controller stores it for reconnection, but since iOS identifies peripherals by a stable CBPeripheral UUID, there's no practical impact.
iOS-Specific Notes
NSBluetoothAlwaysUsageDescriptionmust be in Info.plist. Without it, CoreBluetooth silently refuses to power on.bluetooth-centralbackground mode keeps the connection alive when the app is backgrounded.- Write-without-response is used for all commands. iOS can drop packets under heavy load, but the fixed delays between commands give the BLE stack time to flush.
- The init sequence must run on a separate thread from the CoreBluetooth callback queue, or the sleep delays will block incoming notifications.
Source Code
The full implementation is available at github.com/bitaxislabs/Switch2BLE. It's a self-contained iOS app written in Swift using CoreBluetooth. Supports all four controller types with a debug UI showing live input state, HD Rumble controls, and a BLE log.
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.