Skip to content

feat(switch2_pro): Nintendo Switch 2 Pro Controller BLE emulation - #765

Open
finger563 wants to merge 17 commits into
mainfrom
feat/switch2-pro
Open

feat(switch2_pro): Nintendo Switch 2 Pro Controller BLE emulation#765
finger563 wants to merge 17 commits into
mainfrom
feat/switch2-pro

Conversation

@finger563

Copy link
Copy Markdown
Contributor

Warning

Do not merge until h2zero/esp-nimble-cpp#443 is merged and released.
This PR bumps the esp-nimble-cpp submodule to a branch commit that carries the new
NimBLEServer::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_pro component that emulates a Nintendo Switch 2 Pro Controller
over 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 GATT
interface (not HID-over-GATT) and the console's custom pairing handshake (not BLE SMP).

Status

  • ESP32-C6 (and the other open-NimBLE-controller chips — C61/C2/H2): fully working,
    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.
  • ESP32-S3: builds and pairs, but does not yet stream input reliably. Its closed
    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

  • The switch2_pro component: 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.
  • A small, additive ble_gatt_server enhancement: conn_params_update_callback.
  • esp-nimble-cpp submodule bump for registerServicesFirst() (see the warning above).
  • The opt-in SWITCH2_PRO_PATCH_NIMBLE_5MS controller patch (off by default; required
    only for reconnect/wake, since the console connects a bonded controller at a sub-spec
    5 ms interval) + a hardware-free smoke_test_5ms.py verifier.
  • Docs (doc/en/ble/switch2_pro*, Doxyfile), CI matrix entries (C6 + S3), example, and
    DESIGN/README.

Testing

  • On-hardware against a real Switch 2 (ESP32-C6): pairing, input, reconnect, wake — all
    working; input stays lag-free indefinitely at the console's 15 ms / ~62 Hz cadence.
  • The example builds clean for both esp32c6 and esp32s3. The 5 ms patch is off by
    default 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

Copilot AI lite review requested due to automatic review settings September 3, 2026 15:46
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_pro component (protocol constants, pairing crypto/self-test, GATT layout, advertising variants, bond persistence, and an input streaming task with backpressure).
  • Extends espp::BleGattServer with 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.

Comment thread components/switch2_pro/idf_component.yml
Comment thread components/switch2_pro/src/switch2_pro.cpp
Comment thread components/switch2_pro/src/switch2_pro.cpp Outdated
Comment thread components/switch2_pro/src/switch2_pro_pairing.cpp
Comment thread components/switch2_pro/include/switch2_pro.hpp Outdated
Comment thread components/switch2_pro/include/switch2_pro.hpp Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 truncates 4095 / 2 to 2047, despite STICK_CENTER being 2048. Consequently every default/reset report encodes both sticks one count off center. Scale each half around STICK_CENTER so the endpoints remain 0/4095 and zero maps exactly to 2048.
    components/switch2_pro/src/switch2_pro.cpp:425
  • The underlying API returns false when 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 through start_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 0x0b is forced to the rumble-enabled value even when FEATURE_SELECT has not enabled rumble or has explicitly disabled it. This contradicts Pro2InputReport::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_sec is 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 from inject_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_blob and nvs_commit are 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 ar raise CalledProcessError, missing tools raise OSError, and objdump failures 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_streaming defaults 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, but build_gatt() intentionally creates it with NIMBLE_PROPERTY::WRITE_NR to 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

Comment thread components/switch2_pro/include/switch2_pro.hpp Outdated
Comment thread components/switch2_pro/src/switch2_pro.cpp
Comment thread components/switch2_pro/src/switch2_pro.cpp
Comment thread components/switch2_pro/src/switch2_pro.cpp
Comment thread components/switch2_pro/src/switch2_pro.cpp
Comment thread components/switch2_pro/CMakeLists.txt
Comment thread .github/workflows/build.yml Outdated
Comment thread doc/Doxyfile Outdated
Comment thread doc/Doxyfile Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Multiple critical and moderate issues remain, and merging is gated on an unreleased upstream NimBLE API.

Review details

Suppressed comments (11)

Previously missed (8) — in code that hasn't changed since the last review.

components/switch2_pro/example/main/switch2_pro_example.cpp:31

  • This example proceeds even if NVS initialization or the erase/retry fails. The controller will then appear to pair but cannot persist its bond, so the documented reboot reconnect/wake flow breaks. Check both operations and the final initialization result, as the existing NVS initialization pattern does in components/st25dv/example/main/st25dv_example.cpp:53-59.
    components/switch2_pro/example/sdkconfig.defaults:21
  • The default target is ESP32-C6, whose CPU supports at most 160 MHz, so these 240 MHz assignments are not valid for the default build and are ignored rather than providing the claimed headroom. Keep 240 MHz in an S3-specific defaults file and configure 160 MHz in sdkconfig.defaults.esp32c6.
    components/switch2_pro/include/switch2_pro_report.hpp:82
  • The conversion truncates the normalized midpoint: axis_to_u12(0.0f) produces 2047 (0x7ff) even though STICK_CENTER and the public API define center as 2048 (0x800). Round the scaled value so a default/centered report encodes the declared midpoint.
    components/switch2_pro/src/switch2_pro.cpp:911
  • The return values from both NVS writes are ignored, so a full/corrupt flash can make this function log “saved bond” even though reconnect-after-reboot will fail. Check nvs_set_blob and nvs_commit before updating the cached peer fields or reporting success.
    components/switch2_pro/tools/patch_nimble_5ms.py:225
  • An existing .original is reused forever. If ESP-IDF is upgraded in place, the new unpatched archive is patched while the old-version backup remains; a later --restore then installs an archive from the previous IDF version. Because this branch has already verified that the current archive contains the unpatched pattern, refresh the backup from it before each new patch.
    components/switch2_pro/tools/smoke_test_5ms.py:125
  • The module documents exit code 2 for tool/extraction errors, but an unavailable ar/objdump or failed extraction raises an uncaught subprocess/OSError and Python exits with 1—the same code documented for a valid unpatched controller. This makes automation misclassify verifier failures as an unpatched result; normalize those exceptions to exit 2.
    components/switch2_pro/include/switch2_pro.hpp:195
  • This documentation says on-change mode is the default, but Config::continuous_streaming defaults to true. Generated API docs therefore describe the opposite runtime behavior.
    components/switch2_pro/include/switch2_pro_protocol.hpp:41
  • The protocol header documents this characteristic as WRITE, but build_gatt() intentionally creates it as WRITE_NR to match the real controller. Update the public wire-protocol documentation so consumers do not implement the wrong property.

components/switch2_pro/DESIGN.md:94

  • This table also says the firmware-update characteristic is WRITE, while the implementation and its source comment use WRITE_NR. Keep the design reference aligned with the actual GATT table.
      4147423d-…       WRITE         firmware update (large)

components/switch2_pro/DESIGN.md:43

  • This design statement contradicts the implemented and documented behavior elsewhere: fresh pairing and first-session input run at 15 ms without the patch; only bonded reconnect/wake begins at 5 ms. As written, it incorrectly tells readers that all input streaming requires the controller patch.
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 the console won't stream input.

components/switch2_pro/DESIGN.md:85

  • This says absolute handles are not strict and that UUID discovery is used, but the component and its required registerServicesFirst() dependency are explicitly built around the console addressing fixed handles without discovery. That contradiction obscures the core GATT-layout requirement.
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: 33/33 changed files
  • Comments generated: 7
  • Review effort level: Balanced

Comment thread components/switch2_pro/idf_component.yml
Comment thread components/switch2_pro/src/switch2_pro.cpp Outdated
Comment thread components/switch2_pro/tools/patch_nimble_5ms.py Outdated
Comment thread components/switch2_pro/tools/smoke_test_5ms.py Outdated
Comment thread components/switch2_pro/src/switch2_pro.cpp Outdated
Comment thread components/switch2_pro/src/switch2_pro.cpp Outdated
Comment thread components/switch2_pro/src/switch2_pro.cpp Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Critical patching defects and unresolved concurrency, persistence, wake-state, encoding, and configuration issues require changes and human review.

Review details

Suppressed comments (7)

Previously missed (7) — in code that hasn't changed since the last review.

components/switch2_pro/example/sdkconfig.defaults:21

  • The default target is ESP32-C6, whose CPU tops out at 160 MHz, but these common defaults request 240 MHz. The C6 build ignores/coerces this unsupported setting, so the documented performance configuration is not actually applied. Move the 240 MHz settings to S3-specific defaults and configure 160 MHz for C6.
    components/switch2_pro/include/switch2_pro.hpp:101
  • is_paired() can run in the application task while the NimBLE command callback assigns paired_ during FINALISE. Because paired_ is a plain bool, that is a C++ data race; make it atomic (and load it explicitly at logging/getter sites) or protect it with the connection-state lock.
    components/switch2_pro/include/switch2_pro_report.hpp:82
  • The conversion truncates neutral 0.0 to 0x7ff, even though this class defines STICK_CENTER as 0x800 and reset() uses 0.0 for centered sticks. Round to the nearest 12-bit value so default reports encode the declared center.
    components/switch2_pro/src/switch2_pro.cpp:261
  • These diagnostics are owned and updated by the streaming thread, but the NimBLE disconnect callback reads stream_start_us_, enomem_count_, and wedge_reported_ concurrently before stopping the stream. The remaining unsynchronized reads are undefined behavior; take a synchronized snapshot or make every cross-thread diagnostic atomic.
    components/switch2_pro/src/switch2_pro.cpp:932
  • Both persistence operations are ignored, so a full/read-only NVS partition is still logged as a saved bond even though reconnect after reboot will fail. Check nvs_set_blob and nvs_commit before updating the cached peer or reporting success.
    components/switch2_pro/include/switch2_pro.hpp:196
  • The API documentation labels on-change mode as the default, but Config::continuous_streaming defaults to true. Update this description so users understand that the component continuously sends every interval unless they opt out.
    components/switch2_pro/include/switch2_pro_protocol.hpp:41
  • This public protocol comment says the firmware-update characteristic supports WRITE, but build_gatt() creates it with NIMBLE_PROPERTY::WRITE_NR. Documenting the wrong property can produce an incompatible GATT table for consumers reusing these constants.
  • Files reviewed: 33/33 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment on lines +203 to +207
any_patched_now = False
for lib in libs:
data = read_object(ar, lib, obj)
n_old, n_new = data.count(old), data.count(new)
tag = os.path.basename(lib)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14656e4 — the patcher now PREFLIGHTS every archive (validates the pattern is present and unique) before writing any of them, and wraps the writes so that if a later write fails it rolls back every archive already modified. A bad/ambiguous second archive can no longer leave the first one patched.

Comment on lines +222 to +225
backup = lib + ".original"
if not os.path.isfile(backup):
shutil.copy2(lib, backup)
print(f"{tag}: backed up -> {os.path.basename(backup)}")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14656e4 — the .original backup is now refreshed from the current archive on each patch. Since the preflight has just confirmed the archive is unpatched, this backs up the correct (current-IDF) version, so a --restore after an ESP-IDF upgrade no longer copies a stale prior-version archive back.

Comment on lines +487 to +489
static constexpr std::array<uint8_t, 6> kZeroAddr{};
if (host_addr_ == kZeroAddr)
return false; // no bonded console identity to address the wake to

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 14656e4 — wake_console() now requires reconnect_mode_ (a real persisted bond from a completed FINALISE or a bond loaded from NVS) in addition to a non-zero host_addr_. Since host_addr_ becomes nonzero mid-pairing (EXCHANGE_ADDRESSES), a pairing that then fails no longer lets wake_console() report success or advertise.

finger563 and others added 15 commits September 3, 2026 23:11
…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>
- 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>
- 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>
finger563 and others added 2 commits September 3, 2026 23:13
Copilot round 3:
- manifest: declare the espp/timer dependency (used in CMake + public header).
- destructor: fully deinit NimBLE (ble_gatt_server_.deinit()) so the per-
  characteristic ChannelCallbacks (each holds owner_ == this) are destroyed
  before member teardown, not just the server-level callbacks.
- tools: replace PEP-604 `str | None` with Optional[str] so the 5 ms
  patcher/verifier run under ESP-IDF 5.5's Python 3.9.
- wake-on-boot: model as one-shot (boot_wake_pending_) — cleared on the first
  connection so we stop re-broadcasting the wake flag and can't re-wake a
  console the user intentionally sleeps; user wake (wake_console) is separate.
- on-change streaming: snapshot input_report_ once under the lock and use that
  exact snapshot for the change-check, the send, and the baseline update.
- report byte 0x0B now derives from FEATURE_RUMBLE (matches the negotiated mask
  and the header doc) instead of being hardcoded.

Self-review:
- avoid a deadlock: the wake timer cancels ITSELF from its own task once
  boot-wake is done, instead of the connect callback joining the timer task.
- make the wake flags (wake_pending_, boot_wake_pending_) atomic (cross-thread).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tomicity)

- wake_console(): require a real persisted bond (reconnect_mode_) before emitting
  a wake advertisement. host_addr_ alone is insufficient — it becomes nonzero
  mid-pairing (EXCHANGE_ADDRESSES), so a pairing that then fails would otherwise
  let wake_console() report success and advertise despite no bond.
- patch_nimble_5ms.py: preflight ALL archives before writing any, so a bad or
  ambiguous second archive can no longer leave the first one patched; roll back
  every modified archive if a later write fails. Refresh each .original backup
  from the current (preflight-confirmed unpatched) archive so --restore is safe
  after an ESP-IDF upgrade (no stale prior-version backup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

A critical data race and several moderate concurrency, persistence, encoding, and tooling issues remain unresolved.

Review details

Suppressed comments (11)

Previously missed (7) — in code that hasn't changed since the last review.

components/switch2_pro/example/main/switch2_pro_example.cpp:31

  • Both NVS erase and reinitialization errors are discarded. The example can then continue into pairing while bond persistence is unavailable, despite presenting reconnect as enabled. Follow the repository's NVS examples by checking the erase result, retaining the second init result, and failing on any final error.
    components/switch2_pro/include/switch2_pro_report.hpp:82
  • Neutral input is encoded one count below the declared protocol center: truncating the scaled value maps 0.0f to 0x7ff, while STICK_CENTER is 0x800. Because reset() uses this conversion, every default centered report is off-center. Round the non-negative scaled value to the nearest integer.
    components/switch2_pro/src/switch2_pro.cpp:936
  • The blob write and commit results are ignored, but the function still caches the peer and reports that the bond was saved. If NVS is full or the commit fails, reconnect/wake after reboot is silently lost. Only update state and log success after both operations succeed.
    components/switch2_pro/tools/smoke_test_5ms.py:126
  • The documented exit-code contract says errors return 2, but helper failures use sys.exit(message) and uncaught subprocess errors return status 1—the same status reserved for a valid unpatched result. Automation can therefore misclassify a missing archive/tool as merely unpatched. Normalize string SystemExit and runtime failures to 2 while preserving integer verdicts.
    components/switch2_pro/DESIGN.md:43
  • This blanket statement contradicts the verified behavior documented later in this file: fresh pairing and first-session streaming use 15 ms and do not need the patch; only bonded reconnect/wake starts at 5 ms. Clarify the scope so users do not believe basic input streaming requires modifying their ESP-IDF installation.
    components/switch2_pro/include/switch2_pro.hpp:196
  • This says on-change mode is the default, but Config::continuous_streaming defaults to true. The generated API documentation should describe continuous mode as the default.
    components/switch2_pro/include/switch2_pro_protocol.hpp:40
  • The wire-protocol documentation says this characteristic uses acknowledged WRITE, while build_gatt() deliberately creates it as WRITE_NR. Align the public protocol comment with the implemented and captured property.

components/switch2_pro/DESIGN.md:94

  • This GATT table lists the firmware-update characteristic as WRITE, but build_gatt() reproduces it as WRITE_NR and states that write-without-response is required for parity with the real controller. Keep the design table consistent with the implemented protocol.
      4147423d-…       WRITE         firmware update (large)

components/switch2_pro/example/main/switch2_pro_example.cpp:63

  • The example's default target is ESP32-C6, where the selected BOOT pin is GPIO9, so calling it “GPIO0” here is misleading. Refer to the target-dependent constant instead.
  // The BOOT button (GPIO0) is wired as the A button for easy testing.

components/switch2_pro/include/switch2_pro.hpp:228

  • This comment still describes notify_in_flight_ as flow control and references the removed notify_input_report path. The implementation explicitly treats this callback/count as telemetry only and uses msys1_headroom() for backpressure, so the API documentation is stale.
  /// Notification tx-complete for `characteristic` (frees a tx buffer). Decrements
  /// the in-flight count for the input characteristic so notify_input_report can
  /// flow-control the stream and never overrun the link's tx pool.

components/switch2_pro/src/switch2_pro.cpp:261

  • These diagnostics are written by the streaming thread while the NimBLE disconnect callback reads them, so disconnecting during streaming causes a C++ data race. Moving their resets into the stream thread does not make these cross-thread reads safe; make the shared counters/flags atomic or publish a synchronized snapshot for the callback.
                   (now - stream_start_us_) / 1e6f, tx_completions_.load(), enomem_count_,
                   wedge_reported_, (now - last_tx_complete_us_.load()) / 1000.0f, pool_stats());
  • Files reviewed: 33/33 changed files
  • Comments generated: 1
  • Review effort level: Balanced

NimBLECharacteristic *command_response2_{nullptr};

// Pairing state.
bool paired_{false};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants