-
Notifications
You must be signed in to change notification settings - Fork 29
feat(switch2_pro): Nintendo Switch 2 Pro Controller BLE emulation #765
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
finger563
wants to merge
17
commits into
main
Choose a base branch
from
feat/switch2-pro
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
623e7c0
feat(switch2_pro): GATT + pairing skeleton for Switch 2 Pro Controlle…
finger563 d9df47b
feat(switch2_pro): send pairing responses + init command replies (mil…
finger563 2f03c1d
feat(switch2_pro): target S3 + trace the handshake for on-hardware pa…
finger563 d9c26b6
fix(switch2_pro): keep Nintendo manufacturer data in the primary adve…
finger563 1a181ae
debug(switch2_pro): trace all characteristic reads/writes/subscriptions
finger563 7c42fb8
debug(switch2_pro): dump GATT handle map + enable NimBLE stack logging
finger563 5bc83a4
debug(switch2_pro): fix handle-map timing, NimBLE DEBUG log, auth trace
finger563 1a0f2ea
debug(switch2_pro): actually set NimBLE log to DEBUG (was INFO)
finger563 451e6ec
fix(switch2_pro): disable BLE bonding + clear stale bonds
finger563 40f7cd8
fix(switch2_pro): patcher uses GNU ar (macOS BSD ar can't read the ar…
finger563 eac8765
chore(esp-nimble-cpp): bump submodule for NimBLEServer::registerServi…
finger563 4c7326b
feat(ble_gatt_server): add conn_params_update_callback
finger563 b14ba2f
feat(switch2_pro): working Switch 2 Pro Controller BLE emulation (C6)…
finger563 8e31b14
fix(switch2_pro): address PR review (Copilot + cppcheck)
finger563 b1c63ee
fix(switch2_pro): address 2nd PR review round (Copilot)
finger563 e362ede
fix(switch2_pro): address 3rd PR review round + self-review
finger563 0279fca
fix(switch2_pro): address follow-up PR review (wake guard + patcher a…
finger563 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Submodule esp-nimble-cpp
updated
12 files
| +13 −2 | src/NimBLEAddress.cpp | |
| +7 −3 | src/NimBLEAttValue.cpp | |
| +17 −2 | src/NimBLEDevice.cpp | |
| +10 −8 | src/NimBLEEddystoneTLM.cpp | |
| +2 −2 | src/NimBLEEddystoneTLM.h | |
| +35 −6 | src/NimBLEScan.cpp | |
| +2 −0 | src/NimBLEScan.h | |
| +50 −2 | src/NimBLEServer.cpp | |
| +2 −0 | src/NimBLEServer.h | |
| +2 −2 | src/NimBLEStream.cpp | |
| +2 −1 | src/NimBLEStream.h | |
| +1 −1 | src/NimBLEUtils.cpp |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| example/build/ | ||
| example/sdkconfig | ||
| example/sdkconfig.old |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| idf_component_register( | ||
| INCLUDE_DIRS "include" | ||
| SRC_DIRS "src" | ||
| REQUIRES base_component ble_gatt_server esp-nimble-cpp timer | ||
| PRIV_REQUIRES mbedtls) | ||
|
|
||
| # Opt-in: patch the prebuilt BLE controller library to accept the console's | ||
| # sub-spec 5 ms connection interval. Off by default. Covers the RISC-V NimBLE | ||
| # controller (C6/C61/C2/H2, libble_app.a) and the BTDM/RivieraWaves controller | ||
| # (S3/C3, libbtdm_app.a) — the patcher picks the right object + byte pattern per | ||
| # target. Mutates the global $IDF_PATH install, so it is deliberately explicit | ||
| # and never silent. | ||
| if(CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS) | ||
| if(IDF_TARGET STREQUAL "esp32c6" OR IDF_TARGET STREQUAL "esp32c61" | ||
| OR IDF_TARGET STREQUAL "esp32c2" OR IDF_TARGET STREQUAL "esp32h2" | ||
| OR IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3") | ||
| message(WARNING | ||
| "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS is ON: patching the prebuilt " | ||
| "BLE controller library in $ENV{IDF_PATH} for a 5 ms connection interval " | ||
| "(${IDF_TARGET}). This modifies your global ESP-IDF install; run " | ||
| "tools/patch_nimble_5ms.py --target ${IDF_TARGET} --restore to undo.") | ||
| find_package(Python3 COMPONENTS Interpreter REQUIRED) | ||
| execute_process( | ||
| COMMAND ${Python3_EXECUTABLE} | ||
| ${CMAKE_CURRENT_LIST_DIR}/tools/patch_nimble_5ms.py | ||
| --idf-path $ENV{IDF_PATH} --target ${IDF_TARGET} | ||
| RESULT_VARIABLE _switch2_patch_result) | ||
| if(NOT _switch2_patch_result EQUAL 0) | ||
| message(FATAL_ERROR "[switch2_pro] 5 ms controller patch failed (${_switch2_patch_result})") | ||
| endif() | ||
| else() | ||
| message(WARNING | ||
| "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS has no effect on ${IDF_TARGET}: " | ||
| "no known controller patch for this target (supported: C6/C61/C2/H2 NimBLE, " | ||
| "S3/C3 BTDM).") | ||
| endif() | ||
| endif() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| # switch2_pro — design notes | ||
|
|
||
| ## Goal | ||
|
|
||
| Emulate a **Nintendo Switch 2 Pro Controller over BLE** so a real Switch 2 console | ||
| accepts it as a native controller — including waking the console from sleep over BLE. | ||
|
|
||
| This is NOT the Switch 1 protocol. The Switch 2 moved controllers from Bluetooth | ||
| Classic HID to **BLE with a proprietary GATT layer** (not HID-over-GATT), a custom | ||
| pairing scheme (not BLE SMP), and a custom command channel. So espp's existing | ||
| `hid_service` / `hid-rp` (standard HOGP + report descriptors) do **not** apply here; | ||
| this component builds custom GATT services directly on `espp::BleGattServer`. | ||
|
|
||
| ## Sources / prior art | ||
|
|
||
| - **Protocol facts**: `ndeadly/switch2_controller_research` (byte-level GATT map, | ||
| pairing handshake, command set, report formats; decrypted sniffer captures). | ||
| - **Working ESP32 reference** (MIT): `zhantss/ESP32-BLE5-NSController-Emulator` — | ||
| raw-NimBLE C emulator that a real Switch 2 accepts. We adapt its *approach and | ||
| structure* (with attribution) and reimplement on esp-nimble-cpp / `BleGattServer`. | ||
| We do **not** copy ndeadly's prose/tables wholesale, and we do **not** vendor | ||
| Espressif's `libble_app.a`. | ||
|
|
||
| ## Feasibility (verified) | ||
|
|
||
| Not blocked by cryptographic attestation. The pairing "authentication" is weak and | ||
| reproducible: a **fixed controller key** `B1 = 5CF6EE792CDF05E1BA2B6325C41A5F10`, an | ||
| XOR-derived link key `LTK = A1 ⊕ B1`, and a single AES-128-ECB possession proof | ||
| `B2 = AES_ECB(reverse(LTK), reverse(A2))`. Golden vector (host-verified with openssl): | ||
|
|
||
| A1 = 3503e92982877124bea80c664615834b (host public key, from console) | ||
| B1 = 5cf6ee792cdf05e1ba2b6325c41a5f10 (fixed controller key) | ||
| A2 = 6fc6df8ad8fedf15bb8c15e91f320544 (host challenge) | ||
| LTK = 69f50750ae5874c504836f43820fdc5b (= A1 ⊕ B1) | ||
| B2 = 134c97f511b9b6dd4d86fd40f536e9ed (= AES-128-ECB(rev(LTK), rev(A2))) | ||
|
|
||
| `switch2_pro_pairing.*` implements this and self-tests against the golden vector at | ||
| init (logged pass/fail) — verifiable on-device with no console. | ||
|
|
||
| ## The 5 ms connection-interval problem | ||
|
|
||
| The console drives the link at a **5 ms** connection interval — below the 7.5 ms BLE | ||
| spec minimum. The controller stack must accept it or the console won't stream input. | ||
|
|
||
| Both chip families keep the 7.5 ms floor as a hard compare inside a closed controller | ||
| library, and both are patchable with a single-instruction edit that lowers the floor | ||
| to 4 units (5 ms). `tools/patch_nimble_5ms.py` picks the right archive, object and byte | ||
| pattern per `--target`; `tools/smoke_test_5ms.py` proves the edit at the disassembly | ||
| level with no hardware. | ||
|
|
||
| - **C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): patch | ||
| `$IDF_PATH/.../libble_app.a`, object `ble_ll_conn.c.o`. The floor is | ||
| `addi a5, a4, -6`; flip the immediate to `-4` (`93 07 a7 ff` → `93 07 c7 ff`). | ||
| Adapted from zhantss (MIT). | ||
| - **S3 / C3** (BTDM / RivieraWaves controller, `lib_esp32c3_family/*/libbtdm_app.a` | ||
| and the `libbtdm_app_flash.a` variant): patch object `llc_con_upd.o`, function | ||
| `r_llc_con_upd_param_in_range` — the peripheral-side connection-parameter validator | ||
| the console's `LL_CONNECTION_PARAM_REQ` / `LL_CONNECTION_UPDATE_IND` path runs | ||
| through (confirmed: its only caller is the RivieraWaves `ip_funcs` jump table; its | ||
| siblings are `ll_connection_param_req_handler` / `ll_connection_update_ind_handler`). | ||
| The floor is a compare of the requested min-interval against 6: | ||
| - **S3** (Xtensa): `bltui a4, 6` → `bltui a4, 4` (`b6 64 01` → `b6 44 01`). | ||
| - **C3** (RISC-V): `li a6,5; bgeu a6,a2` → `li a6,3` (`15 48` → `0d 48`). | ||
|
|
||
| Both reverse-engineered here from the same reject-below-6 semantics as the C6 patch; | ||
| each is the single unique occurrence in its object (asserted by the patcher). The | ||
| latency bound sitting right beside the floor (`499` = `0x1f3`, the BLE max latency) | ||
| confirms the surrounding code is the connection-parameter range check. This replaces | ||
| the earlier note about `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` / | ||
| esp-idf#18467, which does **not** exist on IDF 6.0.1. | ||
|
|
||
| **Build integration (decision: opt-in, never silent).** The patch mutates the user's | ||
| global IDF install and is version-fragile (the byte pattern is not guaranteed across | ||
| IDF versions — the patcher refuses to run if the pattern is missing or non-unique). So | ||
| it is gated behind a component Kconfig option `SWITCH2_PRO_PATCH_NIMBLE_5MS` | ||
| (default **n**). When enabled for a supported target, the component CMake invokes the | ||
| patcher at configure time (idempotent) and prints a loud notice. It is **not required | ||
| for the GATT + pairing skeleton milestone** — pairing runs over the command channel | ||
| independent of the interval. | ||
|
|
||
| ## GATT layout (reproduced from captures) | ||
|
|
||
| Two proprietary primary services; contiguous handles matter for some console | ||
| firmwares (FW 2.0.0+ shifts them +8 for headset audio, so absolute-handle dependence | ||
| is not strict — we reproduce the map but discover by UUID). | ||
|
|
||
| 00c5af5d-1964-4e30-8f51-1956f96bd280 (svc1, purpose unclear; chars …281/282/283) | ||
| ab7de9be-89fe-49ad-828f-118f09df7fd0 (svc2, main) | ||
| ab7de9be-…-fd2 READ/NOTIFY common input report (0x05) | ||
| 7492866c-… READ/NOTIFY Pro Controller 2 input report (0x09) | ||
| cc483f51-… WRITE_NR vibration / HD rumble | ||
| 649d4ac9-… WRITE_NR command (basic) | ||
| 3dacbc7e-… WRITE_NR vibration+command combined (pairing runs here) | ||
| 4147423d-… WRITE firmware update (large) | ||
| c765a961-… NOTIFY command response #1 | ||
| 506d9f7d-… NOTIFY command response #2 | ||
|
|
||
| Security: **no SMP** — the console app-level-pairs over the command channel and will | ||
| disconnect a peer that initiates SMP. We configure NimBLE not to initiate pairing; | ||
| the LTK from the 0x15 exchange is what encrypts the link. Bond (host addr + LTK) | ||
| persists in NVS for reconnect + wake. | ||
|
|
||
| ## Milestones (all implemented; verified end-to-end on ESP32-C6) | ||
|
|
||
| 1. **GATT + pairing skeleton**: custom GATT tree stands up, advertises with | ||
| Nintendo manufacturer data, completes the 0x15 pairing handshake (crypto | ||
| known-answer verified) and the console accepts pairing. | ||
| 2. **Command dispatch + init sequence** (flash/calibration reads, feature-select, | ||
| LEDs, firmware-update-prompt suppression) so the console finishes bring-up. | ||
| 3. **Input report streaming** (report 0x09: buttons incl. C/GL/GR, 12-bit sticks, | ||
| IMU block) streamed continuously at the console's 15 ms / ~62 Hz cadence with | ||
| real backpressure. Runs on the spec-legal 15 ms interval — no patch needed. | ||
| 4. **Reconnect + wake-from-sleep** (bonded reconnect with the 0x81 wake flag). | ||
| The console connects a bonded controller at 5 ms, so these need the opt-in | ||
| `SWITCH2_PRO_PATCH_NIMBLE_5MS` controller patch. | ||
|
|
||
| On the ESP32-S3 the BTDM controller does not yet sustain the encrypted input | ||
| stream (see the README "Known issues"); C6-class chips (open NimBLE controller) | ||
| are the supported target. | ||
|
|
||
| ## Component layout | ||
|
|
||
| switch2_pro/ | ||
| include/switch2_pro.hpp Switch2Pro class (over BleGattServer) | ||
| include/switch2_pro_protocol.hpp UUIDs, command/subcommand ids, feature bits, fixed key, golden vector | ||
| include/switch2_pro_report.hpp Pro Controller 2 input report (0x09) packed struct | ||
| src/switch2_pro.cpp GATT setup, advertising, GAP, command dispatch | ||
| src/switch2_pro_pairing.cpp pairing crypto (mbedTLS) + state machine + self-test | ||
| tools/patch_nimble_5ms.py opt-in 5 ms interval patcher (C6/C61/C2/H2 NimBLE + S3/C3 BTDM) | ||
| tools/smoke_test_5ms.py hardware-free verifier (disassembles the controller floor) | ||
| Kconfig SWITCH2_PRO_PATCH_NIMBLE_5MS opt-in | ||
| example/ C6-primary, S3-buildable |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| menu "Switch 2 Pro Controller" | ||
|
|
||
| config SWITCH2_PRO_PATCH_NIMBLE_5MS | ||
| bool "Patch the BLE controller to allow the console's 5 ms connection interval" | ||
| default n | ||
| help | ||
| On RECONNECT and WAKE-FROM-SLEEP the console connects a recognised | ||
| (bonded) controller at a 5 ms connection interval, below the 7.5 ms | ||
| Bluetooth spec minimum, chosen in its CONNECT_IND. The controller | ||
| stack must accept that sub-spec interval or the connection never | ||
| forms. | ||
|
|
||
| When enabled, the component build patches the prebuilt closed | ||
| controller library in your global $IDF_PATH install to lower the | ||
| minimum-interval floor from 6 units (7.5 ms) to 4 (5 ms): | ||
| * ESP32-C6/C61/C2/H2 — NimBLE controller (libble_app.a) | ||
| * ESP32-S3/C3 — BTDM/RivieraWaves controller (libbtdm_app.a) | ||
| THIS MODIFIES YOUR ESP-IDF INSTALLATION. Undo with | ||
| tools/patch_nimble_5ms.py --target <chip> --restore, and verify with | ||
| tools/smoke_test_5ms.py --target <chip>. | ||
|
|
||
| Required for reconnect and wake-from-sleep. NOT required for fresh | ||
| pairing or first-session input streaming (those use the spec-legal | ||
| 15 ms interval). Leave OFF unless you understand the consequences. | ||
|
|
||
| endmenu |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.