The first working Bluetooth LE client for the Nintendo Switch 2 Pro Controller on macOS.
A menubar app that connects to the Switch 2 Pro Controller via BLE and makes it usable in emulators: as keyboard presses (Ryujinx) or as a true analog gamepad over DSU (Dolphin, Cemu).
โฌ๏ธ Download the latest release
This is NOT a system driver. It won't make your controller appear in System Preferences or work natively with games.
This IS:
- โ A BLE client that reads controller inputs via Bluetooth Low Energy
- โ A keyboard bridge that converts inputs to key presses for Ryujinx
- โ A DSU (cemuhook) server that exposes the controller as an analog gamepad to Dolphin, Cemu and other DSU clients
- โ A reference implementation for the Switch 2 Pro Controller BLE protocol
- โ Full button mapping โ all buttons, triggers, D-pad working
- โ Analog sticks โ read with 12-bit precision (converted to 8 directions, see Limitations)
- โ Factory stick calibration โ read from the controller at connect, so sticks reach full deflection without drift
- โ Grip buttons โ Switch 2 exclusive GL/GR buttons supported
- โ Ryujinx compatible โ keyboard bridge for emulator support
- โ No pairing required โ bypasses macOS Bluetooth limitations
- โ Auto-reconnect โ if the controller sleeps or drops, the bridge retries for 60 s
- โ C button โ the Switch 2's new C button can be mapped
- โ Player LED โ player 1 light is set on connect (best effort)
- โ DSU server (cemuhook) โ true analog sticks in Dolphin, Cemu & other DSU clients, no driver needed
- โ Start at Login โ one click in the menubar (bundled .app, macOS 13+)
- โ
Several controllers at once โ one instance per controller with
--config, each pinned to its pad (see Multi-controller)
The Nintendo Switch 2 Pro Controller (Product ID: 0x2069) doesn't work with macOS natively:
| Method | Status | Problem |
|---|---|---|
| USB | โ | Stays silent until it receives a proprietary init sequence (not implemented here yet โ see #14) |
| Bluetooth Classic | โ | macOS can't discover/pair with it |
| Bluetooth LE | โ | Works with custom BLE client (this project) |
This bridge connects via BLE using the bleak library, reads the raw input data, and converts it to keyboard presses that Ryujinx can use.
- macOS Ventura (13.0) or later
- Nintendo Switch 2 Pro Controller
- Python 3.10+ only to run from source (bleak 3 requires it)
- Download
Switch2Bridge-vX.Y.Z.dmgfrom the latest release (its SHA-256 is in the release notes) - Open it and drag Switch2 Bridge to Applications
- First launch: the app isn't notarized, so Gatekeeper blocks it once. On macOS 15+ go to System Settings โ Privacy & Security โ Open Anyway; on macOS 13โ14, right-click the app โ Open
- Grant Accessibility at launch and Bluetooth on the first Connect Controller
git clone https://github.com/mlstr0m/switch2bridge-macos.git
cd switch2bridge-macos
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python Switch2Bridge.pymacOS will ask for two permissions:
- Accessibility โ prompted at first launch (to simulate keyboard input)
- Bluetooth โ prompted the first time you click Connect Controller (not at launch!)
System Settings โ Privacy & Security โ Bluetooth, then relaunch. The app detects a denied permission and offers to open the right settings pane.
chmod +x build_dmg.sh
./build_dmg.shThe installer lands at dist/Switch2Bridge-Installer.dmg. Manual build:
pip install -r requirements-build.txt # adds py2app
python setup_app.py py2app
# โ dist/Switch2 Bridge.app- Launch the app โ a ๐ฎ appears in the menu bar
- Click โ Connect Controller
- Wait for ๐ข (connected)
- Open Ryujinx โ Options โ Settings โ Input
- Input Device: Keyboard
- Controller Type: Pro Controller
- Map keys using the table below
Default mapping:
| Button | Key | Button | Key | |
|---|---|---|---|---|
| A | Z | L | Q | |
| B | X | R | E | |
| X | C | ZL | 1 | |
| Y | V | ZR | 3 | |
| + | P | LS (click) | F | |
| - | M | RS (click) | G | |
| Home | H | GL (grip) | 9 | |
| Capture | O | GR (grip) | 0 |
| D-Pad | Key | Stick | Keys | |
|---|---|---|---|---|
| Up | โ | Left Stick | WASD | |
| Down | โ | Right Stick | IJKL | |
| Left | โ | |||
| Right | โ |
The first time you launch the app it writes a JSON config to:
~/Library/Application Support/Switch2Bridge/mappings.json
Edit it to remap any button or stick direction, then Reload mappings from the menubar (or restart the app). The menubar also has Edit mappings fileโฆ which reveals the file in Finder.
Each value is either a single character ("a", "5", "."), null to leave a button unmapped, or a named key in angle brackets: <up>, <down>, <left>, <right>, <space>, <enter>, <esc>, <tab>, <backspace>, <delete>, <home>, <end>, <pageup>, <pagedown>, <shift>, <ctrl>, <alt>, <cmd>, and <f1> โฆ <f20>.
The Switch 2's new C button is supported as "C" (unmapped by default โ set it to any key to use it).
The "ble" section holds two advanced settings:
input_char: leave itnullto auto-detect the controller's input characteristic (the bridge fills it in itself once detected), or set a 128-bit UUID to force one. See BLE Characteristics.address: leave itnullto connect to any available Switch 2 Pro Controller, or set a Bluetooth address/UUID to pin this instance to a specific controller (useful for multi-controller setups).
Invalid JSON falls back to defaults and the menubar surfaces the parse error. Unknown button names or stick directions (typos) are reported via a notification instead of being silently ignored. Two inputs may share the same key: the key is only released once both are released.
To run multiple controllers simultaneously, launch separate instances of the app with --config pointing to different mapping files:
- From a bundled .app:
open -n -a "Switch2 Bridge" --args --config ~/Library/Application\ Support/Switch2Bridge/pad2.json
- From source:
python Switch2Bridge.py --config ~/Library/Application\ Support/Switch2Bridge/pad2.json
If the specified config file does not exist, it is created with defaults.
Important
Avoid port and controller collisions when running multiple instances:
- Per-instance DSU ports: Every fresh configuration file defaults to DSU port
26760. Two instances cannot bind to the same UDP port. Edit the second configuration file to use another port (e.g.,"port": 26761) or disable DSU ("enabled": false) on that instance. - Controller assignment (
ble.address): By default, an instance connects to the first controller it discovers. To deterministically bind each instance to its own gamepad, pin each controller's Bluetooth address/UUID in its config file:The controller's address is printed in the connection log ("ble": { "input_char": null, "address": "B9EA5233-37EF-4DD6-8A31-9EEAE20F78F8" }
connected to Switch 2 Pro Controller @ <address>). - Per-instance logs: Each instance names its log file after the configuration file (e.g.
pad2.log), so instances do not interfere with each other's logs.
The app runs a cemuhook/DSU server (default 127.0.0.1:26760), which exposes the controller as a full gamepad over UDP โ analog sticks included, bypassing the keyboard bridge's 8-direction limitation.
- Dolphin โ Options โ Controller Settings โ Alternate Input Sources โ enable DSU Client, add
127.0.0.1:26760. The pad then appears as an input device with analog axes. - Cemu โ Input settings โ add a DSUController with the same address.
- Ryujinx โ uses DSU for motion only (Settings โ Input โ enable Motion โ Use CemuHook compatible motion). Buttons/sticks still go through the keyboard bridge. Motion data itself is not decoded yet (sent as zeros).
Configure in mappings.json:
"dsu": { "enabled": true, "host": "127.0.0.1", "port": 26760 }or toggle it from the menubar (DSU server item โ the checkmark shows it's listening). Button mapping on the DSU side is positional: AโCircle, BโCross, XโTriangle, YโSquare, โโShare, +โOptions, HomeโPS, CaptureโTouch. GL/GR/C have no DSU equivalent.
switch2bridge-macos/
โโโ Switch2Bridge.py # Menubar app (BLE client + keyboard bridge)
โโโ dsu_server.py # DSU (cemuhook) server โ analog output for emulators
โโโ setup_app.py # py2app configuration
โโโ build_dmg.sh # Automated build script (.app + DMG)
โโโ requirements.txt # Runtime dependencies
โโโ requirements-build.txt # + py2app, for the .app / DMG
โโโ ruff.toml # Lint config
โโโ tests/
โ โโโ test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle, calibration, CLI)
โ โโโ test_dsu.py # DSU server over real UDP
โโโ .github/
โ โโโ workflows/ci.yml # Lint + tests on every PR, DMG build on main
โ โโโ workflows/release.yml # Manual release: build, tag, publish
โ โโโ release-notes/ # One vX.Y.Z.md per release
โโโ AppIcon.icns # Application icon (used by py2app)
โโโ LICENSE
โโโ README.md
pip install -r requirements.txt
python tests/test_bridge.py # headless: BLE and keyboard are mocked
python tests/test_dsu.py # real UDP round-trips on localhost
pip install ruff && ruff check .CI runs the same on every pull request (macOS, Python 3.10 / 3.12 / 3.13).
Releasing โ bump APP_VERSION in Switch2Bridge.py (the single source of truth), add .github/release-notes/vX.Y.Z.md ({{SHA256}} is replaced by the DMG checksum), merge, then run Actions โ Release โ Run workflow with the tag. It refuses to publish if the tag doesn't match APP_VERSION or the release already exists.
โโโโโโโโโโโโโโโโโโโ BLE โโโโโโโโโโโโโโโโโโโ pynput โโโโโโโโโโโโโโโโโโโ
โ Switch 2 Pro โ โโโโโโโโโโโถ โ Python Bridge โ โโโโโโโโโโโถ โ Ryujinx โ
โ Controller โ (bleak) โ โ (keyboard) โ (Keyboard) โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
- BLE Connection โ uses
bleakto connect directly via Bluetooth LE - Input Parsing โ decodes the proprietary Nintendo protocol
- Keyboard Simulation โ uses
pynputto simulate key presses - Ryujinx โ reads keyboard input as if from a physical keyboard
| UUID | Purpose |
|---|---|
7492866c-ec3e-4619-8258-32755ffcc0f9 |
Input reports (notifications) โ absent on some units |
7492866c-ec3e-4619-8258-32755ffcc0f8 |
Notify-only (read, notify) โ carries the input reports on units without โฆf9 (#15); it can't be written, which is why LED/rumble never worked through it |
649d4ac9-8eb7-4e6c-af44-1ea54fe5f005 |
Command channel (write without response) |
c765a961-d9d8-4d36-a20a-5315b111836a |
Command replies (notifications) |
UUIDs from ndeadly/switch2_controller_research (bluetooth_interface.md) and the hardware findings in #14.
The input characteristic isn't fixed. An AU-market controller in #15 has no โฆf9 at all and streams its input reports from โฆf8 (which ndeadly also lists as the Pro Controller input). So the bridge resolves it at connect time rather than assuming it:
- the UUID pinned in
mappings.json(ble.input_char), if the device exposes it and it can notify; - otherwise
โฆf9; - otherwise it probes the remaining notifiable characteristics โ other known UUIDs (
โฆf8) first, then the same Nintendo vendor block, then other vendor UUIDs, SIG-assigned ones (batteryโฆ) last. Each candidate is subscribed for up to 3 s and only adopted once it streams a report of at least 11 bytes, enough to decode buttons and both sticks. The winner is written back toble.input_char, so later connections skip the probing.
Every connection also logs the full GATT table (GATT: N characteristic(s): โฆ) to ~/Library/Logs/Switch2Bridge/bridge.log โ that line is what a bug report needs when a controller can't be identified.
| Byte | Content |
|---|---|
| 2 | 0x01 B, 0x02 A, 0x04 Y, 0x08 X, 0x10 R, 0x20 ZR, 0x40 +, 0x80 RS |
| 3 | 0x01 Down, 0x02 Right, 0x04 Left, 0x08 Up, 0x10 L, 0x20 ZL, 0x40 โ, 0x80 LS |
| 4 | 0x01 Home, 0x02 Capture, 0x04 GR, 0x08 GL, 0x10 C |
| 5โ7 / 8โ10 | Left / right stick, two packed 12-bit values each |
Byte 4 follows ndeadly's hid_reports.md, espp's Pro Controller 2 report and the capture in #14 โ up to v1.2.4 the bridge had C and Capture swapped.
At connect the bridge reads the controller's factory calibration over the command channel (SPI reads of 0x130A8 for the left stick and 0x130E8 for the right, 9 bytes each: centre, +travel, โtravel as packed 12-bit pairs), then lights the player 1 LED. Real travel is only ~1500โ1770 counts rather than the nominal 2048, so without it a fully pushed stick tops out around 0.8 in DSU. If the controller doesn't answer, the bridge silently keeps the nominal range; the menubar shows sticks calibrated when it worked.
Frame format and addresses come from kennethreitz's fork (issue #14, MIT), itself based on BlueRetro #1249; the addresses match SDL's Switch 2 driver.
- The controller never appears in System Settings โ Bluetooth โ that's expected, and not a failure. This bridge is a BLE client: there is no system-level pairing, so macOS will never list the controller. The only place to watch is the app's menubar icon (๐ โ ๐ข).
- "Controller not found" โ make sure the controller is not paired with a console nearby (unpair it or put the console to sleep far away). Click Connect Controller first โ the search now runs for 30 s โ then hold the small pair button on the back until the LEDs sweep back and forth.
- No Bluetooth prompt ever appeared (run-from-source) โ the permission belongs to Terminal/Python, not the app. Check
System Settings โ Privacy & Security โ Bluetoothand enable Terminal, then relaunch. Without it, scans silently find nothing. - "Characteristic โฆ was not found" / "no readable input characteristic" โ the controller connected but its input-report characteristic isn't where the bridge expects it. Since v1.2.4 the bridge probes the alternatives automatically and remembers what worked, so retry once โ and move the sticks while it says it is identifying the controller, in case that revision only reports on change. If it still gives up, the log now contains a
GATT:line listing every service and characteristic of your controller โ attach it to an issue. You can also pin a UUID yourself:"ble": { "input_char": "โฆ" }inmappings.json. - Menubar says ๐ข connected but inputs don't reach the emulator โ macOS Accessibility permission is missing. Grant it in System Settings โ Privacy & Security โ Accessibility, then relaunch the app. (The app should also pop an alert about this on first launch.)
- Logs โ written to
~/Library/Logs/Switch2Bridge/bridge.log(or<config_name>.logwhen running with--config). Open a terminal andtail -fit to watch what's happening in real time.
| Feature | Status | Notes |
|---|---|---|
| Buttons | โ Working | All buttons mapped |
| C button | โ Fixed in v1.3.0 | Byte 4, bit 0x10 (was wrongly read as 0x02, i.e. Capture, up to v1.2.4) |
| Analog Sticks | โ Analog via DSU | Full 12-bit analog through the DSU server (Dolphin/Cemu), scaled with the controller's factory calibration. The keyboard bridge remains digital: thresholded (with hysteresis) to 8 directions (WASD/IJKL). |
| LED Control | ๐งช Player 1 only | Set once on connect through the command channel |
| Rumble | โ Not implemented | Goes through the command/vibration channels, not โฆc0f8 |
| Motion/Gyro | DSU motion fields are sent (as zeros) โ the gyro bytes in the BLE report are not decoded yet | |
| Native HID | โ Not implemented | Would need a DriverKit system extension (virtual gamepad), which requires a paid Apple Developer account to sign |
Contributions welcome! Areas that need work:
- Rumble โ the command channel is now used for calibration and LEDs; rumble is the next step (see #14)
- Motion controls โ decode gyro/accelerometer data
- Native gamepad โ virtual HID device via DriverKit, so games see a real controller (analog already works through DSU)
- Cross-platform โ Linux/Windows ports
- Aurรฉlien Desert โ reverse engineering & implementation
- Claude (Anthropic) โ development assistance
- Inspired by SPro2Win (Windows)
- Protocol reference from Nintendo Switch Reverse Engineering
- Switch 2 BLE protocol: ndeadly/switch2_controller_research, darthcloud & german77 (BlueRetro #1249)
- C/Capture fix, stick calibration and command channel findings: issue #14 and kennethreitz's fork
MIT License โ see LICENSE for details.
โญ Star this repo if it helped you!
First macOS BLE bridge for Switch 2 Pro Controller โ January 2026