Switch 2 Pro Controller
The Switch2Pro component emulates a Nintendo Switch 2 Pro Controller over
BLE so that a real Nintendo Switch 2 console accepts it as a native
controller — including pairing, waking the console from sleep, reconnecting, and
streaming input reports. It is built on espp::BleGattServer
(NimBLE) and implements the reverse-engineered Nintendo custom GATT interface
(not HID-over-GATT) and the console’s custom pairing handshake (not BLE SMP).
Warning
Supported target: ESP32-S3 with ESP-IDF ≥ v6.1 (or the
v6.0/v5.5/v5.4/v5.3 backports), where the official
CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (default on) lets the controller
accept the console’s sub-spec 5 ms interval — pairing, input streaming,
reconnect, and wake-from-sleep all verified against a real console. The
open-NimBLE-controller chips (ESP32-C6/C61/C2/H2) build and pair, but their
stock controller rejects the 5 ms interval, so sustained input, reconnect and
wake do not work there yet. See the component README’s “The 5 ms connection
interval” and “Known issues”.
Note
Interoperability only. This component contains no Nintendo or Espressif binaries; the pairing “authentication” relies on a published fixed key and is a possession check, not per-device attestation.
#include "switch2_pro.hpp"
espp::Switch2Pro controller({.device_name = "Pro Controller"});
controller.init(); // verifies pairing crypto, builds GATT, advertises
// feed input state; a driver-owned task streams it once the console subscribes
espp::switch2::Pro2InputReport report;
report.set_a(true);
controller.set_input_report(report);
The 5 ms connection interval
The console drives the link at a 5 ms connection interval, below the 7.5 ms
Bluetooth spec minimum. It reaches 5 ms in every mode: a bonded reconnect or
wake connects at 5 ms from the CONNECT_IND, and a fresh session is
renegotiated down to 5 ms (LL_CONNECTION_UPDATE) about 1.5 s after the
console subscribes to input. Only the initial pairing handshake (~15 ms, the
first ~1.5 s) works without sub-spec support; sustained input, reconnect, and
wake all need it. How to enable it depends on the chip:
ESP32-S3 / C3: use ESP-IDF ≥ v6.1 (or the v6.0/v5.5/v5.4/v5.3 backports), where the official
CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE(default on; espressif/esp-idf#18467) makes the BTDM controller accept the 5 ms interval with no binary patch. This is the verified S3/C3 path.ESP32-C6 / C61 / C2 / H2: the open NimBLE controller has no such option yet, and espp does not modify the ESP-IDF controller libraries, so these chips are limited to the initial pairing handshake until Espressif exposes one.
See the component README.
API Reference
Header File
Classes
-
class Switch2Pro : public espp::BaseComponent
Emulates a Nintendo Switch 2 Pro Controller as a BLE peripheral.
The Switch 2 uses a proprietary BLE GATT interface (not HID-over-GATT) with a custom pairing handshake (not BLE SMP). This class stands up that GATT tree on top of espp::BleGattServer, advertises with Nintendo manufacturer data, and answers the console’s command channel — including the reverse- engineered pairing handshake so a real console will bond with it.
Status: works on ESP32-C6 (and the other open-NimBLE-controller chips) and on ESP32-S3 (ESP-IDF >= v6.1) — advertising, the custom GATT tree, the 0x15 pairing handshake, the full init/calibration command sequence, LL encryption, bond persistence, continuous input-report streaming, reconnect, and wake-from-sleep are all implemented and verified against a real console. The console drives the link at a sub-spec 5 ms interval for sustained input, reconnect, and wake; the S3/C3 get that from ESP-IDF’s default-on CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (>= v6.1). The open-NimBLE chips (C6/C61/C2/H2) have no such option yet, so there only the initial pairing handshake works on a stock controller (see the component README).
Example
// Bring up the emulated Switch 2 Pro Controller. init() verifies the pairing // crypto against a known-answer vector, builds the custom Nintendo GATT // services, configures security so the console (not BLE SMP) drives pairing, // and starts advertising with Nintendo manufacturer data. // // INFO shows the high-level protocol flow (connect, pairing steps, subscribes, // encryption, input-stream enable). Use DEBUG to also dump every command/ // response byte — but note that flood can saturate the serial link during the // rapid init sequence. // Defaults: continuous per-interval streaming (like a real controller, // verified stable on C6-class chips) and an all-zero IMU motion block. // Wake-on-boot is disabled so waking is user-initiated, like a real // controller: while bonded but disconnected, pressing BOOT broadcasts the // wake advertisement (see the loop below) instead of the driver nudging the // console automatically every few seconds. espp::Switch2Pro controller({ .device_name = "Pro Controller", .wake_console_on_boot_enabled = false, .log_level = espp::Logger::Verbosity::INFO, }); if (!controller.init()) { logger.error("failed to initialize Switch2Pro"); return; } logger.info("advertising — on the Switch 2, open Controllers > Pair, and watch " "the log for the connect interval, the 0x15 pairing exchange, and " "either 'pairing finalised' or a disconnect reason"); // The BOOT button (GPIO0) is wired as the A button for easy testing. gpio_config_t btn_cfg = {}; btn_cfg.pin_bit_mask = 1ULL << kBootButtonGpio; btn_cfg.mode = GPIO_MODE_INPUT; btn_cfg.pull_up_en = GPIO_PULLUP_ENABLE; btn_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; btn_cfg.intr_type = GPIO_INTR_DISABLE; gpio_config(&btn_cfg); // Feed input state to the driver. set_input_report() just stores the latest // report; the driver's streaming task paces the actual BLE notifications (one // per connection interval, like a real controller) and only streams once the // console has subscribed. So it is safe to call every loop — keep one report // and mutate it. Replace the BOOT read below with your real button/stick source. // // Press BOOT and watch A register on the Switch 2's "Test Input Devices" screen. espp::switch2::Pro2InputReport report; report.set_power(/*battery_level=*/9, /*charging=*/false, /*external_power=*/false); // full int tick = 0; // The Switch 2 shows "press L + R on the controller you want to use" while // selecting; auto-hold L+R for ~1 s each time streaming (re)starts to satisfy // that selection prompt so the console activates this controller. constexpr int kLrHoldTicks = 66; // ~1 s at the 15 ms cadence below bool prev_streaming = false; bool prev_pressed = false; int lr_ticks = 0; while (true) { const bool pressed = gpio_get_level(kBootButtonGpio) == 0; // BOOT reads low when pressed const bool press_edge = pressed && !prev_pressed; prev_pressed = pressed; // BOOT while bonded-but-disconnected = wake the console (a real controller // wakes the console on a button press). wake_console() no-ops unless there // is a stored bond and no active connection, so the edge check is enough. if (press_edge && !controller.is_connected()) { if (controller.wake_console()) logger.info("BOOT pressed while disconnected -> sent wake advertisement"); } // Rising edge of streaming: (re)arm the L+R auto-press. const bool streaming = controller.is_input_streaming(); if (streaming && !prev_streaming) lr_ticks = kLrHoldTicks; prev_streaming = streaming; const bool lr_auto = lr_ticks > 0; if (lr_ticks > 0) --lr_ticks; report.set_a(pressed); // BOOT doubles as A while connected report.set_l(lr_auto); report.set_r(lr_auto); report.set_left_stick(0.f, 0.f); // centered report.set_right_stick(0.f, 0.f); // centered controller.set_input_report(report); if (++tick % 66 == 0) // ~1 s at the 15 ms cadence below logger.info("connected={} streaming={} A(boot)={} L+R(auto)={}", controller.is_connected(), streaming, pressed, lr_auto); std::this_thread::sleep_for(15ms); // ~66 Hz, matching the real controller }
Public Types
-
enum class AdvMode
Advertisement variant. Discovery = fresh pairing (zero host addr). Reconnect = we already have a bond; the paired console’s address is embedded so it recognises us and reconnects (skipping the 0x15 pairing). Wake = like Reconnect but sets the wake flag to bring a sleeping console back up.
Values:
-
enumerator Discovery
-
enumerator Reconnect
-
enumerator Wake
-
enumerator Discovery
Public Functions
-
~Switch2Pro()
Stop the input-streaming task on teardown.
-
bool init()
Initialize NimBLE, build the custom GATT services, configure security so the console (not standard SMP) drives pairing, and start advertising.
- Returns:
true on success.
-
inline bool is_paired() const
Whether the pairing handshake has completed with a console.
-
inline bool is_connected() const
Whether a console is currently connected (link established; init/input subscription may still be in progress — see is_input_streaming()).
-
bool wake_console()
Broadcast the wake advertisement now (e.g. from a button press, matching a real controller’s press-a-button-to-wake-the-console behaviour): embeds the bonded console’s identity address with the wake flag so a sleeping console powers on and reconnects. Requires a stored bond (from a completed pairing, this boot or restored from NVS) and no active connection. Returns true if the advertisement was issued.
-
bool clear_bond()
Forget the persisted bond and return to fresh-pairing (discovery) mode. Erases the NVS bond blob (console address + LTK), clears the in-memory bond state, and — if not currently connected — re-advertises for discovery. Use this when the console has forgotten the controller (otherwise every boot stays in reconnect mode and never advertises for a fresh pairing), or to factory-reset the emulated controller.
- Returns:
true if the bond was erased (or none was stored); false if the NVS erase/commit failed.
-
inline bool is_input_streaming() const
Whether the console has subscribed to the input characteristic (0x000e) on an LL-encrypted connection, i.e. the stream loop is actually sending input reports. Goes true once the subscribed link is encrypted and false on disconnect; useful for driving post-connect behaviour (e.g. the L+R “select this controller” prompt) from the application.
-
inline void set_input_report(const switch2::Pro2InputReport &report)
Store the latest controller state. This does NOT send — a driver-owned streaming task notifies the newest stored report once per connection interval (continuously, like a real controller), so you can call this as often as you like (e.g. on every button/stick change) without flooding the link. Thread-safe.
-
inline const std::string &get_name() const
Get the name of the component
Note
This is the tag of the logger
- Returns:
A const reference to the name of the component
-
inline void set_log_tag(const std::string_view &tag)
Set the tag for the logger
- Parameters:
tag – The tag to use for the logger
-
inline espp::Logger::Verbosity get_log_level() const
Get the log level for the logger
See also
See also
- Returns:
The verbosity level of the logger
-
inline void set_log_level(espp::Logger::Verbosity level)
Set the log level for the logger
See also
See also
- Parameters:
level – The verbosity level to use for the logger
-
inline void set_log_verbosity(espp::Logger::Verbosity level)
Set the log verbosity for the logger
See also
See also
See also
Note
This is a convenience method that calls set_log_level
- Parameters:
level – The verbosity level to use for the logger
-
inline espp::Logger::Verbosity get_log_verbosity() const
Get the log verbosity for the logger
See also
See also
See also
Note
This is a convenience method that calls get_log_level
- Returns:
The verbosity level of the logger
-
inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)
Set the rate limit for the logger
See also
Note
Only calls to the logger that have _rate_limit suffix will be rate limited
- Parameters:
rate_limit – The rate limit to use for the logger
-
struct Config
Configuration for the controller.
Public Members
-
std::string device_name = {"Pro Controller"}
BLE advertised name.
-
bool wake_console_on_boot_enabled = {true}
If we boot with a saved bond, broadcast the *wake* advertisement (and re-issue it every wake_interval) until the console connects, so a sleeping console is woken and reconnects without re-pairing. When false we use the plain reconnection advertisement (only reconnects an already-awake console).
-
bool imu_motion_enabled = {false}
Replay captured IMU motion frames in the input reports’ motion block. Once the console enables the IMU feature (it does during standard init), every report carries a 40-byte motion block. **Off (default): the block is sent all-zero**, which the console accepts (verified on hardware, and what the zhantss emulator ships) — fine for games that do not use motion. On: replay a captured 128-frame resting sequence — but it loops (~2 s at 62 Hz) so its embedded timestamps jump backwards at the wrap. The driver owns the motion block, so there is no per-report motion input today; a live-IMU path (a motion setter on the report) is future work.
-
bool continuous_streaming_enabled = {true}
Streaming model. **On (default) = continuous:** send one report every connection interval with the counter incrementing each time, exactly like a real controller — verified stable and lag-free on the C6-class chips and on the ESP32-S3 (ESP-IDF >= v6.1). This is the recommended mode on every supported target. **Off = on-change:** notify only when the app’s button/stick state changes, plus a low-rate keepalive — an optional reduced-traffic mode (it was also a workaround for the pre-v6.1 S3 BTDM tx-servicing stall, now fixed; see README “Known issues”).
-
uint32_t continuous_stream_divisor = {1}
Continuous-mode send divisor: send one report every Nth connection interval (1 = every interval, 2 = every other, …). The resulting rate depends on the live interval the console chose: at the initial 15 ms, N=1 is ~62 Hz and N=2 ~31 Hz; once the console moves the link to 5 ms, N=1 is ~200 Hz and N=2 ~100 Hz. Diagnostic knob to separate a time-based stall (console deprioritisation — stalls at the same wall-clock regardless of N) from a packet-count-based one (our-side tx-credit accumulation — survives ~N× longer). Ignored in on-change mode.
-
std::string device_name = {"Pro Controller"}
-
enum class AdvMode
Header File
Header File
Classes
-
class Pro2InputReport
The 63-byte Pro Controller 2 input report (BLE omits the leading report-id byte). Buttons are a 3-byte bitfield; sticks are two 12-bit axes packed into 3 bytes each; the tail carries motion/IMU when enabled via feature-select. The application fills buttons, sticks and power; byte 0 (the sequence counter), byte 0x0B (the negotiated-feature flags) and the motion block are written by the driver when it streams the report.
Button bits (matching the reverse-engineered layout). The three button bytes are at report offsets 2, 3, 4 — data()[0] is the counter and data()[1] the power byte — which is what the setters below write (set_bit(2/3/4, …)): report offset 2: 0x80 RStick 0x40 Plus 0x20 ZR 0x10 R 0x08 X 0x04 Y 0x02 A 0x01 B report offset 3: 0x80 LStick 0x40 Minus 0x20 ZL 0x10 L 0x08 Up 0x04 Left 0x02 Right 0x01 Down report offset 4: 0x10 C 0x08 GL 0x04 GR 0x02 Capture 0x01 Home
Public Functions
-
inline void set_power(uint8_t battery_level, bool charging, bool external_power)
battery_level: 0..9 (values above 9 are clamped — 10..15 are reserved, and the field is only 4 bits, so a larger value would otherwise encode a reserved level or wrap). charging/external-power flags share the byte.
-
inline void set_gr(bool v)
right grip button
-
inline void set_gl(bool v)
left grip button
-
inline void set_c(bool v)
Switch 2 “C” (chat) button.
-
inline void set_left_stick(float x, float y)
Left/right stick, each axis in [-1, 1].
Public Static Attributes
-
static constexpr uint16_t STICK_CENTER = 0x800
12-bit midpoint (2048)
-
inline void set_power(uint8_t battery_level, bool charging, bool external_power)