feat(switch2_pro): Nintendo Switch 2 Pro Controller BLE emulation - #765
feat(switch2_pro): Nintendo Switch 2 Pro Controller BLE emulation#765finger563 wants to merge 15 commits into
Conversation
…r BLE emulation New component that emulates a Nintendo Switch 2 Pro Controller as a BLE peripheral so a real Switch 2 accepts it as a native controller (and can be woken from sleep). Unlike the original Switch (BT Classic HID), the Switch 2 uses a proprietary BLE GATT interface (not HID-over-GATT) and a custom pairing handshake (not SMP), so this builds custom Nintendo GATT services directly on espp::BleGattServer rather than hid_service/hid-rp. This first milestone: - custom GATT service tree (two proprietary services + input/command/response characteristics) with no-SMP security config - Nintendo manufacturer-data advertising (+ wake-flag variant scaffolding) - reverse-engineered pairing crypto (LTK = A1 ^ B1, B2 = AES-128-ECB via PSA Crypto), self-tested against a host-verified known-answer vector at init - Pro Controller 2 input report (0x09) struct incl. the C / GL / GR buttons - opt-in, target-gated NimBLE 5 ms connection-interval patch (tools/ + Kconfig), off by default and never mutating $IDF_PATH silently Builds and links for ESP32-C6. Protocol facts from ndeadly/switch2_controller_research; approach + NimBLE patch adapted (MIT) from zhantss/ESP32-BLE5-NSController-Emulator. See DESIGN.md for milestones 2-4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…estone 2) The command channel now replies, completing the pairing handshake on the wire and answering the console's init sequence — the part a real console needs to accept the controller. - send device->host responses on the matching notify characteristic (writes on 0x0016 reply on 0x001e, writes on 0x0014 reply on 0x001a) - full 0x15 pairing handshake replies with exact captured framing: exchange addresses (our BT address), exchange keys (fixed B1), confirm (B2 = AES-ECB), finalise ACK — bytes verified against ndeadly's captures - command dispatch: flash/calibration reads (0x02) from a simulated flash, feature-select (0x0c) mask capture, firmware-info (0x10) canned reply, firmware-update (0x0d) ACK to suppress the update prompt, and header-only ACKs for init/LEDs/vibration/battery so the console's state machine advances - simulated flash (switch2_pro_flash.hpp) with neutral stick calibration (placeholder; refine against a capture for exact stick behavior) Builds and links for ESP32-C6. Uncertain-on-hardware bits (exchange-address byte order, calibration contents, firmware-update suppression) are marked in code for refinement against a real console. Next: input report streaming (milestone 3, needs the 5 ms NimBLE patch) and wake-from-sleep (milestone 4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iring tests Make the example flashable on ESP32-S3 (what's commonly on hand) and observable enough to see how far pairing with a real Switch 2 gets, since S3 can't accept the console's 5 ms interval and may drop mid/post-pairing. - example defaults to esp32s3 (builds clean; the C6 GCC 15.2 attribute issue is RISC-V-only, so S3 is unaffected) with USB-Serial-JTAG console - connect/disconnect callbacks log the negotiated connection interval, supervision timeout, and disconnect reason — the interval is the 5 ms diagnostic, the reason shows why a link dropped - DEBUG-level hex trace of every command write (0x0014/0x0016) and response (0x001a/0x001e), so the 0x15 pairing exchange is visible on the monitor - re-advertise on disconnect - example README: step-by-step pairing test and what to expect on S3 vs C6 Note: S3 pairing may not complete/hold (5 ms limit); the trace shows how far it gets, which is the useful signal. Full path remains C6/C61 + the NimBLE patch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rtisement The controller wasn't visible to the console because the advertisement overflowed the 31-byte legacy limit: flags (3) + name "Pro Controller" (16) + manufacturer data (22) = 41 bytes, so NimBLE dropped/truncated it — and the console filters discovery on the Nintendo manufacturer data, which was the part getting lost. Put flags + manufacturer data (25 bytes, fits) in the primary advertisement and move the device name to the scan response. Flags now 0x06 (LE General Disc + BR/EDR unsupported), matching the captured discovery advertisement. Log the manufacturer-data fit and warn if it still doesn't. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The NS2 now discovers and connects (stable at a 15ms interval — so the 5ms wall isn't hit during pairing on S3), but doesn't start the pairing exchange. We were blind to what it does post-connect (only the two command chars were logged). Attach a tracing callback to every custom characteristic that logs reads, writes (with hex), and notification subscribes at INFO. This shows whether the console is subscribing to our response/input characteristics and what, if anything, it writes — the data needed to find why pairing doesn't start. Hex dump is INFO during bring-up (dial back later). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The console connects but does no value reads/writes/subscribes on our characteristics — consistent with it doing ATT service discovery (invisible to characteristic callbacks) and then declining to proceed. - log the actual handle each service/characteristic landed on vs the real controller's handles (0x000a/0x000e/0x0014/0x0016/0x001a/0x001e). If BleGattServer's GAP/GATT/DeviceInfo/Battery services shifted ours off those handles, and the console keys off them, that's the cause. - enable NimBLE host INFO logging (+ compile-in debug) so GAP/ATT activity (MTU, discovery, connection-parameter updates, subscribes) is visible at the stack level. Diagnostic only; revert the log levels once understood. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prior round's handle dump read 0x0000 because NimBLE assigns handles only after the server starts; move it to after ble_gatt_server_.start(). Turn the NimBLE host log to DEBUG for its tags only (esp_log_level_set, so ATT discovery/reads/ writes show without flooding other components). Log authentication_complete so we can tell if the console is (unexpectedly) running BLE SMP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The prior config set CONFIG_BT_NIMBLE_LOG_LEVEL to INFO, which compiles out NimBLE's ATT/GATT-server DEBUG logs entirely — so the console's service discovery / reads / writes never printed regardless of runtime level. Set it to DEBUG and raise the runtime default level. Verified the generated sdkconfig now has CONFIG_BT_NIMBLE_LOG_LEVEL=0 and CONFIG_LOG_DEFAULT_LEVEL=4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The NS2 does app-level pairing (the 0x15 command exchange), not BLE SMP. Our bonding=true config created BLE bonds; the console then used GATT caching over the bond and skipped service discovery on reconnect, getting stuck after the MTU exchange. Set bonding=false and clear any stored bonds at startup so the console re-discovers our GATT cleanly on each connection. Diagnostics from this round also confirmed our characteristics are shifted off the real controller's handles (command at 0x0033 vs 0x0014) because BleGattServer registers GAP/GATT/DeviceInfo/Battery first — tracked for the likely raw-NimBLE GATT rework if the console keys off fixed handles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chive)
The C6 controller archives are GNU-format (long-name symbol/string tables);
macOS's BSD ar fails to extract them ("File exists" on the / and // members),
which silently broke the patcher on macOS. Prefer the RISC-V toolchain's
riscv32-esp-elf-ar (on PATH after the IDF export script), then llvm-ar, then ar;
add an --ar override.
Verified end-to-end on IDF 6.0.1: ble_ll_conn.c.o has exactly one 7.5ms-floor
pattern; patch -> 5ms -> restore round-trips cleanly. Confirms the 5 ms patch
applies on IDF 6.0.1, not just the 5.5.3 the reference used.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cesFirst Points the esp-nimble-cpp submodule at esp-cpp's feat/register-services-first (also fast-forwards it to upstream h2zero 2.5.0). This pulls in the new opt-in NimBLEServer::registerServicesFirst() API that switch2_pro needs to place its Nintendo services at the low attribute handles a real console addresses by fixed handle. Upstream PR: h2zero/esp-nimble-cpp#443. Do not merge this espp change until that PR is merged and released; the pin will then move to a released commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface NimBLE's BLE_GAP_EVENT_CONN_UPDATE via a new optional conn_params_update_callback on BleGattServer::Callbacks, forwarded from a BleGattServerCallbacks::onConnParamsUpdate override. Lets applications observe connection-parameter updates (interval/latency/timeout) as they complete. Additive and optional; existing users are unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, docs, CI Complete the switch2_pro component so a real Nintendo Switch 2 accepts it as a native Pro Controller. Verified end-to-end on ESP32-C6: pairing, encrypted link, continuous input streaming (~62 Hz, matching a real controller), reconnect, and wake-from-sleep. Highlights: - Streaming model: set_input_report() stores latest state; a driver-owned task streams it (continuous by default, on-change fallback) with real backpressure keyed on the host mbuf pool (NimBLE NOTIFY_TX fires at handoff, not over-air, so it can't gate a backlog — the pool level can). - Correct report/init: firmware-info identity, always-0x38 byte 0x0b, all-zero IMU motion block, feature-mask restore on reconnect, exact GATT handle layout via NimBLEServer::registerServicesFirst(). - Reconnect + wake: bonded reconnect and 0x81 wake advertisement (public wake_console() API); needs the opt-in 5 ms controller patch (off by default). - Tooling: patch_nimble_5ms.py (C6/C61/C2/H2 NimBLE + S3/C3 BTDM) and hardware-free smoke_test_5ms.py verifier. - Docs (doc/en/ble/switch2_pro*, Doxyfile), CI matrix (C6 + S3), example, and README/DESIGN updated. C6 is the supported target; ESP32-S3 builds and pairs but does not yet stream reliably (closed BTDM controller) — documented as a known issue / fast-follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
🟡 Changes recommended
There are several correctness/documentation mismatches and missing error-handling paths (paired-state semantics, pthread config error checking, PSA crypto status handling, and dependency/version alignment) that should be resolved before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new switch2_pro component to ESPP that emulates a Nintendo Switch 2 Pro Controller over BLE (custom GATT + custom pairing/crypto + continuous input streaming), plus a small ble_gatt_server enhancement to support the required GATT/connection-parameter behavior, along with docs and CI coverage for the new example.
Changes:
- Introduces the
components/switch2_procomponent (protocol constants, pairing crypto/self-test, GATT layout, advertising variants, bond persistence, and an input streaming task with backpressure). - Extends
espp::BleGattServerwith a connection-parameters-update callback and an option to disable built-in DIS/BAS services (needed for strict handle layout emulation). - Adds documentation pages, Doxygen inputs, and CI build matrix entries for the new example (ESP32-C6 + ESP32-S3).
File summaries
| File | Description |
|---|---|
| doc/en/ble/switch2_pro.rst | New Sphinx page documenting the Switch 2 Pro BLE emulation component and 5 ms interval patch. |
| doc/en/ble/switch2_pro_example.md | Includes the example README into the docs site. |
| doc/en/ble/index.rst | Adds switch2_pro docs pages to the BLE docs toctree. |
| doc/Doxyfile | Adds Switch2Pro headers and example to Doxygen INPUT/EXAMPLE_PATH. |
| components/switch2_pro/tools/smoke_test_5ms.py | Hardware-free verifier for the 5 ms controller patch (disassembly-based). |
| components/switch2_pro/tools/patch_nimble_5ms.py | Opt-in patcher that edits ESP-IDF controller archives to accept 5 ms intervals. |
| components/switch2_pro/src/switch2_pro.cpp | Core implementation: GATT layout, pairing handling, bond persistence, advertising, and input streaming/backpressure. |
| components/switch2_pro/src/switch2_pro_pairing.cpp | Pairing crypto implementation using PSA Crypto API + self-test. |
| components/switch2_pro/README.md | Component README covering usage, 5 ms patch rationale, and known issues. |
| components/switch2_pro/Kconfig | Adds SWITCH2_PRO_PATCH_NIMBLE_5MS opt-in configuration. |
| components/switch2_pro/include/switch2_pro.hpp | Public API for espp::Switch2Pro and its configuration/streaming behavior. |
| components/switch2_pro/include/switch2_pro_report.hpp | Defines the packed Pro Controller 2 input report helper. |
| components/switch2_pro/include/switch2_pro_protocol.hpp | Protocol constants: UUIDs, command IDs, feature bits, manufacturer data, golden vectors. |
| components/switch2_pro/include/switch2_pro_pairing.hpp | Pairing crypto interface and self-test API. |
| components/switch2_pro/include/switch2_pro_motion.hpp | Embedded captured IMU motion sequence for optional replay. |
| components/switch2_pro/include/switch2_pro_flash.hpp | Embedded captured flash blocks and a simulated flash reader. |
| components/switch2_pro/idf_component.yml | Component Manager manifest for switch2_pro. |
| components/switch2_pro/example/sdkconfig.defaults.esp32c6 | ESP32-C6-specific sdkconfig defaults (libc/toolchain workaround notes). |
| components/switch2_pro/example/sdkconfig.defaults | Example defaults for BLE/NimBLE tuning and patch guidance. |
| components/switch2_pro/example/README.md | Example walkthrough (pairing, streaming, wake/reconnect instructions). |
| components/switch2_pro/example/partitions.csv | Partition table enabling NVS for bond persistence. |
| components/switch2_pro/example/main/switch2_pro_example.cpp | Example app implementing BOOT-as-A and wake-on-press logic. |
| components/switch2_pro/example/main/CMakeLists.txt | Registers the example main component. |
| components/switch2_pro/example/CMakeLists.txt | Example project wiring for ESP-IDF build and components list. |
| components/switch2_pro/DESIGN.md | Design notes, protocol sources/attribution, and patch rationale. |
| components/switch2_pro/CMakeLists.txt | Component build registration + opt-in patch invocation at configure time. |
| components/switch2_pro/.gitignore | Ignores example build artifacts and generated sdkconfig files. |
| components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp | Plumbs NimBLE conn-param-update event into server callbacks. |
| components/ble_gatt_server/include/ble_gatt_server.hpp | Adds conn-param-update callback typedef + togglable DIS/BAS creation/start/deinit. |
| components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp | Declares the new onConnParamsUpdate callback override. |
| .github/workflows/build.yml | Adds CI build matrix entries for switch2_pro/example (C6 + S3). |
Review details
- Files reviewed: 32/32 changed files
- Comments generated: 6
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- confirm(): check psa_crypto_init()/psa_cipher_encrypt() status and out_len; log and return zeroed output on failure instead of a silent partial block. - init(): check esp_pthread_set_cfg() and warn if the streaming thread can't get its configured stack/prio/core. - disconnect: stop clearing paired_ — is_paired() reports bond/handshake existence which survives a disconnect (use is_connected()/is_input_streaming() for session state). - flash read: use std::find_if instead of a raw block-search loop (cppcheck). - docs: refresh the class-level status comment (no longer a "skeleton"); fix the send_ack() doc to match the on-air bytes (0x10/0x78, no payload). - manifest: note the unreleased esp-nimble-cpp registerServicesFirst() dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Critical synchronization, lifecycle, handshake, and configuration issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (13)
Previously missed (12) — in code that hasn't changed since the last review.
components/switch2_pro/example/main/switch2_pro_example.cpp:31
- Initialization failures are ignored, including failure of the erase/retry path, so the example proceeds even though this component's advertised reconnect/wake behavior cannot persist a bond. Check the final NVS status before constructing the controller.
components/switch2_pro/include/switch2_pro_report.hpp:82 - At the documented neutral input
v == 0, this expression truncates4095 / 2to 2047, despiteSTICK_CENTERbeing 2048. Consequently every default/reset report encodes both sticks one count off center. Scale each half aroundSTICK_CENTERso the endpoints remain 0/4095 and zero maps exactly to 2048.
components/switch2_pro/src/switch2_pro.cpp:425 - The underlying API returns
falsewhen advertising cannot start, but that result is discarded and success is logged;wake_console()likewise returns true unconditionally. This makes callers believe the wake/discovery advertisement was issued when it was not. Return and propagate the start result throughstart_advertising(),advertise(),wake_console(), and initialization.
components/switch2_pro/src/switch2_pro.cpp:445 wake_console_on_boot_is immutable and remains true after the first successful connection. With the default configuration, every later disconnect therefore immediately advertises the wake variant, and the persistent timer resumes doing so every five seconds, potentially waking a console the user just put to sleep. Use a one-shot wake-pending latch initialized at boot and clear/cancel it on successful connection, as the public configuration contract states.
components/switch2_pro/src/switch2_pro.cpp:788- Byte
0x0bis forced to the rumble-enabled value even whenFEATURE_SELECThas not enabled rumble or has explicitly disabled it. This contradictsPro2InputReport::reset()and the feature-state contract in the header, and sends a feature state different from the negotiated mask.
components/switch2_pro/src/switch2_pro.cpp:853 - A failed
ble_store_write_our_secis logged at INFO as “ready,” and callers continue to mark/save the pairing even though the controller cannot answer the upcoming encryption request. Propagate this failure frominject_ltk()so finalization/reconnect can fail explicitly rather than reporting a usable bond.
components/switch2_pro/src/switch2_pro.cpp:885 - The return values from
nvs_set_blobandnvs_commitare ignored, yet the method logs success and updates the in-memory bond even when persistence failed. This makes pairing appear durable while reconnect/wake fails after reboot. Only publish success after both operations complete successfully.
components/switch2_pro/tools/patch_nimble_5ms.py:205 - Targets with two controller archives are modified one at a time. If the first archive is patched and validation of the second archive fails, the tool exits after leaving the global ESP-IDF installation partially patched. Preflight every archive and create all backups before writing any archive, or roll back already-written archives on failure.
components/switch2_pro/tools/smoke_test_5ms.py:99 - The smoke test documents exit code 2 for tool errors, but failures from
arraiseCalledProcessError, missing tools raiseOSError, andobjdumpfailures are silently treated as empty output; the first two terminate with Python's exit code 1, which is documented as “unpatched.” Catch tool-execution failures and consistently return the indeterminate/error status 2.
components/switch2_pro/DESIGN.md:43 - This states that every link is driven at 5 ms and cannot stream without the patch, contradicting the verified behavior documented elsewhere in this PR: fresh pairing and first-session streaming use 15 ms, while only bonded reconnect/wake starts at 5 ms. Clarify the distinction here because it changes whether the invasive patch is required.
components/switch2_pro/include/switch2_pro.hpp:195 - This says on-change mode is the default, but
Config::continuous_streamingdefaults to true. Update the method documentation so generated API docs describe the actual default behavior.
components/switch2_pro/include/switch2_pro_protocol.hpp:40 - The protocol comment declares this characteristic as
WRITE, butbuild_gatt()intentionally creates it withNIMBLE_PROPERTY::WRITE_NRto match the real controller. Correct the public protocol documentation to avoid sending users toward the wrong GATT layout.
components/switch2_pro/DESIGN.md:85
- This says absolute handles are not strict and “we discover by UUID,” whereas the implementation and required
registerServicesFirst()API are explicitly based on the console using fixed handles without discovery. Resolve this contradiction; it changes whether the exact GATT ordering is a protocol requirement or merely emulation fidelity.
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).
- Files reviewed: 32/32 changed files
- Comments generated: 9
- Review effort level: Balanced
- data races: make the cross-thread session state atomic (input_subscribed_, active_conn_handle_, enabled_features_) and move the grouped per-session counter/link-baseline resets out of on_subscribe into the streaming thread's session-start, so they stay single-writer. - lifecycle: in ~Switch2Pro() stop/join the wake timer and detach the this-capturing GAP/GATT callbacks (+ stop advertising) before members are destroyed, so a late callback can't touch freed state. - pairing: gate FINALISE on an in-order handshake (pairing_stage_ must reach 3: address-exchange -> key-exchange -> confirm) so a malformed/out-of-order peer can't persist an all-zero/partial bond. - portability: #error if CONFIG_BT_NIMBLE_EXT_ADV is enabled (this component uses the legacy advertising path). - register the component in upload_components.yml; fix alphabetical ordering in build.yml and Doxyfile (switch2_pro before sx126x; headers main/protocol/report). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Warning
Do not merge until h2zero/esp-nimble-cpp#443 is merged and released.
This PR bumps the
esp-nimble-cppsubmodule to a branch commit that carries the newNimBLEServer::registerServicesFirst()API (needed for the exact GATT handle layout).Once #443 is released, the submodule pin moves to the released commit and the manifest
dependency is bumped. Opening now for review.
Summary
Adds a new
switch2_procomponent that emulates a Nintendo Switch 2 Pro Controllerover BLE so a real Switch 2 console accepts it as a native controller — pairing,
encrypted link, continuous input streaming, reconnect, and wake-from-sleep. Built on
espp::BleGattServer(NimBLE); implements the reverse-engineered Nintendo custom GATTinterface (not HID-over-GATT) and the console's custom pairing handshake (not BLE SMP).
Status
verified against a real console. Pairs (full battery + correct icon), streams input
lag-free at ~62 Hz, buttons/sticks register on "Test Input Devices", and reconnect +
wake-from-sleep work without re-pairing.
BTDM BLE controller degrades the encrypted stream under sustained notifications. This
is documented as a known issue and is a fast-follow — see the component README.
What's included
switch2_procomponent: pairing crypto (known-answer verified) + LTK injection,the exact GATT handle layout, the full console init/command sequence, NVS bond
persistence, continuous input streaming with real (mbuf-pool-based) backpressure, and
a
wake_console()API.ble_gatt_serverenhancement:conn_params_update_callback.esp-nimble-cppsubmodule bump forregisterServicesFirst()(see the warning above).SWITCH2_PRO_PATCH_NIMBLE_5MScontroller patch (off by default; requiredonly for reconnect/wake, since the console connects a bonded controller at a sub-spec
5 ms interval) + a hardware-free
smoke_test_5ms.pyverifier.doc/en/ble/switch2_pro*, Doxyfile), CI matrix entries (C6 + S3), example, andDESIGN/README.
Testing
working; input stays lag-free indefinitely at the console's 15 ms / ~62 Hz cadence.
esp32c6andesp32s3. The 5 ms patch is off bydefault so CI builds do not mutate the IDF install.
Attribution / scope
Interoperability only — no Nintendo or Espressif binaries are included. The pairing
"authentication" relies on a published fixed key (a possession check, not per-device
attestation). Protocol reverse-engineering credit: the community, principally
ndeadly/switch2_controller_research; the NimBLE 5 ms patch technique is adapted (MIT)
from zhantss/ESP32-BLE5-NSController-Emulator.
🤖 Generated with Claude Code