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

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

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

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

set_log_level

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

get_log_level

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

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.

Logger::Verbosity log_level = {Logger::Verbosity::INFO}

Verbosity of the logger.

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)

Header File