Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
7fa5f6f
fix(switch2_pro): make paired_/reconnect_mode_ atomic (cross-thread r…
finger563 Sep 4, 2026
d9f65da
docs(switch2_pro): document official ESP-IDF S3/C3 5 ms support
finger563 Sep 4, 2026
c179242
docs(switch2_pro): promote ESP32-S3 to a verified target (ESP-IDF v6.1)
finger563 Sep 4, 2026
f421a1f
fix(switch2_pro): address PR review comments
finger563 Sep 4, 2026
0134b23
fix(switch2_pro): finish 5 ms narrative correction + drop unverified …
finger563 Sep 4, 2026
7405d33
fix(switch2_pro): address Copilot suppressed-comment findings
finger563 Sep 4, 2026
816dd8d
fix(switch2_pro): address latest review round
finger563 Sep 4, 2026
69b8c21
fix(switch2_pro): propagate advertising failure + doc fixes
finger563 Sep 4, 2026
bb9b74d
fix(switch2_pro): wake-timer lifecycle + NOTIFY_TX status accounting
finger563 Sep 4, 2026
8c2ca5e
Start wake timer only if boot wake is pending
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

# build folder for ESP-IDF
build/
# alternate/per-target ESP-IDF build dirs (e.g. build_s3, build_c6)
build_*/


# we only version sdkconfig.defaults
Expand Down
51 changes: 43 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(const 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,35 @@ 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(). Calling it after init() has no effect
/// (the built-in services are created/skipped during init) and is ignored
/// with a warning, since honoring it would leave services created but never
/// started or torn down.
void set_builtin_info_services_enabled(bool enabled) {
if (server_) {
logger_.warn("set_builtin_info_services_enabled() ignored: must be called before init()");
return;
}
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 +312,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 +327,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 +839,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
65 changes: 65 additions & 0 deletions components/switch2_pro/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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 ONLY the open RISC-V
# NimBLE controller (C6/C61/C2/H2, libble_app.a), which has no config option for a
# sub-spec interval. S3/C3 use ESP-IDF >= v6.1's official
# CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE instead (see below) — they are NOT
# patch targets. 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")
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()
elseif(IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3")
message(FATAL_ERROR
"[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS does not support ${IDF_TARGET}. Use "
"ESP-IDF >= v6.1's official CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (default "
"on) for S3/C3 5 ms support instead — no binary patch needed. A patch of the "
"pre-fix BTDM controller was never confirmed to work (espressif/esp-idf#18467). "
"Disable this option for ${IDF_TARGET}.")
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).")
endif()
endif()

# ESP32-S3 / C3: reconnect and wake-from-sleep need the console's sub-spec (5 ms)
# connection interval to be accepted by the closed BTDM controller. The official,
# default-on way is CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE, which requires
# ESP-IDF >= v6.1 (or the v6.0.2 / v5.5.x / v5.4.x / v5.3.x backports;
# espressif/esp-idf#18467). Warn at configure time if neither that option nor the
# legacy binary patch is enabled — reconnect/wake will otherwise silently fail
# (fresh pairing and first-session input still work).
if(IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3")
if(NOT CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE
AND NOT CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS)
idf_build_get_property(_switch2_idf_ver IDF_VERSION)
message(WARNING
"[switch2_pro] ${IDF_TARGET}: neither CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE "
"(official ESP-IDF sub-spec-interval support) nor SWITCH2_PRO_PATCH_NIMBLE_5MS is "
"enabled. The console drives the link at 5 ms for SUSTAINED input (it drops even the "
"fresh session to 5 ms ~1.5 s after subscription) as well as reconnect/wake, so only "
"the initial pairing handshake (~15 ms) will work here — sustained input, reconnect, "
"and wake will fail. Update to ESP-IDF >= v6.1 (you have ${_switch2_idf_ver}) for the "
"default-on option. See espressif/esp-idf#18467.")
endif()
endif()
138 changes: 138 additions & 0 deletions components/switch2_pro/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# 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 reconnect/wake won't form.

Two routes by chip family:

**ESP32-S3 / C3 — official ESP-IDF option (the verified path).** ESP-IDF added
`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default `y`), which lets the BTDM
controller and the BLE host accept sub-spec intervals (down to 3.75 ms) with no
binary patching — in ESP-IDF ≥ v6.0 (`142aea3`) / v5.5 (`cf13345`) / v5.4
(`aefcf1c`) / v5.3 (`9831261`), and confirmed on **v6.1**; see
espressif/esp-idf#18467. The S3 is verified end-to-end on real hardware this way,
and v6.1's updated controller lib also fixes the sustained-tx stall seen on v6.0.1.
There is **no binary-patch fallback for S3/C3**: a patch of the pre-fix BTDM
library (`libbtdm_app.a`, `r_llc_con_upd_param_in_range` — the peripheral-side
connection-parameter validator) was reverse-engineered but never confirmed to
enable 5 ms on hardware (patching that min-interval compare alone is reported
insufficient on the pre-fix S3, esp-idf#18467, matching our own testing where it
had no effect), so it is not shipped. Those RE notes live in git history.

**ESP32-C6 / C61 / C2 / H2 — binary patch.** The open RISC-V NimBLE controller has
no equivalent config option, so `tools/patch_nimble_5ms.py` lowers its 7.5 ms floor
with a single-instruction edit: 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). It is the single
unique occurrence in its object (asserted by the patcher); `tools/smoke_test_5ms.py`
proves the edit at the disassembly level with no hardware.

**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_NR 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 and ESP32-S3)

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 with real backpressure. The `CONNECT_IND` and
pairing run at 15 ms, but ~1.5 s after subscription the console issues an
`LL_CONNECTION_UPDATE` dropping the link to **5 ms** for the rest of the session
(observed on real S3 hardware) — so sustained input needs sub-spec-interval
support (see milestone 4), not just reconnect/wake.
4. **Reconnect + wake-from-sleep** (bonded reconnect with the 0x81 wake flag).
The console connects a bonded controller at 5 ms from the first packet, and
drops even the fresh session to 5 ms mid-stream, so the link runs at 5 ms in
every mode. On S3/C3 use ESP-IDF ≥ v6.1's official
`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default on); on C6/C61/C2/H2 use
the opt-in `SWITCH2_PRO_PATCH_NIMBLE_5MS` controller patch.

On ESP-IDF ≥ v6.1 the ESP32-S3 is also fully verified (pairing, continuous input,
reconnect, wake) — its updated BTDM controller lib both accepts the console's 5 ms
CONNECT_IND (via `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`) and sustains the
encrypted input stream, fixing the ~3 s tx-stall seen on v6.0.1 (see the README
"Known issues"). On pre-v6.1 IDF, 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 use the official IDF option)
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
Loading
Loading