Switch Pro Controller (NS1)

The switch_pro component provides espp::SwitchPro, a Nintendo Switch Pro controller (NS1) USB emulation protocol engine: it implements the Switch Pro controller’s USB HID handshake and input-report protocol so an ESP32-S3 (or other native-USB ESP) can present itself to a Nintendo Switch as a Pro Controller.

It is transport-light — the class owns the controller state and the request/response state machine but performs no USB I/O. Drive it from a USB HID interface (the espp usb_device HID function is the intended pairing): feed host OUTPUT reports (the handshake) to on_hid_report() and send the replies (and the periodic standard input report) back as HID INPUT reports. The HID report descriptor and report packing come from the hid-rp component.

Warning

Emulation only. A real Switch only binds a device advertising Nintendo’s Pro Controller USB VID/PID (0x057E / 0x2009) and identity strings (exposed as espp::SwitchPro constants). Use these to emulate / test against a Switch you own; do not ship a product impersonating Nintendo hardware.

API Reference

Header File

Classes

class SwitchPro : public espp::BaseComponent

Nintendo Switch Pro controller (NS1) USB emulation protocol engine.

This class implements the Nintendo Switch Pro controller’s USB HID handshake and input-report protocol so an ESP device can present itself to a Nintendo Switch (NS1) as a Pro Controller. It is **transport-light**: it owns the controller state + the request/response state machine but performs no USB I/O itself. Drive it from a USB HID interface (e.g. the espp `usb_device` component’s HID function): feed host OUTPUT reports to `on_hid_report()` and send the returned reply (and periodic `get_input_report()` reports) back as HID INPUT reports. See the example.

The HID report descriptor, input-report packing, and the SPI-ROM calibration/config blobs come from the espp `hid-rp` component (`switch_pro_descriptor()`, `SwitchProGamepadInputReport`).

Switch Pro example

extern "C" void app_main(void) {
  espp::Logger logger({.tag = "SwitchProExample", .level = espp::Logger::Verbosity::INFO});
  logger.info("Starting Nintendo Switch Pro (NS1) USB controller example");

  // The protocol engine: owns the controller state + the USB HID handshake.
  espp::SwitchPro controller({.log_level = espp::Logger::Verbosity::WARN});

  // Outgoing HID reports are produced from two contexts (the TinyUSB task, when a
  // host OUTPUT report arrives, and our periodic sender). Funnel them through one
  // queue drained by a single sender task so we never call write_hid_report() from
  // the TinyUSB task (where spinning for the endpoint to drain would deadlock).
  struct OutReport {
    uint8_t id;
    std::vector<uint8_t> data;
  };
  std::deque<OutReport> tx_queue;
  std::mutex tx_mutex;
  std::condition_variable tx_cv;
  auto enqueue = [&](espp::SwitchPro::ReportData rd) {
    {
      std::lock_guard<std::mutex> lock(tx_mutex);
      // cap the queue so a misbehaving host cannot grow it without bound
      if (tx_queue.size() < 16)
        tx_queue.push_back({rd.first, std::move(rd.second)});
    }
    tx_cv.notify_one();
  };

  // Configure the USB device as a single HID interface advertising the Switch Pro
  // report descriptor, with an interrupt-OUT endpoint so we receive the host's
  // OUTPUT reports (the handshake). VID/PID/strings are Nintendo's Pro Controller
  // identifiers -- required for a real Switch to bind it; EMULATION / testing only.
  espp::UsbDevice::Config usb_cfg;
  usb_cfg.vid = espp::SwitchPro::vid;
  usb_cfg.pid = espp::SwitchPro::pid;
  usb_cfg.manufacturer = espp::SwitchPro::manufacturer_name;
  usb_cfg.product = espp::SwitchPro::product_name;
  usb_cfg.log_level = espp::Logger::Verbosity::WARN;

  espp::UsbDevice::HidFunction hid;
  hid.interface_name = "Switch Pro Controller";
  hid.report_descriptor = controller.get_report_descriptor();
  hid.has_out_endpoint = true; // receive host OUTPUT reports (the handshake)
  hid.poll_interval_ms = 8;    // the real Pro Controller polls at 8 ms (full-speed)
  hid.on_receive = [&](std::span<const uint8_t> data) {
    // TinyUSB task context: compute the reply and queue it (don't send here).
    if (data.empty())
      return;
    if (auto reply = controller.on_hid_report(data[0], data.data(), data.size()))
      enqueue(std::move(*reply));
  };
  usb_cfg.hid = hid;

  espp::UsbDevice usb(usb_cfg);

  // On mount, kick off the handshake: the controller proactively sends its
  // device-init (0x81) report.
  usb.set_mount_callback([&]() {
    if (auto init = controller.on_attach())
      enqueue(std::move(*init));
  });

  std::error_code ec;
  if (!usb.initialize(ec)) {
    logger.error("Failed to initialize USB device: {}", ec.message());
    return;
  }
  logger.info("USB HID Switch Pro controller ready; connect it to a Nintendo Switch.");

  std::atomic<bool> running{true};

  // Sender task: drain queued replies first (retrying, since this is our own task
  // and may block), otherwise stream the standard input report once the host has
  // enabled reports.
  std::thread sender([&]() {
    while (running.load()) {
      OutReport rep;
      bool have = false;
      {
        std::unique_lock<std::mutex> lock(tx_mutex);
        tx_cv.wait_for(lock, 15ms, [&]() { return !tx_queue.empty() || !running.load(); });
        if (!tx_queue.empty()) {
          rep = std::move(tx_queue.front());
          tx_queue.pop_front();
          have = true;
        }
      }
      if (have) {
        std::error_code send_ec;
        for (int i = 0; i < 20 && !usb.write_hid_report(rep.id, rep.data, send_ec); ++i)
          std::this_thread::sleep_for(1ms);
      } else if (controller.is_ready()) {
        auto std_report = controller.get_input_report();
        if (!std_report.empty()) {
          std::error_code send_ec;
          usb.write_hid_report(controller.input_report_id(), std_report, send_ec); // best-effort
        }
      }
    }
  });

  // Demo: once ready, cycle A / B / X / Y (500 ms each) and sweep the left stick,
  // so a connected Switch shows live input.
  using InputReport = espp::SwitchPro::InputReport;
  int step = 0;
  while (true) {
    if (controller.is_ready()) {
      const int which = step % 4;
      const float angle = (step % 20) / 20.0f * 2.0f * 3.14159265f;
      controller.update_input_report([&](InputReport &r) {
        r.reset();
        r.set_button_a(which == 0);
        r.set_button_b(which == 1);
        r.set_button_x(which == 2);
        r.set_button_y(which == 3);
        r.set_left_joystick(0.5f * std::cos(angle), 0.5f * std::sin(angle));
      });
      ++step;
    }
    std::this_thread::sleep_for(250ms);
  }

  running.store(false);
  tx_cv.notify_all();
  sender.join();
}

Note

**Emulation only.** A real Switch only binds a device advertising Nintendo’s Pro Controller USB VID/PID (0x057E / 0x2009) and identity strings (exposed here as constants). Use these to emulate/test against a Switch you own; do not ship a product impersonating Nintendo hardware.

Public Types

using InputReport = espp::SwitchProGamepadInputReport<>

The hid-rp standard input report type (report id 0x30).

using ReportData = std::pair<uint8_t, std::vector<uint8_t>>

A HID report to send to the host: {report id, report bytes}.

Public Functions

inline std::vector<uint8_t> get_report_descriptor() const

The HID report descriptor bytes (the full Switch Pro descriptor).

Returns:

A copy of the report descriptor bytes.

inline uint8_t input_report_id() const

The report id of the standard input report (0x30).

inline bool is_ready() const

Whether the handshake has progressed far enough that the host has enabled input reports (i.e. it is meaningful to stream them).

std::optional<ReportData> on_attach()

Kick off the initialization sequence.

Call once when the USB device is attached/mounted. Returns the initial device-info report the controller sends unprompted to start the handshake (report id 0x81), or std::nullopt if none.

std::optional<ReportData> on_hid_report(uint8_t report_id, const uint8_t *data, size_t len)

Handle a host OUTPUT report (host -> device) and produce the reply.

Feed every OUTPUT report the host sends (report ids 0x80 init, 0x01 output / subcommand, 0x10 rumble) here; byte 0 of data must be the report id. Returns the INPUT report to send back (report id + bytes), or std::nullopt if no reply is warranted.

Parameters:
  • report_id – The HID report id (ignored; the type is read from data[0]).

  • data – The received report bytes (data[0] is the report id/type).

  • len – Number of bytes at data.

std::vector<uint8_t> get_input_report() const

The current standard input report bytes (report id 0x30 payload).

Returns:

The report bytes, or an empty vector if not ready yet.

void update_input_report(const std::function<void(InputReport&)> &fn)

Thread-safely mutate the input report (buttons / joysticks / dpad).

The callback receives the underlying `hid-rp` input report; use its setters (e.g. `set_button_a()`, `set_left_joystick()`, `set_dpad()`). Housekeeping fields (USB-powered, battery, connection info) are (re)applied afterward so a caller-issued `reset()` does not clear them.

Parameters:

fn – Callback that mutates the input report.

void set_battery_level(uint8_t level)

Set the reported battery level (0-100).

void set_trigger_elapsed_times(const std::array<uint16_t, 7> &times_10ms)

Set the trigger-buttons-elapsed times (subcommand 0x04 reply), in units of 10 ms, order L,R,ZL,ZR,SL,SR,HOME. Optional; defaults to 0.

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

Public Static Attributes

static constexpr uint16_t vid = 0x057E

Nintendo USB VID.

static constexpr uint16_t pid = 0x2009

Switch Pro Controller USB PID.

static constexpr uint16_t bcd_device = 0x0200

bcdDevice.

static constexpr uint16_t bcd_usb = 0x0200

bcdUSB (USB 2.0).

static constexpr const char *manufacturer_name = "Nintendo Co., Ltd."

iManufacturer.

static constexpr const char *product_name = "Pro Controller"

iProduct.

struct Config

Configuration for the SwitchPro engine.

Public Members

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Logger verbosity.