Skip to content

Latest commit

ย 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐ŸŽฎ Switch 2 Pro Controller โ€” macOS BLE Bridge

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

Release CI macOS Python License


โš ๏ธ What This Is (and Isn't)

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

๐Ÿš€ Features

  • โœ… 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)

๐Ÿค” Why This Exists

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.

๐Ÿ“‹ Requirements

  • macOS Ventura (13.0) or later
  • Nintendo Switch 2 Pro Controller
  • Python 3.10+ only to run from source (bleak 3 requires it)

๐Ÿ“ฅ Install

  1. Download Switch2Bridge-vX.Y.Z.dmg from the latest release (its SHA-256 is in the release notes)
  2. Open it and drag Switch2 Bridge to Applications
  3. 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
  4. Grant Accessibility at launch and Bluetooth on the first Connect Controller

๐Ÿ”ง Run from source

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.py

macOS will ask for two permissions:

  1. Accessibility โ€” prompted at first launch (to simulate keyboard input)
  2. Bluetooth โ€” prompted the first time you click Connect Controller (not at launch!)

โš ๏ธ When running from source, the Bluetooth permission belongs to Terminal (or your Python interpreter), not to the app. If no prompt ever appears, add/enable Terminal manually in System Settings โ†’ Privacy & Security โ†’ Bluetooth, then relaunch. The app detects a denied permission and offers to open the right settings pane.

๐Ÿ“ฆ Build a standalone .app + DMG

chmod +x build_dmg.sh
./build_dmg.sh

The 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

๐ŸŽฏ Usage

  1. Launch the app โ€” a ๐ŸŽฎ appears in the menu bar
  2. Click โ†’ Connect Controller
  3. Wait for ๐ŸŸข (connected)
  4. Open Ryujinx โ†’ Options โ†’ Settings โ†’ Input
    • Input Device: Keyboard
    • Controller Type: Pro Controller
    • Map keys using the table below

๐ŸŽฎ Button Mapping

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 โ†’

Custom mappings

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 it null to 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 it null to 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.

Multi-controller and multiple instances

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:

  1. 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.
  2. 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:
    "ble": {
      "input_char": null,
      "address": "B9EA5233-37EF-4DD6-8A31-9EEAE20F78F8"
    }
    The controller's address is printed in the connection log (connected to Switch 2 Pro Controller @ <address>).
  3. 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.

๐Ÿ•น๏ธ DSU server โ€” true analog sticks (no driver)

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.

๐Ÿ“ Project Structure

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

๐Ÿ› ๏ธ Development

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.

๐Ÿ”ฌ Technical Details

How It Works

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     BLE      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”    pynput    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Switch 2 Pro   โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ  โ”‚  Python Bridge  โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ  โ”‚    Ryujinx      โ”‚
โ”‚   Controller    โ”‚   (bleak)    โ”‚                 โ”‚  (keyboard)  โ”‚   (Keyboard)    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜              โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
  1. BLE Connection โ€” uses bleak to connect directly via Bluetooth LE
  2. Input Parsing โ€” decodes the proprietary Nintendo protocol
  3. Keyboard Simulation โ€” uses pynput to simulate key presses
  4. Ryujinx โ€” reads keyboard input as if from a physical keyboard

BLE Characteristics

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:

  1. the UUID pinned in mappings.json (ble.input_char), if the device exposes it and it can notify;
  2. otherwise โ€ฆf9;
  3. 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 to ble.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.

Input report

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.

Stick calibration

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.

๐Ÿฉบ Troubleshooting

  • 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 โ†’ Bluetooth and 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": "โ€ฆ" } in mappings.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>.log when running with --config). Open a terminal and tail -f it to watch what's happening in real time.

๐Ÿšง Limitations

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 โš ๏ธ Plumbing ready 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

๐Ÿค Contributing

Contributions welcome! Areas that need work:

  1. Rumble โ€” the command channel is now used for calibration and LEDs; rumble is the next step (see #14)
  2. Motion controls โ€” decode gyro/accelerometer data
  3. Native gamepad โ€” virtual HID device via DriverKit, so games see a real controller (analog already works through DSU)
  4. Cross-platform โ€” Linux/Windows ports

๐Ÿ“œ Credits

๐Ÿ“„ License

MIT License โ€” see LICENSE for details.


โญ Star this repo if it helped you!
First macOS BLE bridge for Switch 2 Pro Controller โ€” January 2026

About

Bluetooth bridge for the Nintendo Switch 2 Pro Controller on macOS.

Topics

Resources

Stars

32 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages