Skip to content
Open
Show file tree
Hide file tree
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 Aug 12, 2026
d9df47b
feat(switch2_pro): send pairing responses + init command replies (mil…
finger563 Aug 12, 2026
2f03c1d
feat(switch2_pro): target S3 + trace the handshake for on-hardware pa…
finger563 Aug 13, 2026
d9c26b6
fix(switch2_pro): keep Nintendo manufacturer data in the primary adve…
finger563 Aug 14, 2026
1a181ae
debug(switch2_pro): trace all characteristic reads/writes/subscriptions
finger563 Aug 14, 2026
7c42fb8
debug(switch2_pro): dump GATT handle map + enable NimBLE stack logging
finger563 Aug 14, 2026
5bc83a4
debug(switch2_pro): fix handle-map timing, NimBLE DEBUG log, auth trace
finger563 Aug 14, 2026
1a0f2ea
debug(switch2_pro): actually set NimBLE log to DEBUG (was INFO)
finger563 Aug 14, 2026
451e6ec
fix(switch2_pro): disable BLE bonding + clear stale bonds
finger563 Aug 14, 2026
40f7cd8
fix(switch2_pro): patcher uses GNU ar (macOS BSD ar can't read the ar…
finger563 Aug 14, 2026
eac8765
chore(esp-nimble-cpp): bump submodule for NimBLEServer::registerServi…
finger563 Sep 3, 2026
4c7326b
feat(ble_gatt_server): add conn_params_update_callback
finger563 Sep 3, 2026
b14ba2f
feat(switch2_pro): working Switch 2 Pro Controller BLE emulation (C6)…
finger563 Sep 3, 2026
8e31b14
fix(switch2_pro): address PR review (Copilot + cppcheck)
finger563 Sep 3, 2026
b1c63ee
fix(switch2_pro): address 2nd PR review round (Copilot)
finger563 Sep 3, 2026
e362ede
fix(switch2_pro): address 3rd PR review round + self-review
finger563 Sep 4, 2026
0279fca
fix(switch2_pro): address follow-up PR review (wake guard + patcher a…
finger563 Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ jobs:
target: esp32
- path: 'components/stream_frame/example'
target: esp32
- path: 'components/switch2_pro/example'
target: esp32c6
- path: 'components/switch2_pro/example'
target: esp32s3
- path: 'components/sx126x/example'
target: esp32s3
- path: 'components/t-deck/example'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/upload_components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ jobs:
components/st25dv
components/st7123touch
components/state_machine
components/switch2_pro
components/sx126x
components/t_keyboard
components/t-deck
Expand Down
42 changes: 34 additions & 8 deletions components/ble_gatt_server/include/ble_gatt_server.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ class BleGattServer : public BaseComponent {
/// @param conn_info The connection information for the device.
typedef std::function<void(const NimBLEConnInfo &)> authentication_complete_callback_t;

/// @brief Callback for when the connection parameters are updated (fires on
/// completion of any connection-parameter-update procedure — whether
/// peer- or self-initiated, accepted or rejected; read the live
/// parameters from conn_info to see the outcome).
/// @param conn_info The connection information for the device.
typedef std::function<void(NimBLEConnInfo &)> conn_params_update_callback_t;

/// @brief Callback to retrieve the passkey for the device.
/// @return The passkey for the device.
typedef std::function<uint32_t(void)> get_passkey_callback_t;
Expand Down Expand Up @@ -131,6 +138,8 @@ class BleGattServer : public BaseComponent {
nullptr; ///< Callback for when a device disconnects from the GATT server.
authentication_complete_callback_t authentication_complete_callback =
nullptr; ///< Callback for when a device completes authentication.
conn_params_update_callback_t conn_params_update_callback =
nullptr; ///< Callback for when the connection parameters are updated.
get_passkey_callback_t get_passkey_callback =
nullptr; ///< Callback for getting the passkey.
/// @note If not provided, will simply return
Expand Down Expand Up @@ -259,15 +268,26 @@ class BleGattServer : public BaseComponent {
// set the server callbacks
server_->setCallbacks(new BleGattServerCallbacks(this));

// create the device info service
device_info_service_.init(server_);
if (builtin_info_services_) {
// create the device info service
device_info_service_.init(server_);

// create the battery service
battery_service_.init(server_);
// create the battery service
battery_service_.init(server_);
}

return true;
}

/// Enable or disable the built-in Device Information and Battery services.
/// @param enabled Whether init()/start_services() create and start the
/// built-in Device Information (0x180A) and Battery (0x180F) services.
/// Defaults to true. Set to false BEFORE init() for peripherals that
/// must expose only their own services (e.g. emulating a device whose
/// GATT layout must match a specific attribute table).
/// @note Must be called before init().
void set_builtin_info_services_enabled(bool enabled) { builtin_info_services_ = enabled; }

/// Deinitialize the GATT server
/// This method deletes the server and all associated objects.
/// It also invalidates any references/pointers to the server.
Expand All @@ -283,8 +303,10 @@ class BleGattServer : public BaseComponent {
}

// deinitialize the services
device_info_service_.deinit();
battery_service_.deinit();
if (builtin_info_services_) {
device_info_service_.deinit();
battery_service_.deinit();
}
// if true, deletes all server/advertising/scan/client objects which
// invalidates any references/pointers to them
bool clear_all = true;
Expand All @@ -296,8 +318,10 @@ class BleGattServer : public BaseComponent {
/// Start the services
/// This method starts the device info and battery services.
void start_services() {
device_info_service_.start();
battery_service_.start();
if (builtin_info_services_) {
device_info_service_.start();
battery_service_.start();
}
}

/// Start the server
Expand Down Expand Up @@ -806,6 +830,8 @@ class BleGattServer : public BaseComponent {
NimBLEServer *server_{nullptr}; ///< The GATT server.
DeviceInfoService device_info_service_; ///< The device info service.
BatteryService battery_service_; ///< The battery service.
bool builtin_info_services_{
true}; ///< Whether to create/start the built-in DIS + battery services.
};
} // namespace espp

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class BleGattServerCallbacks : public NimBLEServerCallbacks {
virtual void onConnect(NimBLEServer *server, NimBLEConnInfo &conn_info) override;
virtual void onDisconnect(NimBLEServer *server, NimBLEConnInfo &conn_info, int reason) override;
virtual void onAuthenticationComplete(NimBLEConnInfo &conn_info) override;
virtual void onConnParamsUpdate(NimBLEConnInfo &conn_info) override;
virtual uint32_t onPassKeyDisplay() override;
virtual void onConfirmPassKey(NimBLEConnInfo &conn_info, uint32_t pass_key) override;

Expand Down
7 changes: 7 additions & 0 deletions components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ void BleGattServerCallbacks::onAuthenticationComplete(NimBLEConnInfo &conn_info)
}
}
}
void BleGattServerCallbacks::onConnParamsUpdate(NimBLEConnInfo &conn_info) {
if (server_) {
if (server_->callbacks_.conn_params_update_callback) {
server_->callbacks_.conn_params_update_callback(conn_info);
}
}
}
uint32_t BleGattServerCallbacks::onPassKeyDisplay() {
if (server_ && server_->callbacks_.get_passkey_callback) {
return server_->callbacks_.get_passkey_callback();
Expand Down
3 changes: 3 additions & 0 deletions components/switch2_pro/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
example/build/
example/sdkconfig
example/sdkconfig.old
37 changes: 37 additions & 0 deletions components/switch2_pro/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
idf_component_register(
Comment thread
finger563 marked this conversation as resolved.
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()
132 changes: 132 additions & 0 deletions components/switch2_pro/DESIGN.md
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
26 changes: 26 additions & 0 deletions components/switch2_pro/Kconfig
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
Loading
Loading