From 623e7c08c46ddd333ba5b0c0de039a53b90316df Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 12 Aug 2026 15:54:05 -0500 Subject: [PATCH 01/18] feat(switch2_pro): GATT + pairing skeleton for Switch 2 Pro Controller 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 --- components/switch2_pro/.gitignore | 3 + components/switch2_pro/CMakeLists.txt | 35 ++++ components/switch2_pro/DESIGN.md | 103 ++++++++++ components/switch2_pro/Kconfig | 22 ++ components/switch2_pro/README.md | 58 ++++++ components/switch2_pro/example/CMakeLists.txt | 22 ++ .../switch2_pro/example/main/CMakeLists.txt | 2 + .../example/main/switch2_pro_example.cpp | 44 ++++ components/switch2_pro/example/partitions.csv | 4 + .../switch2_pro/example/sdkconfig.defaults | 29 +++ components/switch2_pro/idf_component.yml | 26 +++ .../switch2_pro/include/switch2_pro.hpp | 94 +++++++++ .../include/switch2_pro_pairing.hpp | 40 ++++ .../include/switch2_pro_protocol.hpp | 134 ++++++++++++ .../include/switch2_pro_report.hpp | 99 +++++++++ components/switch2_pro/src/switch2_pro.cpp | 193 ++++++++++++++++++ .../switch2_pro/src/switch2_pro_pairing.cpp | 52 +++++ .../switch2_pro/tools/patch_nimble_5ms.py | 109 ++++++++++ 18 files changed, 1069 insertions(+) create mode 100644 components/switch2_pro/.gitignore create mode 100644 components/switch2_pro/CMakeLists.txt create mode 100644 components/switch2_pro/DESIGN.md create mode 100644 components/switch2_pro/Kconfig create mode 100644 components/switch2_pro/README.md create mode 100644 components/switch2_pro/example/CMakeLists.txt create mode 100644 components/switch2_pro/example/main/CMakeLists.txt create mode 100644 components/switch2_pro/example/main/switch2_pro_example.cpp create mode 100644 components/switch2_pro/example/partitions.csv create mode 100644 components/switch2_pro/example/sdkconfig.defaults create mode 100644 components/switch2_pro/idf_component.yml create mode 100644 components/switch2_pro/include/switch2_pro.hpp create mode 100644 components/switch2_pro/include/switch2_pro_pairing.hpp create mode 100644 components/switch2_pro/include/switch2_pro_protocol.hpp create mode 100644 components/switch2_pro/include/switch2_pro_report.hpp create mode 100644 components/switch2_pro/src/switch2_pro.cpp create mode 100644 components/switch2_pro/src/switch2_pro_pairing.cpp create mode 100644 components/switch2_pro/tools/patch_nimble_5ms.py diff --git a/components/switch2_pro/.gitignore b/components/switch2_pro/.gitignore new file mode 100644 index 0000000000..f8e49b8257 --- /dev/null +++ b/components/switch2_pro/.gitignore @@ -0,0 +1,3 @@ +example/build/ +example/sdkconfig +example/sdkconfig.old diff --git a/components/switch2_pro/CMakeLists.txt b/components/switch2_pro/CMakeLists.txt new file mode 100644 index 0000000000..26a6c50a27 --- /dev/null +++ b/components/switch2_pro/CMakeLists.txt @@ -0,0 +1,35 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component ble_gatt_server esp-nimble-cpp + PRIV_REQUIRES mbedtls) + +# Opt-in: patch the prebuilt NimBLE controller library to accept the console's +# sub-spec 5 ms connection interval. Off by default. Only applies to RISC-V +# targets with the open NimBLE controller (C6/C61/C2/H2); mutates the global +# $IDF_PATH install, so it is deliberately explicit and never silent. On S3 the +# equivalent is a Kconfig/Espressif path (see DESIGN.md), not this patch. +if(CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS) + if(IDF_TARGET STREQUAL "esp32c6" OR IDF_TARGET STREQUAL "esp32c61" + OR IDF_TARGET STREQUAL "esp32c2" OR IDF_TARGET STREQUAL "esp32h2") + message(WARNING + "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS is ON: patching libble_app.a " + "in $ENV{IDF_PATH} for a 5 ms connection interval (${IDF_TARGET}). " + "This modifies your global ESP-IDF install; run tools/patch_nimble_5ms.py " + "--restore to undo.") + find_package(Python3 COMPONENTS Interpreter REQUIRED) + execute_process( + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_LIST_DIR}/tools/patch_nimble_5ms.py + --idf-path $ENV{IDF_PATH} --target ${IDF_TARGET} + RESULT_VARIABLE _switch2_patch_result) + if(NOT _switch2_patch_result EQUAL 0) + message(FATAL_ERROR "[switch2_pro] NimBLE 5 ms patch failed (${_switch2_patch_result})") + endif() + else() + message(WARNING + "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS has no effect on ${IDF_TARGET}: " + "the binary patch only applies to RISC-V NimBLE controllers " + "(C6/C61/C2/H2). See DESIGN.md for the S3 path.") + endif() +endif() diff --git a/components/switch2_pro/DESIGN.md b/components/switch2_pro/DESIGN.md new file mode 100644 index 0000000000..b8220da361 --- /dev/null +++ b/components/switch2_pro/DESIGN.md @@ -0,0 +1,103 @@ +# switch2_pro — design notes + +## Goal + +Emulate a **Nintendo Switch 2 Pro Controller over BLE** so a real Switch 2 console +accepts it as a native controller — including waking the console from sleep over BLE. + +This is NOT the Switch 1 protocol. The Switch 2 moved controllers from Bluetooth +Classic HID to **BLE with a proprietary GATT layer** (not HID-over-GATT), a custom +pairing scheme (not BLE SMP), and a custom command channel. So espp's existing +`hid_service` / `hid-rp` (standard HOGP + report descriptors) do **not** apply here; +this component builds custom GATT services directly on `espp::BleGattServer`. + +## Sources / prior art + +- **Protocol facts**: `ndeadly/switch2_controller_research` (byte-level GATT map, + pairing handshake, command set, report formats; decrypted sniffer captures). +- **Working ESP32 reference** (MIT): `zhantss/ESP32-BLE5-NSController-Emulator` — + raw-NimBLE C emulator that a real Switch 2 accepts. We adapt its *approach and + structure* (with attribution) and reimplement on esp-nimble-cpp / `BleGattServer`. + We do **not** copy ndeadly's prose/tables wholesale, and we do **not** vendor + Espressif's `libble_app.a`. + +## Feasibility (verified) + +Not blocked by cryptographic attestation. The pairing "authentication" is weak and +reproducible: a **fixed controller key** `B1 = 5CF6EE792CDF05E1BA2B6325C41A5F10`, an +XOR-derived link key `LTK = A1 ⊕ B1`, and a single AES-128-ECB possession proof +`B2 = AES_ECB(reverse(LTK), reverse(A2))`. Golden vector (host-verified with openssl): + + A1 = 3503e92982877124bea80c664615834b (host public key, from console) + B1 = 5cf6ee792cdf05e1ba2b6325c41a5f10 (fixed controller key) + A2 = 6fc6df8ad8fedf15bb8c15e91f320544 (host challenge) + LTK = 69f50750ae5874c504836f43820fdc5b (= A1 ⊕ B1) + B2 = 134c97f511b9b6dd4d86fd40f536e9ed (= AES-128-ECB(rev(LTK), rev(A2))) + +`switch2_pro_pairing.*` implements this and self-tests against the golden vector at +init (logged pass/fail) — verifiable on-device with no console. + +## The 5 ms connection-interval problem + +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. + +- **C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): requires a **binary patch + of the prebuilt `$IDF_PATH/.../libble_app.a`** (change the min-interval floor from + 6→4 units). Provided as `tools/patch_nimble_5ms.py` (adapted from zhantss, MIT). +- **S3 / C3**: a different closed controller lib; historically a Kconfig + (`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`, esp-idf#18467). Note: that symbol is + **absent on IDF 6.0.1** — the S3 path needs re-verification on current IDF. + +**Build integration (decision: opt-in, never silent).** The patch mutates the user's +global IDF install and is version-fragile (the RISC-V byte pattern is not guaranteed +across IDF versions). So it is gated behind a component Kconfig option +`SWITCH2_PRO_PATCH_NIMBLE_5MS` (default **n**). When enabled for a RISC-V target, the +component CMake invokes the patcher at configure time (idempotent, with `--verify-only` +first) and prints a loud notice. It is **not required for the GATT + pairing skeleton +milestone** — pairing runs over the command channel independent of the interval. + +## GATT layout (reproduced from captures) + +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). + + 00c5af5d-1964-4e30-8f51-1956f96bd280 (svc1, purpose unclear; chars …281/282/283) + ab7de9be-89fe-49ad-828f-118f09df7fd0 (svc2, main) + ab7de9be-…-fd2 READ/NOTIFY common input report (0x05) + 7492866c-… READ/NOTIFY Pro Controller 2 input report (0x09) + cc483f51-… WRITE_NR vibration / HD rumble + 649d4ac9-… WRITE_NR command (basic) + 3dacbc7e-… WRITE_NR vibration+command combined (pairing runs here) + 4147423d-… WRITE firmware update (large) + c765a961-… NOTIFY command response #1 + 506d9f7d-… NOTIFY command response #2 + +Security: **no SMP** — the console app-level-pairs over the command channel and will +disconnect a peer that initiates SMP. We configure NimBLE not to initiate pairing; +the LTK from the 0x15 exchange is what encrypts the link. Bond (host addr + LTK) +persists in NVS for reconnect + wake. + +## Milestones + +1. **GATT + pairing skeleton (this milestone)**: custom GATT tree stands up, + advertises with Nintendo manufacturer data, completes the 0x15 pairing handshake. + Crypto host-verified; console-accepts-pairing is the on-hardware exit test. +2. Command dispatch + init sequence (flash/calibration reads, feature-select, LEDs, + firmware-update-prompt suppression) so the console finishes bring-up. +3. Input report streaming (report 0x09: buttons incl. C/GL/GR, 12-bit sticks, IMU) + at the console's cadence — needs the 5 ms patch for stability. +4. Wake-from-sleep advertisement (bonded reconnect with the 0x81 wake flag). + +## Component layout + + switch2_pro/ + include/switch2_pro.hpp Switch2Pro class (over BleGattServer) + include/switch2_pro_protocol.hpp UUIDs, command/subcommand ids, feature bits, fixed key, golden vector + include/switch2_pro_report.hpp Pro Controller 2 input report (0x09) packed struct + src/switch2_pro.cpp GATT setup, advertising, GAP, command dispatch + src/switch2_pro_pairing.cpp pairing crypto (mbedTLS) + state machine + self-test + tools/patch_nimble_5ms.py opt-in 5 ms connection-interval patcher (RISC-V) + Kconfig SWITCH2_PRO_PATCH_NIMBLE_5MS opt-in + example/ C6-primary, S3-buildable diff --git a/components/switch2_pro/Kconfig b/components/switch2_pro/Kconfig new file mode 100644 index 0000000000..adc0b05472 --- /dev/null +++ b/components/switch2_pro/Kconfig @@ -0,0 +1,22 @@ +menu "Switch 2 Pro Controller" + + config SWITCH2_PRO_PATCH_NIMBLE_5MS + bool "Patch NimBLE to allow the console's 5 ms connection interval" + default n + help + The Switch 2 console drives the BLE link at a 5 ms connection + interval, below the 7.5 ms Bluetooth spec minimum. To stream input + reports stably the controller stack must accept it. + + When enabled on a RISC-V target with the open NimBLE controller + (ESP32-C6/C61/C2/H2), the component build patches the prebuilt + libble_app.a in your global $IDF_PATH install to lower the + minimum-interval floor. THIS MODIFIES YOUR ESP-IDF INSTALLATION. + Undo with tools/patch_nimble_5ms.py --restore. + + Has no effect on ESP32-S3/C3 (different closed controller; see + DESIGN.md for the Kconfig/Espressif path). Not required for pairing + or the GATT skeleton — only for stable input streaming. Leave this + OFF unless you understand the consequences. + +endmenu diff --git a/components/switch2_pro/README.md b/components/switch2_pro/README.md new file mode 100644 index 0000000000..f4034f517f --- /dev/null +++ b/components/switch2_pro/README.md @@ -0,0 +1,58 @@ +# Switch 2 Pro Controller (BLE) + +Emulate a **Nintendo Switch 2 Pro Controller over BLE** so a real Switch 2 +console accepts it as a native controller — including waking the console from +sleep. Built on `espp::BleGattServer` (NimBLE). + +> **Status: early — GATT + pairing skeleton.** This milestone stands up the +> custom Nintendo GATT service tree, advertises with Nintendo manufacturer +> data, and implements the reverse-engineered pairing crypto (verified against +> a known-answer vector). The full console init/calibration sequence, input +> report streaming, and console-accept verification on real hardware are +> follow-up milestones (see `DESIGN.md`). + +Unlike the original Switch (Bluetooth Classic HID), the Switch 2 uses a +**proprietary BLE GATT interface — not HID-over-GATT — and a custom pairing +handshake, not BLE SMP**. So this component does *not* use espp's `hid_service` +/ `hid-rp`; it builds the Nintendo custom services directly on `BleGattServer`. + +```cpp +#include "switch2_pro.hpp" + +espp::Switch2Pro controller({.device_name = "Pro Controller"}); +controller.init(); // verifies pairing crypto, builds GATT, advertises +``` + +## The 5 ms connection interval + +The console drives the BLE link at a **5 ms** connection interval, below the +7.5 ms Bluetooth spec minimum. Stable input streaming requires the stack to +accept it: + +- **ESP32-C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): enable the + Kconfig option **`SWITCH2_PRO_PATCH_NIMBLE_5MS`** (default off). When on, the + component build runs `tools/patch_nimble_5ms.py`, which **binary-patches the + prebuilt `libble_app.a` in your global `$IDF_PATH` install** to lower the + minimum-interval floor. This modifies your ESP-IDF installation; undo with + `python tools/patch_nimble_5ms.py --target esp32c6 --restore`. +- **ESP32-S3 / C3**: a different closed controller; the equivalent is an + Espressif Kconfig path (esp-idf#18467), not this binary patch. See `DESIGN.md`. + +The patch is **not** required for pairing or the GATT skeleton — only for +stable input streaming — so it stays off by default. + +## Attribution + +The protocol was reverse-engineered by the community, principally +[ndeadly/switch2_controller_research](https://github.com/ndeadly/switch2_controller_research). +The overall approach and the NimBLE-patch technique are adapted (MIT) from +[zhantss/ESP32-BLE5-NSController-Emulator](https://github.com/zhantss/ESP32-BLE5-NSController-Emulator). +This component reimplements the interoperability protocol on espp/NimBLE; it +contains no Nintendo or Espressif binaries. The pairing "authentication" relies +on a published fixed key and is a possession check, not per-device attestation. + +## Example + +See [example](./example) — builds for ESP32-C6 (primary) and is buildable for +S3. It brings up the controller, runs the pairing-crypto self-test, and +advertises for a console to pair with. diff --git a/components/switch2_pro/example/CMakeLists.txt b/components/switch2_pro/example/CMakeLists.txt new file mode 100644 index 0000000000..efcbc72e3d --- /dev/null +++ b/components/switch2_pro/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py switch2_pro ble_gatt_server" + CACHE STRING + "List of components to include" + ) + +project(switch2_pro_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/switch2_pro/example/main/CMakeLists.txt b/components/switch2_pro/example/main/CMakeLists.txt new file mode 100644 index 0000000000..a941e22ba7 --- /dev/null +++ b/components/switch2_pro/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/switch2_pro/example/main/switch2_pro_example.cpp b/components/switch2_pro/example/main/switch2_pro_example.cpp new file mode 100644 index 0000000000..6a47c65267 --- /dev/null +++ b/components/switch2_pro/example/main/switch2_pro_example.cpp @@ -0,0 +1,44 @@ +#include +#include + +#include "switch2_pro.hpp" + +#include "logger.hpp" + +using namespace std::chrono_literals; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "switch2_pro example", .level = espp::Logger::Verbosity::INFO}); + + //! [switch2_pro 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. + espp::Switch2Pro controller({ + .device_name = "Pro Controller", + .log_level = espp::Logger::Verbosity::INFO, + }); + + if (!controller.init()) { + logger.error("failed to initialize Switch2Pro"); + return; + } + logger.info("advertising — put the Switch 2 into controller pairing to connect"); + + // Report the current button/stick state once input streaming is enabled + // (staged in a follow-up milestone). For now we just build a report to show + // the API and let advertising/pairing run. + espp::switch2::Pro2InputReport report; + while (true) { + report.reset(); + report.increment_counter(); + report.set_a(true); // hold A as a placeholder + report.set_left_stick(0.f, 0.f); // centered + controller.set_input_report(report); + + logger.info("paired: {}", controller.is_paired()); + std::this_thread::sleep_for(1s); + } + //! [switch2_pro example] +} diff --git a/components/switch2_pro/example/partitions.csv b/components/switch2_pro/example/partitions.csv new file mode 100644 index 0000000000..8427228225 --- /dev/null +++ b/components/switch2_pro/example/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +phy_init, data, phy, 0xf000, 0x1000 +factory, app, factory, 0x10000, 2M diff --git a/components/switch2_pro/example/sdkconfig.defaults b/components/switch2_pro/example/sdkconfig.defaults new file mode 100644 index 0000000000..79ab0d4e35 --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -0,0 +1,29 @@ +CONFIG_IDF_TARGET="esp32c6" + +# Common ESP-related +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# Partition Table +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# BT config: NimBLE only (the Switch 2 controller interface is BLE) +CONFIG_BT_ENABLED=y +CONFIG_BT_BLUEDROID_ENABLED=n +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_LOG_LEVEL_NONE=y +CONFIG_BT_NIMBLE_NVS_PERSIST=y +CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=100 +CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 + +# NOTE: MAX_CCCDS should be 4 * MAX_BONDS +CONFIG_BT_NIMBLE_MAX_BONDS=3 +CONFIG_BT_NIMBLE_MAX_CCCDS=128 + +# The Switch 2 console drives a 5 ms connection interval (below the BLE spec +# minimum). Stable input streaming additionally requires the opt-in NimBLE +# patch — enable it (RISC-V targets only) and understand the caveats: +# CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS=y diff --git a/components/switch2_pro/idf_component.yml b/components/switch2_pro/idf_component.yml new file mode 100644 index 0000000000..6158567214 --- /dev/null +++ b/components/switch2_pro/idf_component.yml @@ -0,0 +1,26 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Emulate a Nintendo Switch 2 Pro Controller over BLE (custom GATT + reverse-engineered pairing) so a real Switch 2 accepts it as a native controller and can be woken from sleep." +url: "https://github.com/esp-cpp/espp/tree/main/components/switch2_pro" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/ble/switch2_pro.html" +examples: + - path: example +tags: + - cpp + - Component + - BLE + - NimBLE + - HID + - Gamepad + - Nintendo + - Switch2 +dependencies: + idf: + version: '>=5.5' + h2zero/esp-nimble-cpp: + version: '>=2.3.0' + espp/ble_gatt_server: '>=1.0' + espp/base_component: '>=1.0' diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp new file mode 100644 index 0000000000..fdf4797270 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "NimBLEDevice.h" +#include "ble_gatt_server.hpp" + +#include "base_component.hpp" + +#include "switch2_pro_pairing.hpp" +#include "switch2_pro_protocol.hpp" +#include "switch2_pro_report.hpp" + +namespace espp { + +/// @brief 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. +/// +/// Milestone status: GATT + pairing skeleton. Advertising, the custom service +/// tree, and the 0x15 pairing handshake are wired; the full init/calibration +/// sequence and input-report streaming are staged in follow-up work (see +/// DESIGN.md). Emulating the console's 5 ms connection interval additionally +/// requires the opt-in NimBLE patch (tools/patch_nimble_5ms.py). +/// +/// \section switch2_pro_ex1 Example +/// \snippet switch2_pro_example.cpp switch2_pro example +class Switch2Pro : public BaseComponent { +public: + /// Configuration for the controller. + struct Config { + std::string device_name{"Pro Controller"}; ///< BLE advertised name. + Logger::Verbosity log_level{Logger::Verbosity::INFO}; + }; + + explicit Switch2Pro(const Config &config) + : BaseComponent("Switch2Pro", config.log_level) + , device_name_(config.device_name) + , ble_gatt_server_({.callbacks = {}, .log_level = Logger::Verbosity::WARN}) {} + + /// Initialize NimBLE, build the custom GATT services, configure security so + /// the console (not standard SMP) drives pairing, and start advertising. + /// @return true on success. + bool init(); + + /// Whether the pairing handshake has completed with a console. + bool is_paired() const { return paired_; } + + /// Latest controller state to report once input streaming is enabled. + void set_input_report(const switch2::Pro2InputReport &report) { input_report_ = report; } + +protected: + // --- setup --- + bool build_gatt(); + void configure_security(); + void start_advertising(bool wake, const std::array &host_addr = {}); + + // --- command channel --- + /// Handle a write on a command characteristic (0x0014 / 0x0016). Parses the + /// 8-byte header and dispatches; may notify a response on 0x001a / 0x001e. + void on_command_write(const uint8_t *data, size_t len); + void handle_pairing(switch2::PairingSub sub, const uint8_t *payload, size_t len); + void notify_response(const std::vector &response); + + friend class CommandCallbacks; + + std::string device_name_; + BleGattServer ble_gatt_server_; + + // Proprietary GATT characteristics (owned by NimBLE once created). + NimBLECharacteristic *common_input_{nullptr}; + NimBLECharacteristic *pro2_input_{nullptr}; + NimBLECharacteristic *command_{nullptr}; + NimBLECharacteristic *vibration_command_{nullptr}; + NimBLECharacteristic *command_response1_{nullptr}; + NimBLECharacteristic *command_response2_{nullptr}; + + // Pairing state. + bool paired_{false}; + std::array ltk_{}; ///< derived during key exchange + std::array host_addr_{}; ///< console BD_ADDR (from exchange-addresses) + + switch2::Pro2InputReport input_report_{}; +}; + +} // namespace espp diff --git a/components/switch2_pro/include/switch2_pro_pairing.hpp b/components/switch2_pro/include/switch2_pro_pairing.hpp new file mode 100644 index 0000000000..682cd918b1 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_pairing.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include "switch2_pro_protocol.hpp" + +/// @file switch2_pro_pairing.hpp +/// @brief Switch 2 controller pairing key derivation (the cracked handshake). + +namespace espp::switch2 { + +/// Link key derivation for the Switch 2 pairing handshake. +/// +/// The console sends a 16-byte "public key" A1; the controller replies with the +/// fixed constant B1 (CONTROLLER_KEY_B1). Both sides then form +/// `LTK = A1 ⊕ B1`. To confirm possession, the console sends a challenge A2 and +/// the controller returns `B2 = AES-128-ECB(reverse(LTK), reverse(A2))` (both +/// the key and the block are byte-reversed for the cipher operation). +struct PairingCrypto { + /// LTK = A1 ⊕ B1. + static std::array derive_ltk(const std::array &a1) { + std::array ltk{}; + for (size_t i = 0; i < 16; ++i) + ltk[i] = a1[i] ^ CONTROLLER_KEY_B1[i]; + return ltk; + } + + /// B2 = AES-128-ECB(key = reverse(ltk), data = reverse(a2)). + /// Returns the confirmation to send back to the console. Implemented in the + /// .cpp against mbedTLS. + static std::array confirm(const std::array <k, + const std::array &a2); + + /// Runs derive_ltk()/confirm() against the golden vector and returns true iff + /// both match. Intended to be logged at init as an on-device sanity check. + static bool self_test(); +}; + +} // namespace espp::switch2 diff --git a/components/switch2_pro/include/switch2_pro_protocol.hpp b/components/switch2_pro/include/switch2_pro_protocol.hpp new file mode 100644 index 0000000000..b14d73b48a --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_protocol.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include +#include + +/// @file switch2_pro_protocol.hpp +/// @brief Wire-protocol constants for the Nintendo Switch 2 Pro Controller BLE +/// interface (GATT UUIDs, command channel, pairing). +/// +/// Protocol facts are from the community reverse-engineering effort +/// ndeadly/switch2_controller_research and the zhantss ESP32 emulator (MIT). +/// These are the values a real Switch 2 console expects; they describe an +/// interoperability interface, not Nintendo source. + +namespace espp::switch2 { + +// --------------------------------------------------------------------------- +// GATT UUIDs (128-bit, string form for NimBLEUUID) +// --------------------------------------------------------------------------- + +/// Proprietary service 1 (purpose not fully understood). +inline constexpr const char *SERVICE1_UUID = "00c5af5d-1964-4e30-8f51-1956f96bd280"; +inline constexpr const char *SERVICE1_CHR_281_UUID = "00c5af5d-1964-4e30-8f51-1956f96bd281"; +inline constexpr const char *SERVICE1_CHR_282_UUID = "00c5af5d-1964-4e30-8f51-1956f96bd282"; +inline constexpr const char *SERVICE1_CHR_283_UUID = "00c5af5d-1964-4e30-8f51-1956f96bd283"; + +/// Main HID-like service. +inline constexpr const char *SERVICE2_UUID = "ab7de9be-89fe-49ad-828f-118f09df7fd0"; +/// Common input report (report id 0x05), all controller types. READ | NOTIFY. +inline constexpr const char *COMMON_INPUT_UUID = "ab7de9be-89fe-49ad-828f-118f09df7fd2"; +/// Pro Controller 2 input report (report id 0x09). READ | NOTIFY. +inline constexpr const char *PRO2_INPUT_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f8"; +/// Vibration / HD rumble output. WRITE_NO_RSP. +inline constexpr const char *VIBRATION_UUID = "cc483f51-9258-427d-a939-630c31f72b05"; +/// Command channel (basic). WRITE_NO_RSP. +inline constexpr const char *COMMAND_UUID = "649d4ac9-8eb7-4e6c-af44-1ea54fe5f005"; +/// Vibration+command combined — the pairing handshake runs here. WRITE_NO_RSP. +inline constexpr const char *VIBRATION_COMMAND_UUID = "3dacbc7e-6955-40b5-8eaf-6f9809e8b379"; +/// Firmware update (large writes). WRITE. +inline constexpr const char *FIRMWARE_UPDATE_UUID = "4147423d-fdae-4df7-a4f7-d23e5df59f8d"; +/// Command response #1. NOTIFY. +inline constexpr const char *COMMAND_RESPONSE1_UUID = "c765a961-d9d8-4d36-a20a-5315b111836a"; +/// Command response #2 — replies to writes on the vibration+command channel. NOTIFY. +inline constexpr const char *COMMAND_RESPONSE2_UUID = "506d9f7d-4278-4e95-a549-326ba77657e0"; + +// --------------------------------------------------------------------------- +// Advertising / identity +// --------------------------------------------------------------------------- + +inline constexpr uint16_t NINTENDO_MANUFACTURER_ID = 0x0553; +inline constexpr uint16_t VENDOR_ID = 0x057E; ///< Nintendo +inline constexpr uint16_t PRODUCT_ID_PRO2 = 0x2069; ///< Pro Controller 2 + +/// Manufacturer-specific advertising payload (AD type 0xFF) the console filters +/// on. Byte 0x0B is the wake indicator (0x00 discovery / 0x81 wake) and bytes +/// 0x0C..0x11 carry the bonded host BD_ADDR (byte-reversed); zero for discovery. +inline constexpr std::array MANUFACTURER_DATA_DISCOVERY = { + 0x53, 0x05, 0x01, 0x00, 0x03, 0x7e, 0x05, 0x69, 0x20, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00}; +inline constexpr size_t MANUFACTURER_WAKE_FLAG_OFFSET = 0x0b; +inline constexpr size_t MANUFACTURER_HOST_ADDR_OFFSET = 0x0c; +inline constexpr uint8_t WAKE_FLAG = 0x81; + +// --------------------------------------------------------------------------- +// Command channel framing +// --------------------------------------------------------------------------- + +/// 8-byte command header: +/// [0] command id [1] direction [2] transport [3] subcommand +/// [4] (unknown) [5] length/ACK [6..7] 0x0000 +inline constexpr size_t COMMAND_HEADER_SIZE = 8; +inline constexpr uint8_t DIR_HOST_TO_DEVICE = 0x91; +inline constexpr uint8_t DIR_DEVICE_TO_HOST = 0x01; +inline constexpr uint8_t TRANSPORT_USB = 0x00; +inline constexpr uint8_t TRANSPORT_BT = 0x01; +inline constexpr uint8_t ACK_MARKER = 0x78; ///< seen in header byte 5 of replies + +enum class Command : uint8_t { + NFC = 0x01, + FLASH_READ = 0x02, ///< read calibration / device info + INIT = 0x03, + PLAYER_LEDS = 0x09, + VIBRATION = 0x0a, + BATTERY = 0x0b, + FEATURE_SELECT = 0x0c, ///< enable motion / mouse / rumble / magnetometer + FIRMWARE_UPDATE = 0x0d, + FIRMWARE_INFO = 0x10, + PAIRING = 0x15, +}; + +/// Subcommands of Command::PAIRING (0x15). +enum class PairingSub : uint8_t { + EXCHANGE_ADDRESSES = 0x01, + CONFIRM_LTK = 0x02, ///< console sends challenge A2, controller returns B2 + FINALISE = 0x03, + EXCHANGE_KEYS = 0x04, ///< console sends A1, controller returns fixed B1 + SEND_PAIRING_INFO = 0x07, ///< inject host addr + LTK directly + STORE_PAIRING_INFO = 0x09, +}; + +/// Feature-select (0x0c) capability bits. +enum FeatureBits : uint8_t { + FEATURE_BUTTONS = 0x01, + FEATURE_STICKS = 0x02, + FEATURE_IMU = 0x04, + FEATURE_MOUSE = 0x10, + FEATURE_RUMBLE = 0x20, + FEATURE_MAGNETOMETER = 0x80, +}; +/// Default feature mask the Pro Controller 2 reports. +inline constexpr uint8_t PRO2_FEATURE_MASK = 0x2f; + +// --------------------------------------------------------------------------- +// Pairing crypto constants +// --------------------------------------------------------------------------- + +/// Fixed controller-side "public key" B1 returned during key exchange. Because +/// this is a known constant and the LTK is A1 ⊕ B1, the link key is derivable. +inline constexpr std::array CONTROLLER_KEY_B1 = { + 0x5c, 0xf6, 0xee, 0x79, 0x2c, 0xdf, 0x05, 0xe1, 0xba, 0x2b, 0x63, 0x25, 0xc4, 0x1a, 0x5f, 0x10}; + +/// Golden test vector (host-verified) for the pairing crypto self-test. +namespace golden { +inline constexpr std::array A1 = {0x35, 0x03, 0xe9, 0x29, 0x82, 0x87, 0x71, 0x24, + 0xbe, 0xa8, 0x0c, 0x66, 0x46, 0x15, 0x83, 0x4b}; +inline constexpr std::array A2 = {0x6f, 0xc6, 0xdf, 0x8a, 0xd8, 0xfe, 0xdf, 0x15, + 0xbb, 0x8c, 0x15, 0xe9, 0x1f, 0x32, 0x05, 0x44}; +inline constexpr std::array LTK = {0x69, 0xf5, 0x07, 0x50, 0xae, 0x58, 0x74, 0xc5, + 0x04, 0x83, 0x6f, 0x43, 0x82, 0x0f, 0xdc, 0x5b}; +inline constexpr std::array B2 = {0x13, 0x4c, 0x97, 0xf5, 0x11, 0xb9, 0xb6, 0xdd, + 0x4d, 0x86, 0xfd, 0x40, 0xf5, 0x36, 0xe9, 0xed}; +} // namespace golden + +} // namespace espp::switch2 diff --git a/components/switch2_pro/include/switch2_pro_report.hpp b/components/switch2_pro/include/switch2_pro_report.hpp new file mode 100644 index 0000000000..09e7f4ebeb --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_report.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include + +#include "switch2_pro_protocol.hpp" + +/// @file switch2_pro_report.hpp +/// @brief Nintendo Switch 2 Pro Controller input report (report id 0x09). + +namespace espp::switch2 { + +/// 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. +/// +/// Button bits (matching the reverse-engineered layout): +/// byte0: 0x80 RStick 0x40 Plus 0x20 ZR 0x10 R 0x08 X 0x04 Y 0x02 A 0x01 B +/// byte1: 0x80 LStick 0x40 Minus 0x20 ZL 0x10 L 0x08 Up 0x04 Left 0x02 Right 0x01 Down +/// byte2: 0x10 C 0x08 GL 0x04 GR 0x02 Capture 0x01 Home +class Pro2InputReport { +public: + static constexpr uint8_t REPORT_ID = 0x09; + static constexpr size_t SIZE = 63; + static constexpr uint16_t STICK_CENTER = 0x800; ///< 12-bit midpoint (2048) + static constexpr uint16_t STICK_MAX = 0xfff; + + Pro2InputReport() { reset(); } + + void reset() { + data_.fill(0); + set_left_stick(0.f, 0.f); + set_right_stick(0.f, 0.f); + } + + void increment_counter() { data_[0]++; } + + /// battery_level: 0..9; charging/external-power flags in the same byte. + void set_power(uint8_t battery_level, bool charging, bool external_power) { + data_[1] = static_cast(((battery_level & 0x0f) << 2) | (charging ? 0x02 : 0x00) | + (external_power ? 0x01 : 0x00)); + } + + // Face / shoulder / system buttons. + void set_a(bool v) { set_bit(2, 0x02, v); } + void set_b(bool v) { set_bit(2, 0x01, v); } + void set_x(bool v) { set_bit(2, 0x08, v); } + void set_y(bool v) { set_bit(2, 0x04, v); } + void set_r(bool v) { set_bit(2, 0x10, v); } + void set_zr(bool v) { set_bit(2, 0x20, v); } + void set_plus(bool v) { set_bit(2, 0x40, v); } + void set_rstick(bool v) { set_bit(2, 0x80, v); } + void set_down(bool v) { set_bit(3, 0x01, v); } + void set_right(bool v) { set_bit(3, 0x02, v); } + void set_left(bool v) { set_bit(3, 0x04, v); } + void set_up(bool v) { set_bit(3, 0x08, v); } + void set_l(bool v) { set_bit(3, 0x10, v); } + void set_zl(bool v) { set_bit(3, 0x20, v); } + void set_minus(bool v) { set_bit(3, 0x40, v); } + void set_lstick(bool v) { set_bit(3, 0x80, v); } + void set_home(bool v) { set_bit(4, 0x01, v); } + void set_capture(bool v) { set_bit(4, 0x02, v); } + void set_gr(bool v) { set_bit(4, 0x04, v); } ///< right grip button + void set_gl(bool v) { set_bit(4, 0x08, v); } ///< left grip button + void set_c(bool v) { set_bit(4, 0x10, v); } ///< Switch 2 "C" (chat) button + + /// Left/right stick, each axis in [-1, 1]. + void set_left_stick(float x, float y) { pack_stick(5, x, y); } + void set_right_stick(float x, float y) { pack_stick(8, x, y); } + + const std::array &data() const { return data_; } + +private: + static uint16_t axis_to_u12(float v) { + if (v < -1.f) + v = -1.f; + if (v > 1.f) + v = 1.f; + return static_cast((v * 0.5f + 0.5f) * STICK_MAX); + } + void pack_stick(size_t offset, float x, float y) { + const uint16_t xv = axis_to_u12(x); + const uint16_t yv = axis_to_u12(y); + data_[offset] = static_cast(xv & 0xff); + data_[offset + 1] = static_cast(((yv & 0x0f) << 4) | ((xv >> 8) & 0x0f)); + data_[offset + 2] = static_cast((yv >> 4) & 0xff); + } + void set_bit(size_t byte, uint8_t mask, bool v) { + if (v) + data_[byte] |= mask; + else + data_[byte] &= ~mask; + } + + std::array data_{}; +}; + +} // namespace espp::switch2 diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp new file mode 100644 index 0000000000..e0b46cfa93 --- /dev/null +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -0,0 +1,193 @@ +#include "switch2_pro.hpp" + +namespace espp { + +using namespace switch2; + +/// Forwards NimBLE characteristic writes on the command channels into the owner. +class CommandCallbacks : public NimBLECharacteristicCallbacks { +public: + explicit CommandCallbacks(Switch2Pro *owner) + : owner_(owner) {} + void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo & /*conn*/) override { + auto value = characteristic->getValue(); + owner_->on_command_write(value.data(), value.size()); + } + +private: + Switch2Pro *owner_; +}; + +namespace { +// One shared callbacks instance for the command characteristics. Lives for the +// life of the process (NimBLE keeps a raw pointer to it). +CommandCallbacks *g_command_callbacks = nullptr; +} // namespace + +bool Switch2Pro::init() { + // The pairing crypto is the load-bearing part; verify it against the golden + // vector up front so a broken build fails loudly rather than at the console. + if (PairingCrypto::self_test()) { + logger_.info("pairing crypto self-test passed"); + } else { + logger_.error("pairing crypto self-test FAILED — pairing will be rejected"); + return false; + } + + if (!ble_gatt_server_.init(device_name_)) { + logger_.error("failed to init BLE GATT server"); + return false; + } + configure_security(); + if (!build_gatt()) { + logger_.error("failed to build GATT services"); + return false; + } + ble_gatt_server_.start_services(); + ble_gatt_server_.start(); + start_advertising(/*wake=*/false); + logger_.info("Switch2Pro advertising as '{}'", device_name_); + return true; +} + +void Switch2Pro::configure_security() { + // The console performs its own app-level pairing over the command channel and + // will drop a peer that initiates BLE SMP. We enable bonding + legacy (not LE + // Secure Connections) and never initiate security ourselves. + ble_gatt_server_.set_security(/*bonding=*/true, /*mitm=*/false, /*secure=*/false); + ble_gatt_server_.set_io_capabilities(BLE_HS_IO_NO_INPUT_OUTPUT); +} + +bool Switch2Pro::build_gatt() { + auto *server = ble_gatt_server_.server(); + if (server == nullptr) + return false; + + // Service 1 (purpose not fully understood; created so the handle map matches + // what the console observed from a real controller). + auto *svc1 = server->createService(NimBLEUUID(SERVICE1_UUID)); + svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_281_UUID), NIMBLE_PROPERTY::READ); + svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_282_UUID), NIMBLE_PROPERTY::WRITE); + svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_283_UUID), NIMBLE_PROPERTY::READ); + + // Service 2 — the main HID-like service. + auto *svc2 = server->createService(NimBLEUUID(SERVICE2_UUID)); + common_input_ = svc2->createCharacteristic(NimBLEUUID(COMMON_INPUT_UUID), + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + pro2_input_ = svc2->createCharacteristic(NimBLEUUID(PRO2_INPUT_UUID), + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + svc2->createCharacteristic(NimBLEUUID(VIBRATION_UUID), NIMBLE_PROPERTY::WRITE_NR); + command_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); + vibration_command_ = + svc2->createCharacteristic(NimBLEUUID(VIBRATION_COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); + svc2->createCharacteristic(NimBLEUUID(FIRMWARE_UPDATE_UUID), NIMBLE_PROPERTY::WRITE); + command_response1_ = + svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE1_UUID), NIMBLE_PROPERTY::NOTIFY); + command_response2_ = + svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE2_UUID), NIMBLE_PROPERTY::NOTIFY); + + if (g_command_callbacks == nullptr) + g_command_callbacks = new CommandCallbacks(this); + command_->setCallbacks(g_command_callbacks); + vibration_command_->setCallbacks(g_command_callbacks); + + svc1->start(); + svc2->start(); + return true; +} + +void Switch2Pro::start_advertising(bool wake, const std::array &host_addr) { + auto mfr = MANUFACTURER_DATA_DISCOVERY; + if (wake) { + mfr[MANUFACTURER_WAKE_FLAG_OFFSET] = WAKE_FLAG; + for (size_t i = 0; i < 6; ++i) // host address is byte-reversed on the wire + mfr[MANUFACTURER_HOST_ADDR_OFFSET + i] = host_addr[5 - i]; + } + + BleGattServer::AdvertisedData adv_data; + adv_data.setFlags(BLE_HS_ADV_F_DISC_GEN); + adv_data.setName(device_name_); + adv_data.setManufacturerData(mfr.data(), mfr.size()); + ble_gatt_server_.set_advertisement_data(adv_data); + + BleGattServer::AdvertisingParameters params{}; + params.connectable = true; + ble_gatt_server_.start_advertising(params); +} + +void Switch2Pro::on_command_write(const uint8_t *data, size_t len) { + if (len < COMMAND_HEADER_SIZE) { + logger_.warn("short command write ({} bytes)", len); + return; + } + const auto command = static_cast(data[0]); + const uint8_t subcommand = data[3]; + const uint8_t *payload = data + COMMAND_HEADER_SIZE; + const size_t payload_len = len - COMMAND_HEADER_SIZE; + + switch (command) { + case Command::PAIRING: + handle_pairing(static_cast(subcommand), payload, payload_len); + break; + default: + // Init / flash-read / feature-select / LEDs / firmware-update are staged in + // the next milestone; log so captures against a real console are legible. + logger_.debug("unhandled command 0x{:02x} sub 0x{:02x} ({} payload bytes)", + static_cast(command), subcommand, payload_len); + break; + } +} + +void Switch2Pro::handle_pairing(PairingSub sub, const uint8_t *payload, size_t len) { + switch (sub) { + case PairingSub::EXCHANGE_ADDRESSES: { + // Console sends its host BD_ADDR(es); remember the first (byte-reversed). + if (len >= 6) { + for (size_t i = 0; i < 6; ++i) + host_addr_[i] = payload[5 - i]; + } + logger_.info("pairing: exchange addresses"); + // TODO(milestone-2): reply with our own address in a 0x15/0x01 response. + break; + } + case PairingSub::EXCHANGE_KEYS: { + // Console sends A1; LTK = A1 ⊕ B1. We reply with the fixed B1. + if (len >= 16) { + std::array a1{}; + std::copy(payload, payload + 16, a1.begin()); + ltk_ = PairingCrypto::derive_ltk(a1); + logger_.info("pairing: exchange keys, LTK derived"); + } + // TODO(milestone-2): reply with CONTROLLER_KEY_B1 in a 0x15/0x04 response. + break; + } + case PairingSub::CONFIRM_LTK: { + // Console sends challenge A2; reply B2 = AES-ECB(rev(LTK), rev(A2)). + if (len >= 16) { + std::array a2{}; + std::copy(payload, payload + 16, a2.begin()); + const auto b2 = PairingCrypto::confirm(ltk_, a2); + (void)b2; // TODO(milestone-2): send b2 back in a 0x15/0x02 response. + logger_.info("pairing: confirm challenge"); + } + break; + } + case PairingSub::FINALISE: + paired_ = true; + logger_.info("pairing: finalised — bonded"); + // TODO(milestone-2): persist {host_addr_, ltk_} to NVS for reconnect/wake. + break; + default: + logger_.debug("pairing: unhandled subcommand 0x{:02x}", static_cast(sub)); + break; + } +} + +void Switch2Pro::notify_response(const std::vector &response) { + if (command_response2_ == nullptr) + return; + command_response2_->setValue(response.data(), response.size()); + command_response2_->notify(); +} + +} // namespace espp diff --git a/components/switch2_pro/src/switch2_pro_pairing.cpp b/components/switch2_pro/src/switch2_pro_pairing.cpp new file mode 100644 index 0000000000..287b081bfa --- /dev/null +++ b/components/switch2_pro/src/switch2_pro_pairing.cpp @@ -0,0 +1,52 @@ +#include "switch2_pro_pairing.hpp" + +#include + +namespace espp::switch2 { + +namespace { +std::array reversed(const std::array &in) { + std::array out{}; + for (size_t i = 0; i < 16; ++i) + out[i] = in[15 - i]; + return out; +} +} // namespace + +std::array PairingCrypto::confirm(const std::array <k, + const std::array &a2) { + const auto key = reversed(ltk); + const auto block = reversed(a2); + std::array out{}; + + // AES-128-ECB single-block encrypt via the PSA Crypto API (the supported + // interface in mbedTLS 4.x / IDF 6; the classic mbedtls_aes_* API is private). + psa_crypto_init(); + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); + psa_set_key_type(&attr, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attr, 128); + + psa_key_id_t key_id = 0; + if (psa_import_key(&attr, key.data(), key.size(), &key_id) != PSA_SUCCESS) { + psa_reset_key_attributes(&attr); + return out; // zeros on failure; self_test() will flag it + } + size_t out_len = 0; + psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, block.data(), block.size(), out.data(), + out.size(), &out_len); + psa_destroy_key(key_id); + psa_reset_key_attributes(&attr); + return out; +} + +bool PairingCrypto::self_test() { + const auto ltk = derive_ltk(golden::A1); + if (ltk != golden::LTK) + return false; + const auto b2 = confirm(ltk, golden::A2); + return b2 == golden::B2; +} + +} // namespace espp::switch2 diff --git a/components/switch2_pro/tools/patch_nimble_5ms.py b/components/switch2_pro/tools/patch_nimble_5ms.py new file mode 100644 index 0000000000..0ff679e016 --- /dev/null +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Patch the prebuilt ESP-IDF NimBLE controller library to accept a sub-spec +5 ms BLE connection interval, which the Nintendo Switch 2 console requires of +its controllers. + +The check that rejects intervals below 7.5 ms (6 units of 1.25 ms) is compiled +into `ble_ll_conn.c.o` inside the closed `libble_app.a` shipped with ESP-IDF for +the RISC-V targets. This flips the immediate `-6` to `-4` (5 ms), i.e. + + addi a5, a4, -6 (93 07 a7 ff) -> addi a5, a4, -4 (93 07 c7 ff) + +WARNING: this modifies files inside your global $IDF_PATH install, affecting +every project that uses that IDF. A `.original` backup is written next to the +patched archive; `--restore` puts it back. Only RISC-V targets with the open +NimBLE controller (esp32c6/c61/c2/h2) are supported — S3/C3 use a different +closed controller and are not handled here. + +Approach adapted from the MIT-licensed zhantss/ESP32-BLE5-NSController-Emulator; +the reverse-engineered requirement is documented in ndeadly/switch2_controller_research. +This script ships no Espressif or Nintendo binaries — it only edits the archive +already present in the user's local ESP-IDF. +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile + +OBJECT = "ble_ll_conn.c.o" +OLD = bytes([0x93, 0x07, 0xA7, 0xFF]) # min interval 6 units (7.5 ms) +NEW = bytes([0x93, 0x07, 0xC7, 0xFF]) # min interval 4 units (5 ms) + +# Target -> relative path of libble_app.a under $IDF_PATH. +LIBS = { + "esp32c6": "components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c6/libble_app.a", + "esp32c61": "components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c61/libble_app.a", + "esp32c2": "components/bt/controller/lib_esp32c2/esp32c2-bt-lib/libble_app.a", + "esp32h2": "components/bt/controller/lib_esp32h2/esp32h2-bt-lib/libble_app.a", +} + + +def lib_path(idf_path: str, target: str) -> str: + rel = LIBS.get(target) + if rel is None: + sys.exit(f"unsupported target '{target}'; supported: {', '.join(LIBS)}") + path = os.path.join(idf_path, rel) + if not os.path.isfile(path): + sys.exit(f"library not found: {path}") + return path + + +def read_object(lib: str) -> bytes: + with tempfile.TemporaryDirectory() as tmp: + subprocess.run(["ar", "x", lib, OBJECT], cwd=tmp, check=True) + with open(os.path.join(tmp, OBJECT), "rb") as f: + return f.read() + + +def write_object(lib: str, data: bytes) -> None: + with tempfile.TemporaryDirectory() as tmp: + obj = os.path.join(tmp, OBJECT) + with open(obj, "wb") as f: + f.write(data) + subprocess.run(["ar", "r", lib, obj], cwd=os.path.dirname(obj) or ".", check=True) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--idf-path", default=os.environ.get("IDF_PATH"), help="ESP-IDF root") + ap.add_argument("--target", required=True, help="esp32c6 / esp32c61 / esp32c2 / esp32h2") + ap.add_argument("--verify-only", action="store_true", help="report state, change nothing") + ap.add_argument("--restore", action="store_true", help="restore the .original backup") + args = ap.parse_args() + if not args.idf_path: + sys.exit("set --idf-path or the IDF_PATH environment variable") + + lib = lib_path(args.idf_path, args.target) + backup = lib + ".original" + + if args.restore: + if not os.path.isfile(backup): + sys.exit(f"no backup to restore: {backup}") + shutil.copy2(backup, lib) + print(f"restored {lib} from backup") + return 0 + + data = read_object(lib) + n_old, n_new = data.count(OLD), data.count(NEW) + if args.verify_only: + print(f"{OBJECT}: unpatched-pattern={n_old} patched-pattern={n_new}") + return 0 + if n_new > 0 and n_old == 0: + print("already patched; nothing to do") + return 0 + if n_old == 0: + sys.exit("expected byte pattern not found — IDF version may differ; not patching") + + if not os.path.isfile(backup): + shutil.copy2(lib, backup) + print(f"backed up -> {backup}") + write_object(lib, data.replace(OLD, NEW)) + print(f"patched {n_old} occurrence(s); {lib} now accepts a 5 ms connection interval") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d9df47ba4fc595c9d628e0ea24bc3229c46a864f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 12 Aug 2026 16:48:05 -0500 Subject: [PATCH 02/18] feat(switch2_pro): send pairing responses + init command replies (milestone 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../switch2_pro/include/switch2_pro.hpp | 24 ++- .../switch2_pro/include/switch2_pro_flash.hpp | 46 ++++ .../include/switch2_pro_pairing.hpp | 1 + .../include/switch2_pro_protocol.hpp | 1 + .../include/switch2_pro_report.hpp | 1 + components/switch2_pro/src/switch2_pro.cpp | 200 ++++++++++++++---- 6 files changed, 225 insertions(+), 48 deletions(-) create mode 100644 components/switch2_pro/include/switch2_pro_flash.hpp diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index fdf4797270..f8990144c9 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -64,11 +64,24 @@ class Switch2Pro : public BaseComponent { void start_advertising(bool wake, const std::array &host_addr = {}); // --- command channel --- - /// Handle a write on a command characteristic (0x0014 / 0x0016). Parses the - /// 8-byte header and dispatches; may notify a response on 0x001a / 0x001e. - void on_command_write(const uint8_t *data, size_t len); - void handle_pairing(switch2::PairingSub sub, const uint8_t *payload, size_t len); - void notify_response(const std::vector &response); + /// Handle a write on a command characteristic. `via_vibration_command` is + /// true for writes on 0x0016 (pairing/init) which reply on 0x001e, false for + /// writes on 0x0014 which reply on 0x001a. Parses the 8-byte header and + /// dispatches, notifying a response. + void on_command_write(bool via_vibration_command, const uint8_t *data, size_t len); + void handle_pairing(bool via_vibration_command, uint8_t transport, switch2::PairingSub sub, + const uint8_t *payload, size_t len); + void handle_command(bool via_vibration_command, switch2::Command cmd, uint8_t transport, + uint8_t sub, const uint8_t *payload, size_t len); + + /// Build an 8-byte device->host response header + payload and notify it on + /// the response characteristic matching the request source. + void send_response(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub, + uint8_t byte4, uint8_t byte5, const uint8_t *payload, size_t payload_len); + /// Header-only ACK (byte4=0x00, byte5=0xf8, payload = {0x01,0,0,0}). + void send_ack(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub); + /// Our own BT address (6 bytes) for the exchange-addresses reply. + std::array local_bt_address() const; friend class CommandCallbacks; @@ -87,6 +100,7 @@ class Switch2Pro : public BaseComponent { bool paired_{false}; std::array ltk_{}; ///< derived during key exchange std::array host_addr_{}; ///< console BD_ADDR (from exchange-addresses) + uint8_t feature_mask_{switch2::PRO2_FEATURE_MASK}; switch2::Pro2InputReport input_report_{}; }; diff --git a/components/switch2_pro/include/switch2_pro_flash.hpp b/components/switch2_pro/include/switch2_pro_flash.hpp new file mode 100644 index 0000000000..d2b9985c2d --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_flash.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +/// @file switch2_pro_flash.hpp +/// @brief Simulated controller flash the console reads during init (command +/// 0x02 memory reads): device info and stick calibration. +/// +/// The console reads calibration/device-info blocks from the controller's +/// internal flash during bring-up. We emulate that flash in RAM and answer the +/// reads. The exact factory-calibration contents are controller-specific; the +/// values here are structurally valid placeholders (neutral stick calibration +/// centered at the 12-bit midpoint) sufficient for bring-up. Refine against a +/// real controller capture for pixel-accurate stick calibration. + +namespace espp::switch2 { + +/// Reads `len` bytes from the simulated flash at `addr` into `out`. Unknown +/// regions read back as zero. Returns the number of bytes written (== len). +inline size_t simulated_flash_read(uint32_t addr, size_t len, uint8_t *out) { + std::memset(out, 0, len); + + // Neutral stick calibration: center at 0x800 (12-bit midpoint), symmetric + // +/- range. Packed as the console expects (3 bytes per two 12-bit values). + // NOTE: placeholder — replace with captured factory calibration for exact + // stick behavior on real hardware. + static constexpr std::array kNeutralStickCal = {0x00, 0x08, 0x80, 0x00, 0x08, + 0x80, 0x00, 0x08, 0x80}; + + // Device-info region (~0x13000): serial/colors/etc. Left mostly zero; the + // console tolerates zeros here for bring-up. + switch (addr) { + case 0x0130A8: // primary stick calibration + case 0x0130E8: // secondary stick calibration + std::memcpy(out, kNeutralStickCal.data(), + len < kNeutralStickCal.size() ? len : kNeutralStickCal.size()); + break; + default: + break; + } + return len; +} + +} // namespace espp::switch2 diff --git a/components/switch2_pro/include/switch2_pro_pairing.hpp b/components/switch2_pro/include/switch2_pro_pairing.hpp index 682cd918b1..b033bc3989 100644 --- a/components/switch2_pro/include/switch2_pro_pairing.hpp +++ b/components/switch2_pro/include/switch2_pro_pairing.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "switch2_pro_protocol.hpp" diff --git a/components/switch2_pro/include/switch2_pro_protocol.hpp b/components/switch2_pro/include/switch2_pro_protocol.hpp index b14d73b48a..a67bbffb53 100644 --- a/components/switch2_pro/include/switch2_pro_protocol.hpp +++ b/components/switch2_pro/include/switch2_pro_protocol.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include /// @file switch2_pro_protocol.hpp diff --git a/components/switch2_pro/include/switch2_pro_report.hpp b/components/switch2_pro/include/switch2_pro_report.hpp index 09e7f4ebeb..162028f3fd 100644 --- a/components/switch2_pro/include/switch2_pro_report.hpp +++ b/components/switch2_pro/include/switch2_pro_report.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index e0b46cfa93..df107324d8 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -1,27 +1,36 @@ #include "switch2_pro.hpp" +#include "esp_mac.h" + +#include "switch2_pro_flash.hpp" + namespace espp { using namespace switch2; -/// Forwards NimBLE characteristic writes on the command channels into the owner. +/// Forwards NimBLE characteristic writes on a command channel into the owner, +/// tagged with which channel (0x0014 vs the vibration+command 0x0016) so the +/// response goes out on the matching notify characteristic. class CommandCallbacks : public NimBLECharacteristicCallbacks { public: - explicit CommandCallbacks(Switch2Pro *owner) - : owner_(owner) {} + CommandCallbacks(Switch2Pro *owner, bool via_vibration_command) + : owner_(owner) + , via_vibration_command_(via_vibration_command) {} void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo & /*conn*/) override { auto value = characteristic->getValue(); - owner_->on_command_write(value.data(), value.size()); + owner_->on_command_write(via_vibration_command_, value.data(), value.size()); } private: Switch2Pro *owner_; + bool via_vibration_command_; }; namespace { -// One shared callbacks instance for the command characteristics. Lives for the -// life of the process (NimBLE keeps a raw pointer to it). -CommandCallbacks *g_command_callbacks = nullptr; +// Per-channel callbacks instances. Live for the process (NimBLE keeps raw +// pointers to them). +CommandCallbacks *g_command_cb = nullptr; // 0x0014 -> response 0x001a +CommandCallbacks *g_vibration_command_cb = nullptr; // 0x0016 -> response 0x001e } // namespace bool Switch2Pro::init() { @@ -86,10 +95,12 @@ bool Switch2Pro::build_gatt() { command_response2_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE2_UUID), NIMBLE_PROPERTY::NOTIFY); - if (g_command_callbacks == nullptr) - g_command_callbacks = new CommandCallbacks(this); - command_->setCallbacks(g_command_callbacks); - vibration_command_->setCallbacks(g_command_callbacks); + if (g_command_cb == nullptr) + g_command_cb = new CommandCallbacks(this, /*via_vibration_command=*/false); + if (g_vibration_command_cb == nullptr) + g_vibration_command_cb = new CommandCallbacks(this, /*via_vibration_command=*/true); + command_->setCallbacks(g_command_cb); + vibration_command_->setCallbacks(g_vibration_command_cb); svc1->start(); svc2->start(); @@ -115,79 +126,182 @@ void Switch2Pro::start_advertising(bool wake, const std::array &host ble_gatt_server_.start_advertising(params); } -void Switch2Pro::on_command_write(const uint8_t *data, size_t len) { +std::array Switch2Pro::local_bt_address() const { + std::array addr{}; + esp_read_mac(addr.data(), ESP_MAC_BT); + return addr; +} + +// --------------------------------------------------------------------------- +// Response framing +// --------------------------------------------------------------------------- + +void Switch2Pro::send_response(bool via_vibration_command, uint8_t cmd, uint8_t transport, + uint8_t sub, uint8_t byte4, uint8_t byte5, const uint8_t *payload, + size_t payload_len) { + auto *response_char = via_vibration_command ? command_response2_ : command_response1_; + if (response_char == nullptr) + return; + std::vector out; + out.reserve(COMMAND_HEADER_SIZE + payload_len); + // Device->host header: [cmd, 0x01, transport, sub, byte4, byte5, 0x00, 0x00]. + out.insert(out.end(), {cmd, DIR_DEVICE_TO_HOST, transport, sub, byte4, byte5, 0x00, 0x00}); + if (payload != nullptr && payload_len > 0) + out.insert(out.end(), payload, payload + payload_len); + response_char->setValue(out.data(), out.size()); + response_char->notify(); +} + +void Switch2Pro::send_ack(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub) { + // Header-only ACK: byte4=0x00, byte5=0xf8, payload {0x01,0,0,0} (matches the + // captured 0x03/0x0d init ACK). + static constexpr std::array kAckPayload = {0x01, 0x00, 0x00, 0x00}; + send_response(via_vibration_command, cmd, transport, sub, 0x00, 0xf8, kAckPayload.data(), + kAckPayload.size()); +} + +// --------------------------------------------------------------------------- +// Command dispatch +// --------------------------------------------------------------------------- + +void Switch2Pro::on_command_write(bool via_vibration_command, const uint8_t *data, size_t len) { if (len < COMMAND_HEADER_SIZE) { logger_.warn("short command write ({} bytes)", len); return; } - const auto command = static_cast(data[0]); - const uint8_t subcommand = data[3]; + const auto cmd = static_cast(data[0]); + const uint8_t transport = data[2]; + const uint8_t sub = data[3]; const uint8_t *payload = data + COMMAND_HEADER_SIZE; const size_t payload_len = len - COMMAND_HEADER_SIZE; - switch (command) { - case Command::PAIRING: - handle_pairing(static_cast(subcommand), payload, payload_len); - break; - default: - // Init / flash-read / feature-select / LEDs / firmware-update are staged in - // the next milestone; log so captures against a real console are legible. - logger_.debug("unhandled command 0x{:02x} sub 0x{:02x} ({} payload bytes)", - static_cast(command), subcommand, payload_len); - break; + if (cmd == Command::PAIRING) { + handle_pairing(via_vibration_command, transport, static_cast(sub), payload, + payload_len); + } else { + handle_command(via_vibration_command, cmd, transport, sub, payload, payload_len); } } -void Switch2Pro::handle_pairing(PairingSub sub, const uint8_t *payload, size_t len) { +void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, PairingSub sub, + const uint8_t *payload, size_t len) { + // Pairing responses use byte4=0x10, byte5=0x78, and a payload that begins + // with a 0x01 status byte (exact framing from ndeadly's captures). switch (sub) { case PairingSub::EXCHANGE_ADDRESSES: { - // Console sends its host BD_ADDR(es); remember the first (byte-reversed). if (len >= 6) { - for (size_t i = 0; i < 6; ++i) + for (size_t i = 0; i < 6; ++i) // console host address, byte-reversed host_addr_[i] = payload[5 - i]; } - logger_.info("pairing: exchange addresses"); - // TODO(milestone-2): reply with our own address in a 0x15/0x01 response. + // Reply: {0x01, 0x04, 0x01} + our BT address. The 0x04/0x01 prefix bytes + // are as observed in captures; address byte order to be confirmed on HW. + const auto addr = local_bt_address(); + std::array reply{0x01, 0x04, 0x01, addr[0], addr[1], + addr[2], addr[3], addr[4], addr[5]}; + send_response(via_vibration_command, 0x15, transport, 0x01, 0x10, 0x78, reply.data(), + reply.size()); + logger_.info("pairing: exchange addresses -> replied with our address"); break; } case PairingSub::EXCHANGE_KEYS: { - // Console sends A1; LTK = A1 ⊕ B1. We reply with the fixed B1. if (len >= 16) { std::array a1{}; std::copy(payload, payload + 16, a1.begin()); ltk_ = PairingCrypto::derive_ltk(a1); - logger_.info("pairing: exchange keys, LTK derived"); } - // TODO(milestone-2): reply with CONTROLLER_KEY_B1 in a 0x15/0x04 response. + // Reply: {0x01} + fixed controller key B1. + std::array reply{0x01}; + std::copy(CONTROLLER_KEY_B1.begin(), CONTROLLER_KEY_B1.end(), reply.begin() + 1); + send_response(via_vibration_command, 0x15, transport, 0x04, 0x10, 0x78, reply.data(), + reply.size()); + logger_.info("pairing: exchange keys -> LTK derived, replied B1"); break; } case PairingSub::CONFIRM_LTK: { - // Console sends challenge A2; reply B2 = AES-ECB(rev(LTK), rev(A2)). + std::array b2{}; if (len >= 16) { std::array a2{}; std::copy(payload, payload + 16, a2.begin()); - const auto b2 = PairingCrypto::confirm(ltk_, a2); - (void)b2; // TODO(milestone-2): send b2 back in a 0x15/0x02 response. - logger_.info("pairing: confirm challenge"); + b2 = PairingCrypto::confirm(ltk_, a2); } + // Reply: {0x01} + B2 = AES-128-ECB(rev(LTK), rev(A2)). + std::array reply{0x01}; + std::copy(b2.begin(), b2.end(), reply.begin() + 1); + send_response(via_vibration_command, 0x15, transport, 0x02, 0x10, 0x78, reply.data(), + reply.size()); + logger_.info("pairing: confirm -> replied B2"); break; } - case PairingSub::FINALISE: + case PairingSub::FINALISE: { + static constexpr std::array reply{0x01}; + send_response(via_vibration_command, 0x15, transport, 0x03, 0x10, 0x78, reply.data(), + reply.size()); paired_ = true; logger_.info("pairing: finalised — bonded"); - // TODO(milestone-2): persist {host_addr_, ltk_} to NVS for reconnect/wake. + // TODO(milestone-4): persist {host_addr_, ltk_} to NVS for reconnect/wake. break; + } default: logger_.debug("pairing: unhandled subcommand 0x{:02x}", static_cast(sub)); break; } } -void Switch2Pro::notify_response(const std::vector &response) { - if (command_response2_ == nullptr) - return; - command_response2_->setValue(response.data(), response.size()); - command_response2_->notify(); +void Switch2Pro::handle_command(bool via_vibration_command, Command cmd, uint8_t transport, + uint8_t sub, const uint8_t *payload, size_t len) { + switch (cmd) { + case Command::FLASH_READ: { + // Request payload: [len, 0x7e, 0x00, 0x00, addr(4 LE)]. Reply echoes + // len+addr then the data from the simulated flash. + if (len < 8) { + send_ack(via_vibration_command, static_cast(cmd), transport, sub); + break; + } + const uint8_t read_len = payload[0]; + const uint32_t addr = + static_cast(payload[4]) | (static_cast(payload[5]) << 8) | + (static_cast(payload[6]) << 16) | (static_cast(payload[7]) << 24); + std::vector reply(8u + read_len, 0); + reply[0] = read_len; + reply[4] = payload[4]; + reply[5] = payload[5]; + reply[6] = payload[6]; + reply[7] = payload[7]; + simulated_flash_read(addr, read_len, reply.data() + 8); + send_response(via_vibration_command, static_cast(cmd), transport, sub, 0x10, 0x78, + reply.data(), reply.size()); + logger_.debug("flash read {} bytes @ 0x{:06x}", read_len, addr); + break; + } + case Command::FEATURE_SELECT: + if (sub == 0x02 && len >= 1) + feature_mask_ = payload[0]; // set feature mask + send_ack(via_vibration_command, static_cast(cmd), transport, sub); + break; + case Command::FIRMWARE_INFO: { + // Captured reply for 0x10/0x01. + static constexpr std::array fw = {0x01, 0x00, 0x0e, 0x01, 0x0c, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0xff}; + send_response(via_vibration_command, static_cast(cmd), transport, sub, 0x10, 0x78, + fw.data(), fw.size()); + break; + } + case Command::FIRMWARE_UPDATE: + // ACK without offering an update, to suppress the console's update prompt. + // TODO(hw): confirm the exact bytes the console needs to skip the prompt. + send_ack(via_vibration_command, static_cast(cmd), transport, sub); + break; + case Command::INIT: + case Command::PLAYER_LEDS: + case Command::VIBRATION: + case Command::BATTERY: + case Command::NFC: + default: + // Acknowledge so the console's init state machine advances. Command-specific + // payloads (battery level, etc.) are refined in later work. + send_ack(via_vibration_command, static_cast(cmd), transport, sub); + break; + } } } // namespace espp From 2f03c1d7801f58b8d34774c30e82f26de72ee0e0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Wed, 12 Aug 2026 22:58:39 -0500 Subject: [PATCH 03/18] feat(switch2_pro): target S3 + trace the handshake for on-hardware pairing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/switch2_pro/example/README.md | 52 +++++++++++++++++++ .../example/main/switch2_pro_example.cpp | 9 +++- .../switch2_pro/example/sdkconfig.defaults | 12 ++++- .../switch2_pro/include/switch2_pro.hpp | 4 ++ components/switch2_pro/src/switch2_pro.cpp | 38 ++++++++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 components/switch2_pro/example/README.md diff --git a/components/switch2_pro/example/README.md b/components/switch2_pro/example/README.md new file mode 100644 index 0000000000..dd72cada39 --- /dev/null +++ b/components/switch2_pro/example/README.md @@ -0,0 +1,52 @@ +# Switch 2 Pro Controller Example + +Brings up the emulated Switch 2 Pro Controller (milestones 1–2: GATT + pairing). +On boot it runs the pairing-crypto self-test, stands up the custom Nintendo GATT +services, and advertises with Nintendo manufacturer data. DEBUG logging traces +every command write and response so you can watch the handshake with a real +console. + +## Build, flash, monitor + +Default target is **esp32s3** (what most people have on hand): + +```bash +idf.py build flash monitor +``` + +For the full path on a RISC-V board (the console's 5 ms interval works there): + +```bash +idf.py set-target esp32c6 +idf.py menuconfig # Switch 2 Pro Controller -> enable the 5 ms NimBLE patch +idf.py build flash monitor +``` + +## Testing pairing with a real Switch 2 + +1. Flash and open the monitor. You should see `pairing crypto self-test passed` + and `advertising as 'Pro Controller'`. +2. On the Switch 2: **System Settings → Controllers → Pair New Controllers** + (or the Change Grip/Order screen — but note that screen is known to be + flaky; prefer the Controllers menu). +3. Watch the log: + - `connected: peer=… interval=…ms supervision=…ms` — **the interval is the + key number.** The Switch 2 drives **5 ms**. If you see ~5 ms here on S3, + great; if the console forces 5 ms and S3 can't hold it, expect a + `disconnected: … reason=…` shortly after (often a supervision timeout). + - `cmd<-0x0016 […]: 15 91 01 04 …` / `rsp->0x001e […]: 15 01 01 04 …` — the + 0x15 pairing exchange (addresses → keys → confirm → finalise). + - `pairing: finalised — bonded` — the handshake completed. + +### What to expect on S3 + +S3 **cannot** accept the console's sub-spec 5 ms connection interval (the +NimBLE 5 ms patch is RISC-V-only; see the component `DESIGN.md`). Depending on +how the console negotiates the interval, S3 may connect and get partway through +pairing before the link drops, or drop soon after connecting. The **logs show +exactly how far it got** — which is the useful data. A clean pass-through to +`pairing: finalised` and a stable connection is expected only on C6/C61 with +the patch enabled. + +If pairing completes but the controller isn't fully usable, that's milestone 3 +(input report streaming), which also needs the 5 ms interval. diff --git a/components/switch2_pro/example/main/switch2_pro_example.cpp b/components/switch2_pro/example/main/switch2_pro_example.cpp index 6a47c65267..6a54f8353f 100644 --- a/components/switch2_pro/example/main/switch2_pro_example.cpp +++ b/components/switch2_pro/example/main/switch2_pro_example.cpp @@ -15,16 +15,21 @@ extern "C" void app_main(void) { // 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. + // + // DEBUG log level traces every command write and response on the serial + // monitor — flip to INFO for quieter output once things work. espp::Switch2Pro controller({ .device_name = "Pro Controller", - .log_level = espp::Logger::Verbosity::INFO, + .log_level = espp::Logger::Verbosity::DEBUG, }); if (!controller.init()) { logger.error("failed to initialize Switch2Pro"); return; } - logger.info("advertising — put the Switch 2 into controller pairing to connect"); + 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"); // Report the current button/stick state once input streaming is enabled // (staged in a follow-up milestone). For now we just build a report to show diff --git a/components/switch2_pro/example/sdkconfig.defaults b/components/switch2_pro/example/sdkconfig.defaults index 79ab0d4e35..b651b62b2d 100644 --- a/components/switch2_pro/example/sdkconfig.defaults +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -1,4 +1,14 @@ -CONFIG_IDF_TARGET="esp32c6" +# Default target is esp32s3 (buildable/flashable today). NOTE: the Switch 2 +# drives a 5 ms connection interval that only RISC-V targets (C6/C61) can accept +# via the opt-in NimBLE patch; on S3 the console may disconnect mid- or +# post-pairing (watch the connect interval + disconnect reason in the log). Use +# `idf.py set-target esp32c6` + CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS=y for the +# full path. See DESIGN.md. +CONFIG_IDF_TARGET="esp32s3" + +# On the ESP32-S3 (native USB), route the console to USB-Serial-JTAG so the +# monitor shows the pairing trace. Harmless on boards with a UART bridge too. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y # Common ESP-related CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index f8990144c9..ffc527554c 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -61,8 +61,12 @@ class Switch2Pro : public BaseComponent { // --- setup --- bool build_gatt(); void configure_security(); + void configure_callbacks(); void start_advertising(bool wake, const std::array &host_addr = {}); + /// Log a byte buffer as hex at debug level (command/response tracing). + void log_hex(const char *prefix, const uint8_t *data, size_t len); + // --- command channel --- /// Handle a write on a command characteristic. `via_vibration_command` is /// true for writes on 0x0016 (pairing/init) which reply on 0x001e, false for diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index df107324d8..f9f23c41f7 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -1,5 +1,8 @@ #include "switch2_pro.hpp" +#include +#include + #include "esp_mac.h" #include "switch2_pro_flash.hpp" @@ -43,6 +46,7 @@ bool Switch2Pro::init() { return false; } + configure_callbacks(); if (!ble_gatt_server_.init(device_name_)) { logger_.error("failed to init BLE GATT server"); return false; @@ -67,6 +71,38 @@ void Switch2Pro::configure_security() { ble_gatt_server_.set_io_capabilities(BLE_HS_IO_NO_INPUT_OUTPUT); } +void Switch2Pro::configure_callbacks() { + BleGattServer::Callbacks callbacks; + callbacks.connect_callback = [this](NimBLEConnInfo &info) { + // The connection interval right after connect is the key diagnostic: the + // Switch 2 drives 5 ms (interval == 4 units). If a controller can't hold + // that, the console typically disconnects with a supervision timeout. + logger_.info("connected: peer={} interval={:.2f}ms supervision={}ms latency={}", + info.getAddress().toString(), info.getConnInterval() * 1.25f, + info.getConnTimeout() * 10, info.getConnLatency()); + }; + callbacks.disconnect_callback = [this](NimBLEConnInfo &info, BleGattServer::DisconnectReason r) { + logger_.warn("disconnected: peer={} reason={} (paired={})", info.getAddress().toString(), r, + paired_); + paired_ = false; + start_advertising(/*wake=*/false); + }; + ble_gatt_server_.set_callbacks(callbacks); +} + +void Switch2Pro::log_hex(const char *prefix, const uint8_t *data, size_t len) { + if (logger_.get_verbosity() > Logger::Verbosity::DEBUG) + return; + std::string hex; + hex.reserve(len * 3); + char tmp[4]; + for (size_t i = 0; i < len; ++i) { + std::snprintf(tmp, sizeof(tmp), "%02x ", data[i]); + hex += tmp; + } + logger_.debug("{} [{}]: {}", prefix, len, hex); +} + bool Switch2Pro::build_gatt() { auto *server = ble_gatt_server_.server(); if (server == nullptr) @@ -148,6 +184,7 @@ void Switch2Pro::send_response(bool via_vibration_command, uint8_t cmd, uint8_t out.insert(out.end(), {cmd, DIR_DEVICE_TO_HOST, transport, sub, byte4, byte5, 0x00, 0x00}); if (payload != nullptr && payload_len > 0) out.insert(out.end(), payload, payload + payload_len); + log_hex(via_vibration_command ? "rsp->0x001e" : "rsp->0x001a", out.data(), out.size()); response_char->setValue(out.data(), out.size()); response_char->notify(); } @@ -165,6 +202,7 @@ void Switch2Pro::send_ack(bool via_vibration_command, uint8_t cmd, uint8_t trans // --------------------------------------------------------------------------- void Switch2Pro::on_command_write(bool via_vibration_command, const uint8_t *data, size_t len) { + log_hex(via_vibration_command ? "cmd<-0x0016" : "cmd<-0x0014", data, len); if (len < COMMAND_HEADER_SIZE) { logger_.warn("short command write ({} bytes)", len); return; From d9c26b6711479189f04da0309f8a8524ad49d238 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 08:24:23 -0500 Subject: [PATCH 04/18] fix(switch2_pro): keep Nintendo manufacturer data in the primary advertisement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/switch2_pro/src/switch2_pro.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index f9f23c41f7..21540c4b96 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -151,15 +151,26 @@ void Switch2Pro::start_advertising(bool wake, const std::array &host mfr[MANUFACTURER_HOST_ADDR_OFFSET + i] = host_addr[5 - i]; } + // The console filters on the Nintendo manufacturer data, so it MUST be in the + // primary advertisement. Flags (3) + manufacturer data (22) = 25 bytes, which + // fits the 31-byte legacy limit; the name goes in the scan response so the + // whole thing doesn't overflow (which would silently drop the manufacturer + // data and make the controller invisible to the console). BleGattServer::AdvertisedData adv_data; - adv_data.setFlags(BLE_HS_ADV_F_DISC_GEN); - adv_data.setName(device_name_); - adv_data.setManufacturerData(mfr.data(), mfr.size()); + adv_data.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP); + if (!adv_data.setManufacturerData(mfr.data(), mfr.size())) + logger_.error("manufacturer data did not fit the advertisement!"); ble_gatt_server_.set_advertisement_data(adv_data); + BleGattServer::AdvertisedData scan_response; + scan_response.setName(device_name_); + ble_gatt_server_.set_scan_response_data(scan_response); + BleGattServer::AdvertisingParameters params{}; params.connectable = true; + params.scan_response = true; ble_gatt_server_.start_advertising(params); + logger_.info("advertising: flags+mfr({} B) in adv, name in scan response", mfr.size()); } std::array Switch2Pro::local_bt_address() const { From 1a181ae9b98d1c0e08975275bab545f50911fee2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 08:31:22 -0500 Subject: [PATCH 05/18] debug(switch2_pro): trace all characteristic reads/writes/subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../switch2_pro/include/switch2_pro.hpp | 2 +- components/switch2_pro/src/switch2_pro.cpp | 81 ++++++++++++------- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index ffc527554c..f5f3a2fdbf 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -87,7 +87,7 @@ class Switch2Pro : public BaseComponent { /// Our own BT address (6 bytes) for the exchange-addresses reply. std::array local_bt_address() const; - friend class CommandCallbacks; + friend class ChannelCallbacks; std::string device_name_; BleGattServer ble_gatt_server_; diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index 21540c4b96..6aee72a977 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -11,31 +11,41 @@ namespace espp { using namespace switch2; -/// Forwards NimBLE characteristic writes on a command channel into the owner, -/// tagged with which channel (0x0014 vs the vibration+command 0x0016) so the -/// response goes out on the matching notify characteristic. -class CommandCallbacks : public NimBLECharacteristicCallbacks { +/// Characteristic callbacks that (a) trace everything the console does — reads, +/// writes, notification subscriptions — for debugging bring-up, and (b) for the +/// two command channels, dispatch writes into the owner. role: 0 = passive +/// (log only), 1 = command channel 0x0014, 2 = vibration+command 0x0016. +class ChannelCallbacks : public NimBLECharacteristicCallbacks { public: - CommandCallbacks(Switch2Pro *owner, bool via_vibration_command) + ChannelCallbacks(Switch2Pro *owner, const char *name, int role) : owner_(owner) - , via_vibration_command_(via_vibration_command) {} + , name_(name) + , role_(role) {} + void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo & /*conn*/) override { auto value = characteristic->getValue(); - owner_->on_command_write(via_vibration_command_, value.data(), value.size()); + owner_->logger_.info("WRITE {} ({} bytes)", name_, value.size()); + owner_->log_hex(name_, value.data(), value.size()); + if (role_ == 1) + owner_->on_command_write(/*via_vibration_command=*/false, value.data(), value.size()); + else if (role_ == 2) + owner_->on_command_write(/*via_vibration_command=*/true, value.data(), value.size()); + } + void onRead(NimBLECharacteristic * /*c*/, NimBLEConnInfo & /*conn*/) override { + owner_->logger_.info("READ {}", name_); + } + void onSubscribe(NimBLECharacteristic * /*c*/, NimBLEConnInfo & /*conn*/, + uint16_t sub_value) override { + owner_->logger_.info("SUBSCRIBE {} value=0x{:04x} ({})", name_, sub_value, + sub_value ? "on" : "off"); } private: Switch2Pro *owner_; - bool via_vibration_command_; + const char *name_; + int role_; }; -namespace { -// Per-channel callbacks instances. Live for the process (NimBLE keeps raw -// pointers to them). -CommandCallbacks *g_command_cb = nullptr; // 0x0014 -> response 0x001a -CommandCallbacks *g_vibration_command_cb = nullptr; // 0x0016 -> response 0x001e -} // namespace - bool Switch2Pro::init() { // The pairing crypto is the load-bearing part; verify it against the golden // vector up front so a broken build fails loudly rather than at the console. @@ -91,8 +101,6 @@ void Switch2Pro::configure_callbacks() { } void Switch2Pro::log_hex(const char *prefix, const uint8_t *data, size_t len) { - if (logger_.get_verbosity() > Logger::Verbosity::DEBUG) - return; std::string hex; hex.reserve(len * 3); char tmp[4]; @@ -100,7 +108,9 @@ void Switch2Pro::log_hex(const char *prefix, const uint8_t *data, size_t len) { std::snprintf(tmp, sizeof(tmp), "%02x ", data[i]); hex += tmp; } - logger_.debug("{} [{}]: {}", prefix, len, hex); + // INFO during bring-up so the raw command/response bytes always show; dial + // back to debug once the protocol is settled. + logger_.info("{} [{}]: {}", prefix, len, hex); } bool Switch2Pro::build_gatt() { @@ -108,35 +118,46 @@ bool Switch2Pro::build_gatt() { if (server == nullptr) return false; + // Attach a tracing callback to every characteristic so bring-up logs show + // exactly what the console does. Roles: 1 = command 0x0014, 2 = vibration+ + // command 0x0016, 0 = passive. + auto attach = [this](NimBLECharacteristic *c, const char *name, int role) { + c->setCallbacks(new ChannelCallbacks(this, name, role)); + }; + // Service 1 (purpose not fully understood; created so the handle map matches // what the console observed from a real controller). auto *svc1 = server->createService(NimBLEUUID(SERVICE1_UUID)); - svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_281_UUID), NIMBLE_PROPERTY::READ); - svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_282_UUID), NIMBLE_PROPERTY::WRITE); - svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_283_UUID), NIMBLE_PROPERTY::READ); + attach(svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_281_UUID), NIMBLE_PROPERTY::READ), + "svc1.281", 0); + attach(svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_282_UUID), NIMBLE_PROPERTY::WRITE), + "svc1.282", 0); + attach(svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_283_UUID), NIMBLE_PROPERTY::READ), + "svc1.283", 0); // Service 2 — the main HID-like service. auto *svc2 = server->createService(NimBLEUUID(SERVICE2_UUID)); common_input_ = svc2->createCharacteristic(NimBLEUUID(COMMON_INPUT_UUID), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + attach(common_input_, "common_input(0x000a)", 0); pro2_input_ = svc2->createCharacteristic(NimBLEUUID(PRO2_INPUT_UUID), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); - svc2->createCharacteristic(NimBLEUUID(VIBRATION_UUID), NIMBLE_PROPERTY::WRITE_NR); + attach(pro2_input_, "pro2_input(0x000e)", 0); + attach(svc2->createCharacteristic(NimBLEUUID(VIBRATION_UUID), NIMBLE_PROPERTY::WRITE_NR), + "vibration(0x0012)", 0); command_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); + attach(command_, "command(0x0014)", 1); vibration_command_ = svc2->createCharacteristic(NimBLEUUID(VIBRATION_COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); - svc2->createCharacteristic(NimBLEUUID(FIRMWARE_UPDATE_UUID), NIMBLE_PROPERTY::WRITE); + attach(vibration_command_, "vib_command(0x0016)", 2); + attach(svc2->createCharacteristic(NimBLEUUID(FIRMWARE_UPDATE_UUID), NIMBLE_PROPERTY::WRITE), + "firmware(0x0018)", 0); command_response1_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE1_UUID), NIMBLE_PROPERTY::NOTIFY); + attach(command_response1_, "resp1(0x001a)", 0); command_response2_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE2_UUID), NIMBLE_PROPERTY::NOTIFY); - - if (g_command_cb == nullptr) - g_command_cb = new CommandCallbacks(this, /*via_vibration_command=*/false); - if (g_vibration_command_cb == nullptr) - g_vibration_command_cb = new CommandCallbacks(this, /*via_vibration_command=*/true); - command_->setCallbacks(g_command_cb); - vibration_command_->setCallbacks(g_vibration_command_cb); + attach(command_response2_, "resp2(0x001e)", 0); svc1->start(); svc2->start(); From 7c42fb8893bd0fa88abb64c0a7a94e66adb3daac Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 08:46:53 -0500 Subject: [PATCH 06/18] debug(switch2_pro): dump GATT handle map + enable NimBLE stack logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../switch2_pro/example/sdkconfig.defaults | 7 ++++++- components/switch2_pro/src/switch2_pro.cpp | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/components/switch2_pro/example/sdkconfig.defaults b/components/switch2_pro/example/sdkconfig.defaults index b651b62b2d..2da092939e 100644 --- a/components/switch2_pro/example/sdkconfig.defaults +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -24,7 +24,12 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_BT_ENABLED=y CONFIG_BT_BLUEDROID_ENABLED=n CONFIG_BT_NIMBLE_ENABLED=y -CONFIG_BT_NIMBLE_LOG_LEVEL_NONE=y +# Bring-up debugging: let the NimBLE host log GAP/ATT activity (MTU exchange, +# service discovery, connection-parameter updates, subscribes) so we can see +# what the console does at the stack level — our characteristic callbacks only +# fire on value reads/writes, not on discovery. Set back to _NONE when settled. +CONFIG_BT_NIMBLE_LOG_LEVEL_INFO=y +CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y CONFIG_BT_NIMBLE_NVS_PERSIST=y CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=100 CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index 6aee72a977..fb09fdf76e 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -161,6 +161,22 @@ bool Switch2Pro::build_gatt() { svc1->start(); svc2->start(); + + // Dump the actual handle map. A real Pro Controller 2 has its custom services + // at low handles with the characteristics at fixed offsets (command 0x0014, + // vib+command 0x0016, responses 0x001a/0x001e, inputs 0x000a/0x000e). If the + // console keys off those handles and ours are shifted (because NimBLE/ + // BleGattServer put GAP/GATT/DeviceInfo/Battery at the low handles first), + // that would explain why it connects but never drives the command channel. + logger_.info("GATT handle map (actual vs real-controller):"); + logger_.info(" svc1 = 0x{:04x} (0x0001)", svc1->getHandle()); + logger_.info(" svc2 = 0x{:04x} (0x0008)", svc2->getHandle()); + logger_.info(" common_input = 0x{:04x} (0x000a)", common_input_->getHandle()); + logger_.info(" pro2_input = 0x{:04x} (0x000e)", pro2_input_->getHandle()); + logger_.info(" command = 0x{:04x} (0x0014)", command_->getHandle()); + logger_.info(" vib_command = 0x{:04x} (0x0016)", vibration_command_->getHandle()); + logger_.info(" resp1 = 0x{:04x} (0x001a)", command_response1_->getHandle()); + logger_.info(" resp2 = 0x{:04x} (0x001e)", command_response2_->getHandle()); return true; } From 5bc83a406de1d04ce306ddddb677500a8b49eae8 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 08:58:06 -0500 Subject: [PATCH 07/18] debug(switch2_pro): fix handle-map timing, NimBLE DEBUG log, auth trace 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 --- .../switch2_pro/include/switch2_pro.hpp | 2 ++ components/switch2_pro/src/switch2_pro.cpp | 31 +++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index f5f3a2fdbf..988e278b2b 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -66,6 +66,8 @@ class Switch2Pro : public BaseComponent { /// Log a byte buffer as hex at debug level (command/response tracing). void log_hex(const char *prefix, const uint8_t *data, size_t len); + /// Log the assigned GATT handles (call after the server has started). + void log_handle_map(); // --- command channel --- /// Handle a write on a command characteristic. `via_vibration_command` is diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index fb09fdf76e..8df7edbb42 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -3,6 +3,7 @@ #include #include +#include "esp_log.h" #include "esp_mac.h" #include "switch2_pro_flash.hpp" @@ -47,6 +48,12 @@ class ChannelCallbacks : public NimBLECharacteristicCallbacks { }; bool Switch2Pro::init() { + // Bring-up debugging: turn the NimBLE host log up to DEBUG for its tags only, + // so we see the console's ATT service discovery / reads / writes without + // flooding every other component. (Compiled in via CONFIG_LOG_MAXIMUM_LEVEL.) + esp_log_level_set("NimBLE", ESP_LOG_DEBUG); + esp_log_level_set("NimBLEGATTS", ESP_LOG_DEBUG); + // The pairing crypto is the load-bearing part; verify it against the golden // vector up front so a broken build fails loudly rather than at the console. if (PairingCrypto::self_test()) { @@ -68,6 +75,7 @@ bool Switch2Pro::init() { } ble_gatt_server_.start_services(); ble_gatt_server_.start(); + log_handle_map(); // after start(), so handles are assigned start_advertising(/*wake=*/false); logger_.info("Switch2Pro advertising as '{}'", device_name_); return true; @@ -97,6 +105,12 @@ void Switch2Pro::configure_callbacks() { paired_ = false; start_advertising(/*wake=*/false); }; + callbacks.authentication_complete_callback = [this](const NimBLEConnInfo &info) { + // If this fires, the console ran BLE SMP (which the research says it should + // NOT do). Encrypted={}, bonded={} tells us what security state it reached. + logger_.info("AUTH complete: encrypted={} bonded={} authenticated={}", info.isEncrypted(), + info.isBonded(), info.isAuthenticated()); + }; ble_gatt_server_.set_callbacks(callbacks); } @@ -161,23 +175,22 @@ bool Switch2Pro::build_gatt() { svc1->start(); svc2->start(); + return true; +} - // Dump the actual handle map. A real Pro Controller 2 has its custom services - // at low handles with the characteristics at fixed offsets (command 0x0014, - // vib+command 0x0016, responses 0x001a/0x001e, inputs 0x000a/0x000e). If the - // console keys off those handles and ours are shifted (because NimBLE/ - // BleGattServer put GAP/GATT/DeviceInfo/Battery at the low handles first), - // that would explain why it connects but never drives the command channel. +void Switch2Pro::log_handle_map() { + // Handles are only assigned once the server has started, so this must run + // after ble_gatt_server_.start(). A real Pro Controller 2 has these + // characteristics at fixed handles (parenthesized); if BleGattServer's + // GAP/GATT/DeviceInfo/Battery services shifted ours off those and the console + // keys off them, that explains connect-but-no-command-channel. logger_.info("GATT handle map (actual vs real-controller):"); - logger_.info(" svc1 = 0x{:04x} (0x0001)", svc1->getHandle()); - logger_.info(" svc2 = 0x{:04x} (0x0008)", svc2->getHandle()); logger_.info(" common_input = 0x{:04x} (0x000a)", common_input_->getHandle()); logger_.info(" pro2_input = 0x{:04x} (0x000e)", pro2_input_->getHandle()); logger_.info(" command = 0x{:04x} (0x0014)", command_->getHandle()); logger_.info(" vib_command = 0x{:04x} (0x0016)", vibration_command_->getHandle()); logger_.info(" resp1 = 0x{:04x} (0x001a)", command_response1_->getHandle()); logger_.info(" resp2 = 0x{:04x} (0x001e)", command_response2_->getHandle()); - return true; } void Switch2Pro::start_advertising(bool wake, const std::array &host_addr) { From 1a0f2eaab863523dec0e9ce1b2ae3587d9778a23 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 09:02:58 -0500 Subject: [PATCH 08/18] debug(switch2_pro): actually set NimBLE log to DEBUG (was INFO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/switch2_pro/example/sdkconfig.defaults | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/components/switch2_pro/example/sdkconfig.defaults b/components/switch2_pro/example/sdkconfig.defaults index 2da092939e..3e304eccad 100644 --- a/components/switch2_pro/example/sdkconfig.defaults +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -27,9 +27,12 @@ CONFIG_BT_NIMBLE_ENABLED=y # Bring-up debugging: let the NimBLE host log GAP/ATT activity (MTU exchange, # service discovery, connection-parameter updates, subscribes) so we can see # what the console does at the stack level — our characteristic callbacks only -# fire on value reads/writes, not on discovery. Set back to _NONE when settled. -CONFIG_BT_NIMBLE_LOG_LEVEL_INFO=y +# fire on value reads/writes, not on discovery. The ATT/GATT-server logs are at +# DEBUG, so NimBLE must be compiled at DEBUG (INFO drops them) and the runtime +# default level raised. Set back to _NONE when settled. +CONFIG_BT_NIMBLE_LOG_LEVEL_DEBUG=y CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y +CONFIG_LOG_DEFAULT_LEVEL_DEBUG=y CONFIG_BT_NIMBLE_NVS_PERSIST=y CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=100 CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 From 451e6eca0dcebdc0ecfb45c5ce95360e3c899cf8 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 09:16:56 -0500 Subject: [PATCH 09/18] fix(switch2_pro): disable BLE bonding + clear stale bonds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/switch2_pro/src/switch2_pro.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index 8df7edbb42..99c465b5ff 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -82,11 +82,17 @@ bool Switch2Pro::init() { } void Switch2Pro::configure_security() { - // The console performs its own app-level pairing over the command channel and - // will drop a peer that initiates BLE SMP. We enable bonding + legacy (not LE - // Secure Connections) and never initiate security ourselves. - ble_gatt_server_.set_security(/*bonding=*/true, /*mitm=*/false, /*secure=*/false); + // The Switch 2 does its own app-level pairing over the command channel (the + // 0x15 exchange), NOT BLE SMP. BLE-level bonding here just creates a bond the + // console then uses for GATT caching, which makes it skip service discovery + // on reconnect and get stuck. So: no bonding, no SMP-initiated security. + ble_gatt_server_.set_security(/*bonding=*/false, /*mitm=*/false, /*secure=*/false); ble_gatt_server_.set_io_capabilities(BLE_HS_IO_NO_INPUT_OUTPUT); + // Clear any bonds left from earlier rounds so the console re-discovers our + // GATT cleanly instead of using a stale cache. + size_t cleared = ble_gatt_server_.unpair_all().size(); + if (cleared) + logger_.info("cleared {} stale BLE bond(s)", cleared); } void Switch2Pro::configure_callbacks() { From 40f7cd83efcad402df1138e04b856bc14313622a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 14 Aug 2026 11:17:23 -0500 Subject: [PATCH 10/18] fix(switch2_pro): patcher uses GNU ar (macOS BSD ar can't read the archive) 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 --- .../switch2_pro/tools/patch_nimble_5ms.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/components/switch2_pro/tools/patch_nimble_5ms.py b/components/switch2_pro/tools/patch_nimble_5ms.py index 0ff679e016..47eb729691 100644 --- a/components/switch2_pro/tools/patch_nimble_5ms.py +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -32,6 +32,18 @@ OLD = bytes([0x93, 0x07, 0xA7, 0xFF]) # min interval 6 units (7.5 ms) NEW = bytes([0x93, 0x07, 0xC7, 0xFF]) # min interval 4 units (5 ms) + +def resolve_ar(explicit: str | None) -> str: + """The controller archives are GNU-format (long-name symbol/string tables). + macOS's BSD `ar` cannot extract them, so prefer the RISC-V toolchain's GNU + `ar` (on PATH after the ESP-IDF export script), then llvm-ar, then `ar`.""" + if explicit: + return explicit + for cand in ("riscv32-esp-elf-ar", "llvm-ar"): + if shutil.which(cand): + return cand + return "ar" + # Target -> relative path of libble_app.a under $IDF_PATH. LIBS = { "esp32c6": "components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c6/libble_app.a", @@ -51,19 +63,19 @@ def lib_path(idf_path: str, target: str) -> str: return path -def read_object(lib: str) -> bytes: +def read_object(ar: str, lib: str) -> bytes: with tempfile.TemporaryDirectory() as tmp: - subprocess.run(["ar", "x", lib, OBJECT], cwd=tmp, check=True) + subprocess.run([ar, "x", lib, OBJECT], cwd=tmp, check=True) with open(os.path.join(tmp, OBJECT), "rb") as f: return f.read() -def write_object(lib: str, data: bytes) -> None: +def write_object(ar: str, lib: str, data: bytes) -> None: with tempfile.TemporaryDirectory() as tmp: obj = os.path.join(tmp, OBJECT) with open(obj, "wb") as f: f.write(data) - subprocess.run(["ar", "r", lib, obj], cwd=os.path.dirname(obj) or ".", check=True) + subprocess.run([ar, "r", lib, obj], cwd=os.path.dirname(obj) or ".", check=True) def main() -> int: @@ -72,10 +84,14 @@ def main() -> int: ap.add_argument("--target", required=True, help="esp32c6 / esp32c61 / esp32c2 / esp32h2") ap.add_argument("--verify-only", action="store_true", help="report state, change nothing") ap.add_argument("--restore", action="store_true", help="restore the .original backup") + ap.add_argument("--ar", default=None, + help="archiver to use (default: riscv32-esp-elf-ar / llvm-ar / ar). " + "macOS BSD ar cannot read these GNU-format archives.") args = ap.parse_args() if not args.idf_path: sys.exit("set --idf-path or the IDF_PATH environment variable") + ar = resolve_ar(args.ar) lib = lib_path(args.idf_path, args.target) backup = lib + ".original" @@ -86,10 +102,10 @@ def main() -> int: print(f"restored {lib} from backup") return 0 - data = read_object(lib) + data = read_object(ar, lib) n_old, n_new = data.count(OLD), data.count(NEW) if args.verify_only: - print(f"{OBJECT}: unpatched-pattern={n_old} patched-pattern={n_new}") + print(f"{OBJECT} (via {ar}): unpatched-pattern={n_old} patched-pattern={n_new}") return 0 if n_new > 0 and n_old == 0: print("already patched; nothing to do") @@ -100,7 +116,7 @@ def main() -> int: if not os.path.isfile(backup): shutil.copy2(lib, backup) print(f"backed up -> {backup}") - write_object(lib, data.replace(OLD, NEW)) + write_object(ar, lib, data.replace(OLD, NEW)) print(f"patched {n_old} occurrence(s); {lib} now accepts a 5 ms connection interval") return 0 From eac8765142aa4b80f51ba2ae9f399c9a0e1dd065 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 10:43:44 -0500 Subject: [PATCH 11/18] chore(esp-nimble-cpp): bump submodule for NimBLEServer::registerServicesFirst 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 --- components/esp-nimble-cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/esp-nimble-cpp b/components/esp-nimble-cpp index 1eddc28515..6a396c7a5d 160000 --- a/components/esp-nimble-cpp +++ b/components/esp-nimble-cpp @@ -1 +1 @@ -Subproject commit 1eddc28515bbb2ef29ccc2494d14584c40b400ad +Subproject commit 6a396c7a5da171452249b149a3f8990790a472d3 From 4c7326be5012ffdb42f0f9fbcfd30e9fdd7f74aa Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 10:44:14 -0500 Subject: [PATCH 12/18] feat(ble_gatt_server): add conn_params_update_callback 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 --- .../include/ble_gatt_server.hpp | 42 +++++++++++++++---- .../include/ble_gatt_server_callbacks.hpp | 1 + .../src/ble_gatt_server_callbacks.cpp | 7 ++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/components/ble_gatt_server/include/ble_gatt_server.hpp b/components/ble_gatt_server/include/ble_gatt_server.hpp index 44462f1423..c7694e2d17 100644 --- a/components/ble_gatt_server/include/ble_gatt_server.hpp +++ b/components/ble_gatt_server/include/ble_gatt_server.hpp @@ -69,6 +69,13 @@ class BleGattServer : public BaseComponent { /// @param conn_info The connection information for the device. typedef std::function authentication_complete_callback_t; + /// @brief Callback for when the connection parameters are updated (fires on + /// completion of any connection-parameter-update procedure — whether + /// peer- or self-initiated, accepted or rejected; read the live + /// parameters from conn_info to see the outcome). + /// @param conn_info The connection information for the device. + typedef std::function conn_params_update_callback_t; + /// @brief Callback to retrieve the passkey for the device. /// @return The passkey for the device. typedef std::function get_passkey_callback_t; @@ -131,6 +138,8 @@ class BleGattServer : public BaseComponent { nullptr; ///< Callback for when a device disconnects from the GATT server. authentication_complete_callback_t authentication_complete_callback = nullptr; ///< Callback for when a device completes authentication. + conn_params_update_callback_t conn_params_update_callback = + nullptr; ///< Callback for when the connection parameters are updated. get_passkey_callback_t get_passkey_callback = nullptr; ///< Callback for getting the passkey. /// @note If not provided, will simply return @@ -259,15 +268,26 @@ class BleGattServer : public BaseComponent { // set the server callbacks server_->setCallbacks(new BleGattServerCallbacks(this)); - // create the device info service - device_info_service_.init(server_); + if (builtin_info_services_) { + // create the device info service + device_info_service_.init(server_); - // create the battery service - battery_service_.init(server_); + // create the battery service + battery_service_.init(server_); + } return true; } + /// Enable or disable the built-in Device Information and Battery services. + /// @param enabled Whether init()/start_services() create and start the + /// built-in Device Information (0x180A) and Battery (0x180F) services. + /// Defaults to true. Set to false BEFORE init() for peripherals that + /// must expose only their own services (e.g. emulating a device whose + /// GATT layout must match a specific attribute table). + /// @note Must be called before init(). + void set_builtin_info_services_enabled(bool enabled) { builtin_info_services_ = enabled; } + /// Deinitialize the GATT server /// This method deletes the server and all associated objects. /// It also invalidates any references/pointers to the server. @@ -283,8 +303,10 @@ class BleGattServer : public BaseComponent { } // deinitialize the services - device_info_service_.deinit(); - battery_service_.deinit(); + if (builtin_info_services_) { + device_info_service_.deinit(); + battery_service_.deinit(); + } // if true, deletes all server/advertising/scan/client objects which // invalidates any references/pointers to them bool clear_all = true; @@ -296,8 +318,10 @@ class BleGattServer : public BaseComponent { /// Start the services /// This method starts the device info and battery services. void start_services() { - device_info_service_.start(); - battery_service_.start(); + if (builtin_info_services_) { + device_info_service_.start(); + battery_service_.start(); + } } /// Start the server @@ -806,6 +830,8 @@ class BleGattServer : public BaseComponent { NimBLEServer *server_{nullptr}; ///< The GATT server. DeviceInfoService device_info_service_; ///< The device info service. BatteryService battery_service_; ///< The battery service. + bool builtin_info_services_{ + true}; ///< Whether to create/start the built-in DIS + battery services. }; } // namespace espp diff --git a/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp b/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp index e2b52016e8..398f6bc911 100644 --- a/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp +++ b/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp @@ -16,6 +16,7 @@ class BleGattServerCallbacks : public NimBLEServerCallbacks { virtual void onConnect(NimBLEServer *server, NimBLEConnInfo &conn_info) override; virtual void onDisconnect(NimBLEServer *server, NimBLEConnInfo &conn_info, int reason) override; virtual void onAuthenticationComplete(NimBLEConnInfo &conn_info) override; + virtual void onConnParamsUpdate(NimBLEConnInfo &conn_info) override; virtual uint32_t onPassKeyDisplay() override; virtual void onConfirmPassKey(NimBLEConnInfo &conn_info, uint32_t pass_key) override; diff --git a/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp b/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp index ff5eed31df..82c27ade8e 100644 --- a/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp +++ b/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp @@ -62,6 +62,13 @@ void BleGattServerCallbacks::onAuthenticationComplete(NimBLEConnInfo &conn_info) } } } +void BleGattServerCallbacks::onConnParamsUpdate(NimBLEConnInfo &conn_info) { + if (server_) { + if (server_->callbacks_.conn_params_update_callback) { + server_->callbacks_.conn_params_update_callback(conn_info); + } + } +} uint32_t BleGattServerCallbacks::onPassKeyDisplay() { if (server_ && server_->callbacks_.get_passkey_callback) { return server_->callbacks_.get_passkey_callback(); From b14ba2f6823304792acb8b463b496a74569cd83b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 10:45:05 -0500 Subject: [PATCH 13/18] feat(switch2_pro): working Switch 2 Pro Controller BLE emulation (C6), docs, CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/build.yml | 4 + components/switch2_pro/CMakeLists.txt | 30 +- components/switch2_pro/DESIGN.md | 73 +- components/switch2_pro/Kconfig | 30 +- components/switch2_pro/README.md | 137 ++- components/switch2_pro/example/README.md | 82 +- .../example/main/switch2_pro_example.cpp | 99 ++- .../switch2_pro/example/sdkconfig.defaults | 76 +- .../example/sdkconfig.defaults.esp32c6 | 14 + .../switch2_pro/include/switch2_pro.hpp | 190 +++- .../switch2_pro/include/switch2_pro_flash.hpp | 86 +- .../include/switch2_pro_motion.hpp | 403 +++++++++ .../include/switch2_pro_protocol.hpp | 64 +- .../include/switch2_pro_report.hpp | 1 + components/switch2_pro/src/switch2_pro.cpp | 828 ++++++++++++++++-- .../switch2_pro/tools/patch_nimble_5ms.py | 264 ++++-- .../switch2_pro/tools/smoke_test_5ms.py | 125 +++ doc/Doxyfile | 4 + doc/en/ble/index.rst | 2 + doc/en/ble/switch2_pro.rst | 62 ++ doc/en/ble/switch2_pro_example.md | 2 + 21 files changed, 2268 insertions(+), 308 deletions(-) create mode 100644 components/switch2_pro/example/sdkconfig.defaults.esp32c6 create mode 100644 components/switch2_pro/include/switch2_pro_motion.hpp create mode 100644 components/switch2_pro/tools/smoke_test_5ms.py create mode 100644 doc/en/ble/switch2_pro.rst create mode 100644 doc/en/ble/switch2_pro_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f39e533dcb..0a3058394c 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -302,6 +302,10 @@ jobs: target: esp32 - path: 'components/sx126x/example' target: esp32s3 + - path: 'components/switch2_pro/example' + target: esp32c6 + - path: 'components/switch2_pro/example' + target: esp32s3 - path: 'components/t-deck/example' target: esp32s3 - path: 'components/t-dongle-s3/example' diff --git a/components/switch2_pro/CMakeLists.txt b/components/switch2_pro/CMakeLists.txt index 26a6c50a27..bd5e7e1522 100644 --- a/components/switch2_pro/CMakeLists.txt +++ b/components/switch2_pro/CMakeLists.txt @@ -1,22 +1,24 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES base_component ble_gatt_server esp-nimble-cpp + REQUIRES base_component ble_gatt_server esp-nimble-cpp timer PRIV_REQUIRES mbedtls) -# Opt-in: patch the prebuilt NimBLE controller library to accept the console's -# sub-spec 5 ms connection interval. Off by default. Only applies to RISC-V -# targets with the open NimBLE controller (C6/C61/C2/H2); mutates the global -# $IDF_PATH install, so it is deliberately explicit and never silent. On S3 the -# equivalent is a Kconfig/Espressif path (see DESIGN.md), not this patch. +# Opt-in: patch the prebuilt BLE controller library to accept the console's +# sub-spec 5 ms connection interval. Off by default. Covers the RISC-V NimBLE +# controller (C6/C61/C2/H2, libble_app.a) and the BTDM/RivieraWaves controller +# (S3/C3, libbtdm_app.a) — the patcher picks the right object + byte pattern per +# target. Mutates the global $IDF_PATH install, so it is deliberately explicit +# and never silent. if(CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS) if(IDF_TARGET STREQUAL "esp32c6" OR IDF_TARGET STREQUAL "esp32c61" - OR IDF_TARGET STREQUAL "esp32c2" OR IDF_TARGET STREQUAL "esp32h2") + OR IDF_TARGET STREQUAL "esp32c2" OR IDF_TARGET STREQUAL "esp32h2" + OR IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3") message(WARNING - "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS is ON: patching libble_app.a " - "in $ENV{IDF_PATH} for a 5 ms connection interval (${IDF_TARGET}). " - "This modifies your global ESP-IDF install; run tools/patch_nimble_5ms.py " - "--restore to undo.") + "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS is ON: patching the prebuilt " + "BLE controller library in $ENV{IDF_PATH} for a 5 ms connection interval " + "(${IDF_TARGET}). This modifies your global ESP-IDF install; run " + "tools/patch_nimble_5ms.py --target ${IDF_TARGET} --restore to undo.") find_package(Python3 COMPONENTS Interpreter REQUIRED) execute_process( COMMAND ${Python3_EXECUTABLE} @@ -24,12 +26,12 @@ if(CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS) --idf-path $ENV{IDF_PATH} --target ${IDF_TARGET} RESULT_VARIABLE _switch2_patch_result) if(NOT _switch2_patch_result EQUAL 0) - message(FATAL_ERROR "[switch2_pro] NimBLE 5 ms patch failed (${_switch2_patch_result})") + message(FATAL_ERROR "[switch2_pro] 5 ms controller patch failed (${_switch2_patch_result})") endif() else() message(WARNING "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS has no effect on ${IDF_TARGET}: " - "the binary patch only applies to RISC-V NimBLE controllers " - "(C6/C61/C2/H2). See DESIGN.md for the S3 path.") + "no known controller patch for this target (supported: C6/C61/C2/H2 NimBLE, " + "S3/C3 BTDM).") endif() endif() diff --git a/components/switch2_pro/DESIGN.md b/components/switch2_pro/DESIGN.md index b8220da361..685513ddbe 100644 --- a/components/switch2_pro/DESIGN.md +++ b/components/switch2_pro/DESIGN.md @@ -42,20 +42,41 @@ init (logged pass/fail) — verifiable on-device with no console. 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. -- **C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): requires a **binary patch - of the prebuilt `$IDF_PATH/.../libble_app.a`** (change the min-interval floor from - 6→4 units). Provided as `tools/patch_nimble_5ms.py` (adapted from zhantss, MIT). -- **S3 / C3**: a different closed controller lib; historically a Kconfig - (`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`, esp-idf#18467). Note: that symbol is - **absent on IDF 6.0.1** — the S3 path needs re-verification on current IDF. +Both chip families keep the 7.5 ms floor as a hard compare inside a closed controller +library, and both are patchable with a single-instruction edit that lowers the floor +to 4 units (5 ms). `tools/patch_nimble_5ms.py` picks the right archive, object and byte +pattern per `--target`; `tools/smoke_test_5ms.py` proves the edit at the disassembly +level with no hardware. + +- **C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): patch + `$IDF_PATH/.../libble_app.a`, object `ble_ll_conn.c.o`. The floor is + `addi a5, a4, -6`; flip the immediate to `-4` (`93 07 a7 ff` → `93 07 c7 ff`). + Adapted from zhantss (MIT). +- **S3 / C3** (BTDM / RivieraWaves controller, `lib_esp32c3_family/*/libbtdm_app.a` + and the `libbtdm_app_flash.a` variant): patch object `llc_con_upd.o`, function + `r_llc_con_upd_param_in_range` — the peripheral-side connection-parameter validator + the console's `LL_CONNECTION_PARAM_REQ` / `LL_CONNECTION_UPDATE_IND` path runs + through (confirmed: its only caller is the RivieraWaves `ip_funcs` jump table; its + siblings are `ll_connection_param_req_handler` / `ll_connection_update_ind_handler`). + The floor is a compare of the requested min-interval against 6: + - **S3** (Xtensa): `bltui a4, 6` → `bltui a4, 4` (`b6 64 01` → `b6 44 01`). + - **C3** (RISC-V): `li a6,5; bgeu a6,a2` → `li a6,3` (`15 48` → `0d 48`). + + Both reverse-engineered here from the same reject-below-6 semantics as the C6 patch; + each is the single unique occurrence in its object (asserted by the patcher). The + latency bound sitting right beside the floor (`499` = `0x1f3`, the BLE max latency) + confirms the surrounding code is the connection-parameter range check. This replaces + the earlier note about `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` / + esp-idf#18467, which does **not** exist on IDF 6.0.1. **Build integration (decision: opt-in, never silent).** The patch mutates the user's -global IDF install and is version-fragile (the RISC-V byte pattern is not guaranteed -across IDF versions). So it is gated behind a component Kconfig option -`SWITCH2_PRO_PATCH_NIMBLE_5MS` (default **n**). When enabled for a RISC-V target, the -component CMake invokes the patcher at configure time (idempotent, with `--verify-only` -first) and prints a loud notice. It is **not required for the GATT + pairing skeleton -milestone** — pairing runs over the command channel independent of the interval. +global IDF install and is version-fragile (the byte pattern is not guaranteed across +IDF versions — the patcher refuses to run if the pattern is missing or non-unique). So +it is gated behind a component Kconfig option `SWITCH2_PRO_PATCH_NIMBLE_5MS` +(default **n**). When enabled for a supported target, the component CMake invokes the +patcher at configure time (idempotent) and prints a loud notice. It is **not required +for the GATT + pairing skeleton milestone** — pairing runs over the command channel +independent of the interval. ## GATT layout (reproduced from captures) @@ -79,16 +100,23 @@ disconnect a peer that initiates SMP. We configure NimBLE not to initiate pairin the LTK from the 0x15 exchange is what encrypts the link. Bond (host addr + LTK) persists in NVS for reconnect + wake. -## Milestones +## Milestones (all implemented; verified end-to-end on ESP32-C6) -1. **GATT + pairing skeleton (this milestone)**: custom GATT tree stands up, - advertises with Nintendo manufacturer data, completes the 0x15 pairing handshake. - Crypto host-verified; console-accepts-pairing is the on-hardware exit test. -2. Command dispatch + init sequence (flash/calibration reads, feature-select, LEDs, - firmware-update-prompt suppression) so the console finishes bring-up. -3. Input report streaming (report 0x09: buttons incl. C/GL/GR, 12-bit sticks, IMU) - at the console's cadence — needs the 5 ms patch for stability. -4. Wake-from-sleep advertisement (bonded reconnect with the 0x81 wake flag). +1. **GATT + pairing skeleton**: custom GATT tree stands up, advertises with + Nintendo manufacturer data, completes the 0x15 pairing handshake (crypto + known-answer verified) and the console accepts pairing. +2. **Command dispatch + init sequence** (flash/calibration reads, feature-select, + LEDs, firmware-update-prompt suppression) so the console finishes bring-up. +3. **Input report streaming** (report 0x09: buttons incl. C/GL/GR, 12-bit sticks, + IMU block) streamed continuously at the console's 15 ms / ~62 Hz cadence with + real backpressure. Runs on the spec-legal 15 ms interval — no patch needed. +4. **Reconnect + wake-from-sleep** (bonded reconnect with the 0x81 wake flag). + The console connects a bonded controller at 5 ms, so these need the opt-in + `SWITCH2_PRO_PATCH_NIMBLE_5MS` controller patch. + +On the ESP32-S3 the BTDM controller does not yet sustain the encrypted input +stream (see the README "Known issues"); C6-class chips (open NimBLE controller) +are the supported target. ## Component layout @@ -98,6 +126,7 @@ persists in NVS for reconnect + wake. include/switch2_pro_report.hpp Pro Controller 2 input report (0x09) packed struct src/switch2_pro.cpp GATT setup, advertising, GAP, command dispatch src/switch2_pro_pairing.cpp pairing crypto (mbedTLS) + state machine + self-test - tools/patch_nimble_5ms.py opt-in 5 ms connection-interval patcher (RISC-V) + tools/patch_nimble_5ms.py opt-in 5 ms interval patcher (C6/C61/C2/H2 NimBLE + S3/C3 BTDM) + tools/smoke_test_5ms.py hardware-free verifier (disassembles the controller floor) Kconfig SWITCH2_PRO_PATCH_NIMBLE_5MS opt-in example/ C6-primary, S3-buildable diff --git a/components/switch2_pro/Kconfig b/components/switch2_pro/Kconfig index adc0b05472..060b055af6 100644 --- a/components/switch2_pro/Kconfig +++ b/components/switch2_pro/Kconfig @@ -1,22 +1,26 @@ menu "Switch 2 Pro Controller" config SWITCH2_PRO_PATCH_NIMBLE_5MS - bool "Patch NimBLE to allow the console's 5 ms connection interval" + bool "Patch the BLE controller to allow the console's 5 ms connection interval" default n help - The Switch 2 console drives the BLE link at a 5 ms connection - interval, below the 7.5 ms Bluetooth spec minimum. To stream input - reports stably the controller stack must accept it. + On RECONNECT and WAKE-FROM-SLEEP the console connects a recognised + (bonded) controller at a 5 ms connection interval, below the 7.5 ms + Bluetooth spec minimum, chosen in its CONNECT_IND. The controller + stack must accept that sub-spec interval or the connection never + forms. - When enabled on a RISC-V target with the open NimBLE controller - (ESP32-C6/C61/C2/H2), the component build patches the prebuilt - libble_app.a in your global $IDF_PATH install to lower the - minimum-interval floor. THIS MODIFIES YOUR ESP-IDF INSTALLATION. - Undo with tools/patch_nimble_5ms.py --restore. + When enabled, the component build patches the prebuilt closed + controller library in your global $IDF_PATH install to lower the + minimum-interval floor from 6 units (7.5 ms) to 4 (5 ms): + * ESP32-C6/C61/C2/H2 — NimBLE controller (libble_app.a) + * ESP32-S3/C3 — BTDM/RivieraWaves controller (libbtdm_app.a) + THIS MODIFIES YOUR ESP-IDF INSTALLATION. Undo with + tools/patch_nimble_5ms.py --target --restore, and verify with + tools/smoke_test_5ms.py --target . - Has no effect on ESP32-S3/C3 (different closed controller; see - DESIGN.md for the Kconfig/Espressif path). Not required for pairing - or the GATT skeleton — only for stable input streaming. Leave this - OFF unless you understand the consequences. + Required for reconnect and wake-from-sleep. NOT required for fresh + pairing or first-session input streaming (those use the spec-legal + 15 ms interval). Leave OFF unless you understand the consequences. endmenu diff --git a/components/switch2_pro/README.md b/components/switch2_pro/README.md index f4034f517f..186e45592a 100644 --- a/components/switch2_pro/README.md +++ b/components/switch2_pro/README.md @@ -4,12 +4,25 @@ Emulate a **Nintendo Switch 2 Pro Controller over BLE** so a real Switch 2 console accepts it as a native controller — including waking the console from sleep. Built on `espp::BleGattServer` (NimBLE). -> **Status: early — GATT + pairing skeleton.** This milestone stands up the -> custom Nintendo GATT service tree, advertises with Nintendo manufacturer -> data, and implements the reverse-engineered pairing crypto (verified against -> a known-answer vector). The full console init/calibration sequence, input -> report streaming, and console-accept verification on real hardware are -> follow-up milestones (see `DESIGN.md`). +> **Status: fully working on the ESP32-C6 (recommended target).** +> Verified against a real Switch 2 (ESP32-C6-DevKit): the console pairs with and +> accepts the emulator as a Pro Controller (full battery, correct icon), input +> streams **continuously and lag-free** (one report per 15 ms connection +> interval, ~62 Hz — matching a real controller's cadence), buttons and sticks +> register on the console's "Test Input Devices" screen, and **reconnect** +> (including the console reconnecting on its own after a reboot) and +> **wake-from-sleep** both work without re-pairing. What's implemented: +> advertising with Nintendo manufacturer data, the exact GATT handle layout, +> the reverse-engineered pairing crypto (known-answer verified) + LTK injection +> for standard LL encryption, the full console init/command sequence, bond +> persistence (NVS), and continuous input-report streaming with real +> backpressure. +> +> **ESP32-S3: pairs, but input only works in short bursts.** The S3's *closed* +> BTDM BLE controller stops servicing notification tx ~3 s into any sustained +> encrypted stream (rate-independent; host and buffers healthy) and the link +> then supervision-times-out — see "Known issues" below. Use a chip from the +> open-NimBLE-controller family (C6/C61/C2/H2) instead. Unlike the original Switch (Bluetooth Classic HID), the Switch 2 uses a **proprietary BLE GATT interface — not HID-over-GATT — and a custom pairing @@ -23,23 +36,100 @@ espp::Switch2Pro controller({.device_name = "Pro Controller"}); controller.init(); // verifies pairing crypto, builds GATT, advertises ``` -## The 5 ms connection interval +## The 5 ms connection interval (required for reconnect & wake) -The console drives the BLE link at a **5 ms** connection interval, below the -7.5 ms Bluetooth spec minimum. Stable input streaming requires the stack to -accept it: +The console chooses the connection interval **in its `CONNECT_IND`, based on +whether it recognises the controller**: -- **ESP32-C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): enable the - Kconfig option **`SWITCH2_PRO_PATCH_NIMBLE_5MS`** (default off). When on, the - component build runs `tools/patch_nimble_5ms.py`, which **binary-patches the - prebuilt `libble_app.a` in your global `$IDF_PATH` install** to lower the - minimum-interval floor. This modifies your ESP-IDF installation; undo with - `python tools/patch_nimble_5ms.py --target esp32c6 --restore`. -- **ESP32-S3 / C3**: a different closed controller; the equivalent is an - Espressif Kconfig path (esp-idf#18467), not this binary patch. See `DESIGN.md`. +- **Fresh pairing:** 15 ms — spec-legal, works on a stock controller. The + console then services continuous 62 Hz input at 15 ms indefinitely (it never + renegotiates mid-session), which is why pairing and first-session input work + unpatched. +- **Reconnect / wake (bonded controller):** `CONNECT_IND interval=4` — **5 ms + from the very first packet**, below the 7.5 ms Bluetooth spec minimum + (verified in both the reconnect and wake captures). A stock controller + cannot accept that connection, so reconnect/wake silently fail: the console + wakes on our advertisement, attempts the 5 ms connection, and gives up. -The patch is **not** required for pairing or the GATT skeleton — only for -stable input streaming — so it stays off by default. +So the controller patch is **required for reconnect and wake-from-sleep** — but +it is **off by default**, because enabling it mutates the prebuilt controller +library in your global `$IDF_PATH`, which is too invasive to do silently. Fresh +pairing and first-session input work without it; enable it (uncomment +`CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS` in the example's `sdkconfig.defaults`, or +run the tool directly) to get reconnect/wake. The Kconfig option +**`SWITCH2_PRO_PATCH_NIMBLE_5MS`** makes the component build run +`tools/patch_nimble_5ms.py`, which **binary-patches the prebuilt controller +library** to lower the minimum-interval floor from 6 units (7.5 ms) to 4 (5 ms): + +- **ESP32-C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): `libble_app.a`, + `addi a5,a4,-6` → `-4`. +- **ESP32-S3 / C3** (BTDM / RivieraWaves controller): `libbtdm_app.a` (+ the + `_flash` variant), `r_llc_con_upd_param_in_range` — S3 `bltui a4,6` → `bltui + a4,4`, C3 `li a6,5` → `li a6,3`. + +This modifies your ESP-IDF installation; undo with `python +tools/patch_nimble_5ms.py --target --restore`, and check the current state +(no hardware needed) with `python tools/smoke_test_5ms.py --target `, which +disassembles the controller and reports whether 5 ms is accepted or rejected. + +## Stability notes & known issues + +**ESP32-S3: the closed BTDM BLE controller stalls sustained notification tx.** +The one unresolved issue, isolated by elimination on real hardware: ~3 s into +any sustained encrypted notification stream, the S3's controller stops +servicing tx — completions become sporadic (hundreds of ms apart), input dies, +and the link eventually supervision-times-out. Everything host-side is +demonstrably healthy at that moment (mbuf pools near-full, controller ACL +buffers free, host task responsive), and the stall time is **independent of the +send rate** (62 Hz and 31 Hz stall at the same wall-clock, ruling out +per-packet resource exhaustion). The identical firmware on an **ESP32-C6** +(open-source NimBLE controller) streams indefinitely with zero distress — and +reconnect-after-console-reboot, which never worked on the S3, works on the C6. +Conclusion: an S3 BTDM controller bug/incompatibility on this link +configuration (2M PHY + LL encryption + sustained peripheral notifications), +unreachable from the host. On the S3, on-change streaming +(`Config::continuous_streaming = false`) reduces traffic enough to partially +mask it (input works in bursts); the real fix is using a C6-class chip. + +Diagnostics that survive in the driver (useful if this ever regresses): a +per-500 ms stream-health debug log (drain rate, backpressure skips, ENOMEM +count, per-pool free/low-water), a one-shot `TX WEDGE` warning pinpointing the +first ENOMEM's origin (host mbuf vs downstream), and an `@disconnect` summary +tying the disconnect to the tx timeline. + +**Streaming model.** `set_input_report()` only stores the latest state; a +driver-owned task streams it — by default **continuously**, one report per live +connection interval with the byte-0 counter incrementing every report, matching +a real controller (verified in captures: a real device streams 62 Hz at the +15 ms pairing interval). The task applies **real backpressure** by capping +un-drained host mbufs (the `NOTIFY_TX` event fires at host→controller handoff, +*not* over-air completion, so counting those cannot detect a backlog — the mbuf +pool level can). The 40-byte IMU motion block is sent all-zero by default +(accepted by the console); `Config::stream_imu_motion` replays captured frames +instead. + +**Dedicate a core to BLE (dual-core chips).** With everything on core 0 (the +ESP-IDF default), a fast input-notify loop starves the NimBLE host and the link +supervision-times-out within seconds. The example's `sdkconfig.defaults` pins +the BLE controller and host to core 1 on dual-core targets and enlarges the +NimBLE mbuf/ACL pools. Also keep HCI commands (e.g. `ble_gap_read_le_phy`) out +of any per-interval loop — at 62 Hz they flood the HCI path and stall the +host's data tx. + +**What the captures established** (ndeadly's `nrf52840` captures, decrypted, +via `tshark`): a real controller's fresh pairing runs at **15 ms** and its +reconnect **and wake** at **5 ms**, fixed in the `CONNECT_IND` and never +renegotiated mid-session (zero `LL_CONNECTION_UPDATE_IND` / +`LL_CONNECTION_PARAM_REQ` in any capture; the only post-connect LL change is +the PHY switch to 2M). Active-use motion captures run at ~7.5 ms / 133 Hz for +minutes. The console services an emulated controller's *fresh-pair* session at +15 ms / 62 Hz indefinitely, but connects to a *bonded* controller only at 5 ms +— which is why the controller patch is required for reconnect/wake (see "The +5 ms connection interval" above). Advertising variants: the bonded +manufacturer-data payload embeds the console's identity address, with flag +byte `0x00` for passive reconnect presence and `0x81` ("user pressed a button +— connect to me") for wake; an idle console ignores the passive variant, so +user-initiated wake should broadcast `0x81` (see `wake_console()`). ## Attribution @@ -53,6 +143,7 @@ on a published fixed key and is a possession check, not per-device attestation. ## Example -See [example](./example) — builds for ESP32-C6 (primary) and is buildable for -S3. It brings up the controller, runs the pairing-crypto self-test, and -advertises for a console to pair with. +See [example](./example) — default target ESP32-C6 (recommended; also builds +for S3, with the streaming caveat above). It brings up the controller, runs the +pairing-crypto self-test, advertises for a console to pair with, and maps the +BOOT button to A (GPIO0 on Xtensa boards, GPIO9 on the RISC-V devkits). diff --git a/components/switch2_pro/example/README.md b/components/switch2_pro/example/README.md index dd72cada39..9ca73df67e 100644 --- a/components/switch2_pro/example/README.md +++ b/components/switch2_pro/example/README.md @@ -1,52 +1,64 @@ # Switch 2 Pro Controller Example -Brings up the emulated Switch 2 Pro Controller (milestones 1–2: GATT + pairing). -On boot it runs the pairing-crypto self-test, stands up the custom Nintendo GATT -services, and advertises with Nintendo manufacturer data. DEBUG logging traces -every command write and response so you can watch the handshake with a real -console. +Brings up the emulated Switch 2 Pro Controller and connects to a real Nintendo +Switch 2 console. On boot it runs the pairing-crypto self-test, stands up the +custom Nintendo GATT services, and advertises with Nintendo manufacturer data. +Once paired it streams input reports continuously (like a real controller); the +board's BOOT button doubles as the **A** button while connected, and — when +bonded but disconnected — a BOOT press broadcasts the wake advertisement to wake +the console. + +> **Supported target: ESP32-C6** (default). Pairing, input, reconnect, and +> wake-from-sleep all work against a real console. The **ESP32-S3 builds and +> pairs but does not yet stream input reliably** (see the component README's +> "Known issues"). RISC-V siblings (C61/C2/H2) use the same open NimBLE +> controller as the C6. ## Build, flash, monitor -Default target is **esp32s3** (what most people have on hand): +Default target is **esp32c6**: ```bash idf.py build flash monitor ``` -For the full path on a RISC-V board (the console's 5 ms interval works there): +Reconnect and wake-from-sleep require the console's sub-spec 5 ms connection +interval, which needs the opt-in controller patch (off by default because it +modifies your global `$IDF_PATH` install). Fresh pairing and input streaming +work **without** it. To enable it: ```bash -idf.py set-target esp32c6 -idf.py menuconfig # Switch 2 Pro Controller -> enable the 5 ms NimBLE patch +idf.py menuconfig # Component config -> Switch 2 Pro -> enable the 5 ms NimBLE patch +# or: python ../tools/patch_nimble_5ms.py --target esp32c6 (undo with --restore) +python ../tools/smoke_test_5ms.py --target esp32c6 # verify (no hardware needed) idf.py build flash monitor ``` -## Testing pairing with a real Switch 2 +The ESP32-S3 also builds (`idf.py set-target esp32s3`), but see the caveat above. + +## Testing with a real Switch 2 -1. Flash and open the monitor. You should see `pairing crypto self-test passed` - and `advertising as 'Pro Controller'`. -2. On the Switch 2: **System Settings → Controllers → Pair New Controllers** - (or the Change Grip/Order screen — but note that screen is known to be - flaky; prefer the Controllers menu). +1. Flash and open the monitor. You should see `pairing crypto self-test passed`, + the GATT handle map, and `Switch2Pro advertising as 'Pro Controller'`. +2. On the Switch 2: **System Settings → Controllers → Pair New Controllers**. 3. Watch the log: - - `connected: peer=… interval=…ms supervision=…ms` — **the interval is the - key number.** The Switch 2 drives **5 ms**. If you see ~5 ms here on S3, - great; if the console forces 5 ms and S3 can't hold it, expect a - `disconnected: … reason=…` shortly after (often a supervision timeout). - - `cmd<-0x0016 […]: 15 91 01 04 …` / `rsp->0x001e […]: 15 01 01 04 …` — the - 0x15 pairing exchange (addresses → keys → confirm → finalise). - - `pairing: finalised — bonded` — the handshake completed. - -### What to expect on S3 - -S3 **cannot** accept the console's sub-spec 5 ms connection interval (the -NimBLE 5 ms patch is RISC-V-only; see the component `DESIGN.md`). Depending on -how the console negotiates the interval, S3 may connect and get partway through -pairing before the link drops, or drop soon after connecting. The **logs show -exactly how far it got** — which is the useful data. A clean pass-through to -`pairing: finalised` and a stable connection is expected only on C6/C61 with -the patch enabled. - -If pairing completes but the controller isn't fully usable, that's milestone 3 -(input report streaming), which also needs the 5 ms interval. + - `connected: peer=… interval=…ms` — the negotiated connection interval. + - `pairing: finalised — bonded` then `AUTH complete: encrypted=true` — the + handshake and link encryption completed. + - `input-report streaming ENABLED (0x000e)` — the console subscribed; input + now streams. +4. Open **Test Input Devices** (Controllers → *your* controller) and press the + board's **BOOT** button — **A** should register on screen. + +### Reconnect and wake (5 ms patch enabled) + +After the first pairing the bond is saved to NVS. On the next boot the controller +advertises for reconnection. With the console asleep, press **BOOT** while the +log shows `connected=false` to broadcast the wake advertisement — the console +should power on and reconnect without re-pairing. + +## Feeding real input + +`set_input_report()` just stores the latest state; a driver-owned task paces the +BLE notifications. Replace the BOOT-button read in `main` with your real +button/stick source and call `set_input_report()` whenever state changes. diff --git a/components/switch2_pro/example/main/switch2_pro_example.cpp b/components/switch2_pro/example/main/switch2_pro_example.cpp index 6a54f8353f..f7e6d80703 100644 --- a/components/switch2_pro/example/main/switch2_pro_example.cpp +++ b/components/switch2_pro/example/main/switch2_pro_example.cpp @@ -1,26 +1,55 @@ #include #include +#include "driver/gpio.h" +#include "nvs_flash.h" + #include "switch2_pro.hpp" #include "logger.hpp" using namespace std::chrono_literals; +// The BOOT button doubles as the A button for boards without dedicated buttons. +// It reads low when pressed. GPIO0 on Xtensa (S3/S2/classic); GPIO9 on the +// RISC-V chips (C6/C61/C3/C2/H2 devkits). +#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2 || CONFIG_IDF_TARGET_ESP32S3 +static constexpr gpio_num_t kBootButtonGpio = GPIO_NUM_0; +#else +static constexpr gpio_num_t kBootButtonGpio = GPIO_NUM_9; +#endif + extern "C" void app_main(void) { espp::Logger logger({.tag = "switch2_pro example", .level = espp::Logger::Verbosity::INFO}); + // Bond persistence (LTK + console address) is stored in NVS so the controller + // reconnects after a reboot without re-pairing. + esp_err_t nvs_err = nvs_flash_init(); + if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + nvs_flash_init(); + } + //! [switch2_pro 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. // - // DEBUG log level traces every command write and response on the serial - // monitor — flip to INFO for quieter output once things work. + // 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", - .log_level = espp::Logger::Verbosity::DEBUG, + .log_level = espp::Logger::Verbosity::INFO, + .wake_console_on_boot = false, }); if (!controller.init()) { @@ -31,19 +60,65 @@ extern "C" void app_main(void) { "the log for the connect interval, the 0x15 pairing exchange, and " "either 'pairing finalised' or a disconnect reason"); - // Report the current button/stick state once input streaming is enabled - // (staged in a follow-up milestone). For now we just build a report to show - // the API and let advertising/pairing run. + // 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) { - report.reset(); - report.increment_counter(); - report.set_a(true); // hold A as a placeholder - report.set_left_stick(0.f, 0.f); // centered + 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); - logger.info("paired: {}", controller.is_paired()); - std::this_thread::sleep_for(1s); + 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 } //! [switch2_pro example] } diff --git a/components/switch2_pro/example/sdkconfig.defaults b/components/switch2_pro/example/sdkconfig.defaults index 3e304eccad..a55a794833 100644 --- a/components/switch2_pro/example/sdkconfig.defaults +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -1,10 +1,10 @@ -# Default target is esp32s3 (buildable/flashable today). NOTE: the Switch 2 -# drives a 5 ms connection interval that only RISC-V targets (C6/C61) can accept -# via the opt-in NimBLE patch; on S3 the console may disconnect mid- or -# post-pairing (watch the connect interval + disconnect reason in the log). Use -# `idf.py set-target esp32c6` + CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS=y for the -# full path. See DESIGN.md. -CONFIG_IDF_TARGET="esp32s3" +# Default target is esp32c6 — the RECOMMENDED chip (open-source NimBLE BLE +# controller): pairing, reconnect, wake, and sustained lag-free 62 Hz input +# streaming all verified against a real Switch 2. The ESP32-S3 builds and pairs, +# but its closed BTDM BLE controller stops servicing tx ~3 s into any sustained +# encrypted notification stream (host-side unfixable — see README "Known +# issues"), so S3 input only works in short bursts. +CONFIG_IDF_TARGET="esp32c6" # On the ESP32-S3 (native USB), route the console to USB-Serial-JTAG so the # monitor shows the pairing trace. Harmless on boards with a UART bridge too. @@ -13,7 +13,12 @@ CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y # Common ESP-related CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# 1000 Hz tick so the ~15 ms input-stream cadence is representable (at the 100 Hz +# default, sleep_for(15ms) rounds to a 20 ms tick, desyncing from the connection +# interval), and 240 MHz for headroom in the BLE host + input path. CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y # Partition Table @@ -24,24 +29,57 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" CONFIG_BT_ENABLED=y CONFIG_BT_BLUEDROID_ENABLED=n CONFIG_BT_NIMBLE_ENABLED=y -# Bring-up debugging: let the NimBLE host log GAP/ATT activity (MTU exchange, -# service discovery, connection-parameter updates, subscribes) so we can see -# what the console does at the stack level — our characteristic callbacks only -# fire on value reads/writes, not on discovery. The ATT/GATT-server logs are at -# DEBUG, so NimBLE must be compiled at DEBUG (INFO drops them) and the runtime -# default level raised. Set back to _NONE when settled. -CONFIG_BT_NIMBLE_LOG_LEVEL_DEBUG=y -CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y -CONFIG_LOG_DEFAULT_LEVEL_DEBUG=y +# No WiFi is used, so disable BLE/WiFi software coexistence. Coexistence +# arbitration injects tx-scheduling latency on the radio that shows up as +# stalled BLE notifications under sustained streaming (a suspect in the ENOMEM +# tx wedge). Giving BLE the full radio removes that latency source. +CONFIG_ESP_COEX_SW_COEXIST_ENABLE=n +# Pin the BLE controller and NimBLE host to core 1, away from the app's main task +# (core 0). By default all three share core 0, so our ~66 Hz notify loop starves +# the host: it falls behind processing the controller's "number-of-completed- +# packets" HCI events, tx credits are never returned, and after a few seconds +# every notify() returns ENOMEM (rc=6) and the link supervision-times-out. +CONFIG_BT_CTRL_PINNED_TO_CORE_1=y +CONFIG_BT_NIMBLE_PINNED_TO_CORE_1=y +# Logging: keep the NimBLE host quiet (its DEBUG dumps every ACL byte on its own +# line, which is enormous) and let our own Switch2Pro INFO trace carry the +# protocol flow. Raise NimBLE back to _DEBUG only when the raw stack-level view +# is needed. +CONFIG_BT_NIMBLE_LOG_LEVEL_WARNING=y +CONFIG_LOG_DEFAULT_LEVEL_INFO=y CONFIG_BT_NIMBLE_NVS_PERSIST=y CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN=100 CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192 +# A real Pro Controller 2 answers the console's ATT Exchange MTU (512) with 512. +# NimBLE's default preferred MTU is 256; the console appears to stall right after +# the MTU exchange if the controller grants less, so match the real controller. +CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=512 + +# We stream input reports continuously (one per connection interval, ~62 Hz at +# 15 ms) on the 2M PHY the console negotiates. Give the host mbuf pools generous +# headroom over the default 12 MSYS blocks so a transient tx backlog (the driver +# also applies real backpressure, capping un-drained mbufs) never exhausts them. +CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=100 +CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=48 +CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=40 +# S3-only (ignored elsewhere): pre-allocate persistent controller ACL TX buffers +# instead of the default per-TX dynamic allocation. Note this does NOT fix the +# S3 BTDM tx-servicing stall (see README "Known issues") — it only removes one +# allocation failure mode under sustained streaming. +CONFIG_BT_CTRL_BLE_STATIC_ACL_TX_BUF_NB=12 # NOTE: MAX_CCCDS should be 4 * MAX_BONDS CONFIG_BT_NIMBLE_MAX_BONDS=3 CONFIG_BT_NIMBLE_MAX_CCCDS=128 -# The Switch 2 console drives a 5 ms connection interval (below the BLE spec -# minimum). Stable input streaming additionally requires the opt-in NimBLE -# patch — enable it (RISC-V targets only) and understand the caveats: +# OFF by default: enabling it mutates the prebuilt BLE controller lib in your +# global $IDF_PATH at configure time, which is too invasive to do silently in a +# released example. But it is REQUIRED for reconnect and wake-from-sleep: the +# capture shows the console connects to a RECOGNISED (bonded) controller with +# `CONNECT_IND interval=4` (5 ms, below the 7.5 ms spec minimum) from the very +# first packet, so a stock controller cannot complete a reconnect/wake connection. +# Fresh pairing (15 ms) and first-session input streaming work WITHOUT the patch. +# To enable reconnect/wake: uncomment below (or run +# `tools/patch_nimble_5ms.py --target `), then verify with +# `tools/smoke_test_5ms.py --target `; undo with `--restore`. # CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS=y diff --git a/components/switch2_pro/example/sdkconfig.defaults.esp32c6 b/components/switch2_pro/example/sdkconfig.defaults.esp32c6 new file mode 100644 index 0000000000..b6ad185ff5 --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults.esp32c6 @@ -0,0 +1,14 @@ +# ESP32-C6-specific defaults (applied on top of sdkconfig.defaults when the +# target is esp32c6; S3-only options in the base file are ignored here). +# +# The C6 RISC-V GCC 15.2 toolchain (esp-15.2.0_20251204) fails to compile +# picolibc's hal/assert.h (__noreturn=[[noreturn]] -Werror=attributes), so use +# newlib. Xtensa (S3) compiles picolibc fine — this is C6-toolchain-specific. +CONFIG_LIBC_NEWLIB=y + +# The C6 is single-core: the core-pinning options from the base defaults don't +# exist here (silently ignored). The C6's BLE controller is the OPEN-SOURCE +# NimBLE controller (libble_app.a) — the same one the known-working zhantss +# emulator was verified on — patched for 5 ms by the same +# CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS option (different lib/instruction than +# the S3's closed BTDM controller). diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index 988e278b2b..0353bab38f 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -1,15 +1,21 @@ #pragma once #include +#include +#include #include +#include +#include #include #include +#include #include #include "NimBLEDevice.h" #include "ble_gatt_server.hpp" #include "base_component.hpp" +#include "timer.hpp" #include "switch2_pro_pairing.hpp" #include "switch2_pro_protocol.hpp" @@ -39,13 +45,50 @@ class Switch2Pro : public BaseComponent { struct Config { std::string device_name{"Pro Controller"}; ///< BLE advertised name. Logger::Verbosity log_level{Logger::Verbosity::INFO}; + /// 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 wake_console_on_boot{true}; + std::chrono::duration wake_interval{std::chrono::seconds(5)}; + /// 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). 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; prefer feeding real IMU data via the report instead. + bool stream_imu_motion{false}; + /// 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 + /// (open NimBLE controller) at the console's 15 ms / 62 Hz. **Off = + /// on-change:** notify only when the app's button/stick state changes, plus a + /// low-rate keepalive — a reduced-traffic fallback that partially masks the + /// ESP32-S3 BTDM controller's tx-servicing bug (see README "Known issues"). + bool continuous_streaming{true}; + /// Continuous-mode send divisor: send one report every Nth connection interval + /// (1 = every interval / 62 Hz, 2 = every other / 31 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. + uint32_t continuous_stream_divisor{1}; }; explicit Switch2Pro(const Config &config) : BaseComponent("Switch2Pro", config.log_level) , device_name_(config.device_name) + , wake_console_on_boot_(config.wake_console_on_boot) + , wake_interval_(config.wake_interval) + , stream_imu_motion_(config.stream_imu_motion) + , continuous_streaming_(config.continuous_streaming) + , continuous_stream_divisor_( + config.continuous_stream_divisor ? config.continuous_stream_divisor : 1) , ble_gatt_server_({.callbacks = {}, .log_level = Logger::Verbosity::WARN}) {} + /// Stop the input-streaming task on teardown. + ~Switch2Pro(); + /// Initialize NimBLE, build the custom GATT services, configure security so /// the console (not standard SMP) drives pairing, and start advertising. /// @return true on success. @@ -54,15 +97,56 @@ class Switch2Pro : public BaseComponent { /// Whether the pairing handshake has completed with a console. bool is_paired() const { return paired_; } - /// Latest controller state to report once input streaming is enabled. - void set_input_report(const switch2::Pro2InputReport &report) { input_report_ = report; } + /// Whether a console is currently connected (link established; init/input + /// subscription may still be in progress — see is_input_streaming()). + bool is_connected() const { return active_conn_handle_ != 0xffff; } + + /// 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 wake_console(); + + /// Whether the console has subscribed to the input characteristic (0x000e) and + /// we are actively streaming input reports. Goes true near the end of init and + /// false on disconnect; useful for driving post-connect behaviour (e.g. the + /// L+R "select this controller" prompt) from the application. + bool is_input_streaming() const { return input_subscribed_; } + + /// 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. + void set_input_report(const switch2::Pro2InputReport &report) { + std::lock_guard lk(input_mutex_); + input_report_ = report; + } + + /// 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. + enum class AdvMode { Discovery, Reconnect, Wake }; protected: // --- setup --- bool build_gatt(); void configure_security(); void configure_callbacks(); - void start_advertising(bool wake, const std::array &host_addr = {}); + /// `host_addr_le` is the paired console's BD_ADDR in wire (little-endian) order, + /// embedded verbatim for Reconnect/Wake; ignored for Discovery. + void start_advertising(AdvMode mode, const std::array &host_addr_le = {}); + /// Advertise in the mode appropriate to the current state: Wake (with the stored + /// console address) if bonded and wake-on-boot is enabled, else Reconnect if + /// bonded, else Discovery. + void advertise(); + /// Start a periodic timer that re-issues the wake advertisement (via advertise()) + /// every wake_interval_ while disconnected, so a sleeping console keeps getting + /// nudged until it wakes and reconnects. No-op if already running. + void start_wake_timer(); /// Log a byte buffer as hex at debug level (command/response tracing). void log_hex(const char *prefix, const uint8_t *data, size_t len); @@ -86,12 +170,68 @@ class Switch2Pro : public BaseComponent { uint8_t byte4, uint8_t byte5, const uint8_t *payload, size_t payload_len); /// Header-only ACK (byte4=0x00, byte5=0xf8, payload = {0x01,0,0,0}). void send_ack(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub); + + /// Inject the current LTK (ltk_) into NimBLE's security store for `peer` so the + /// controller can satisfy the console's link-layer encryption request (the + /// Switch 2 uses standard LL encryption with the app-derived LTK, not SMP). + void inject_ltk(uint8_t peer_type, const uint8_t *peer_val_le); + /// Inject ltk_ for the currently-connected peer (used right after finalise). + void inject_pairing_ltk(); + /// Persist the bond {console address, LTK} to NVS so it survives reboots and + /// the controller can reconnect/wake without re-pairing. + void save_bond(); + /// Load a persisted bond into bond_peer_* / ltk_. Returns true if one exists. + bool load_bond(); /// Our own BT address (6 bytes) for the exchange-addresses reply. std::array local_bt_address() const; + /// Driver-owned streaming task: while the console is subscribed, notify the + /// input report on 0x000e. In on-change mode (default) it sends only when the + /// app state changes plus a low-rate keepalive; in continuous mode it sends one + /// report every connection interval like a real controller. Started in init(), + /// stopped in the destructor. + void input_stream_loop(); + /// Send one input report now (latest stored state + counter + motion), honoring + /// the in-flight flow-control cap. Returns true iff a notification was actually + /// queued (rc==0); false on a flow-control skip or ENOMEM. Called by input_stream_loop(). + bool send_input_report(); + /// On-change keepalive: send a report at least this often (in connection + /// intervals) even when the app state is unchanged, so the console keeps seeing + /// the controller as active. ~10 intervals ≈ 150 ms at 15 ms. + static constexpr uint32_t kKeepaliveIntervals = 10; + /// Compact one-line dump of every NimBLE mempool's free/total(low-water) — the + /// authoritative "is the tx pool actually draining back?" signal for the wedge. + std::string pool_stats(); + /// Real backpressure: true iff the host msys_1 mbuf pool has fewer than + /// kMaxOutstandingMbufs blocks currently un-drained (outstanding = total-free). + /// This is the TRUE over-air-completion signal — unlike notify_in_flight_, which + /// is decremented at host→controller handoff and so never reflects the backlog. + bool msys1_headroom(); + /// Max input-report mbufs allowed un-drained at once. Healthy streaming holds + /// ~2 outstanding, so this only ever bites during a backlog — capping latency + /// (~Nx interval) and guaranteeing the pool never reaches 0 (the ENOMEM wedge). + static constexpr int kMaxOutstandingMbufs = 8; + /// Read the live connection interval/latency/PHY and log a line whenever any of + /// them changes (diagnostic for the pairing->active LL renegotiation). + void poll_conn_state(); + /// Track CCCD subscribe/unsubscribe so we only stream input when the console + /// has asked for it (updates input_subscribed_ for the 0x000e characteristic). + void on_subscribe(NimBLECharacteristic *characteristic, uint16_t sub_value); + /// 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. + void on_notify_tx(NimBLECharacteristic *characteristic); + friend class ChannelCallbacks; std::string device_name_; + bool wake_console_on_boot_; + std::chrono::duration wake_interval_; + bool stream_imu_motion_; + bool continuous_streaming_; ///< see Config::continuous_streaming (on-change vs per-interval) + uint32_t + continuous_stream_divisor_; ///< see Config::continuous_stream_divisor (rate-halving probe) + std::shared_ptr wake_timer_; ///< re-issues the wake advertisement until connected BleGattServer ble_gatt_server_; // Proprietary GATT characteristics (owned by NimBLE once created). @@ -104,9 +244,49 @@ class Switch2Pro : public BaseComponent { // Pairing state. bool paired_{false}; - std::array ltk_{}; ///< derived during key exchange - std::array host_addr_{}; ///< console BD_ADDR (from exchange-addresses) + bool reconnect_mode_{false}; ///< booted with a stored bond (reconnect, not fresh pair) + bool wake_pending_{false}; ///< wake_console() latched: keep the WAKE adv variant until connected + bool input_subscribed_{false}; ///< console has enabled input-report notifications (0x000e) + uint8_t report_counter_{0}; ///< input-report sequence (byte 0); +1 per delivered report + std::atomic notify_in_flight_{0}; ///< queued-but-not-yet-transmitted input notifications + std::atomic tx_completions_{ + 0}; ///< count of NOTIFY_TX completions (flow-control signal) + uint32_t enomem_count_{0}; ///< diagnostic: notifies deferred because the tx pool was full + uint32_t motion_idx_{0}; ///< index into kMotionSequence for the replayed IMU block + switch2::Pro2InputReport last_streamed_{}; ///< last app-state we notified (on-change dedup) + bool have_streamed_{false}; ///< false until the first report goes out (forces initial send) + uint32_t idle_intervals_{0}; ///< connection intervals since last send (on-change keepalive) + uint32_t interval_tick_{0}; ///< continuous-mode interval counter (for the rate divisor) + // --- tx-wedge diagnostics: localize the ENOMEM stall (our tx drain vs the console) --- + std::atomic last_tx_complete_us_{0}; ///< esp_timer time of the last NOTIFY_TX completion + int64_t stream_start_us_{0}; ///< when the current streaming run began (0 = not started) + int64_t hb_last_us_{0}; ///< last heartbeat timestamp + uint32_t hb_last_completions_{ + 0}; ///< tx_completions_ snapshot at last heartbeat (drain-rate delta) + uint32_t hb_last_enomem_{0}; ///< enomem_count_ snapshot at last heartbeat + uint32_t send_attempts_{0}; ///< send_input_report() calls this streaming run + uint32_t backpressure_skips_{0}; ///< sends deferred because msys_1 had no headroom + bool wedge_reported_{false}; ///< one-shot guard for the wedge-onset log + std::mutex input_mutex_; ///< guards input_report_ (set from app task, read by stream task) + std::thread input_stream_thread_; ///< streams input reports once per connection interval + std::atomic stream_stop_{false}; ///< signals input_stream_thread_ to exit + // Last-observed link state, logged whenever it changes so we can see exactly + // what the console renegotiates at the pairing->active transition. + uint16_t last_itvl_{0}; + uint16_t last_latency_{0xffff}; + uint8_t last_tx_phy_{0}; + uint8_t last_rx_phy_{0}; + uint16_t active_conn_handle_{0xffff}; ///< current connection (BLE_HS_CONN_HANDLE_NONE) + std::array ltk_{}; ///< derived during key exchange (A1 ^ B1) + std::array host_addr_{}; ///< console BD_ADDR (from exchange-addresses) + uint8_t bond_peer_type_{0}; ///< persisted console address type + std::array bond_peer_val_{}; ///< persisted console address (wire/little-endian order) uint8_t feature_mask_{switch2::PRO2_FEATURE_MASK}; + /// Features the console has actually enabled via FEATURE_SELECT (0x0c). The + /// input report must reflect these: rumble (bit 5) sets report byte 0x0B to + /// 0x38, and IMU (bit 2) makes us stream the 40-byte motion block — the + /// console enables both (mask 0x2f) and discards reports that omit them. + uint8_t enabled_features_{0}; switch2::Pro2InputReport input_report_{}; }; diff --git a/components/switch2_pro/include/switch2_pro_flash.hpp b/components/switch2_pro/include/switch2_pro_flash.hpp index d2b9985c2d..4e280357ad 100644 --- a/components/switch2_pro/include/switch2_pro_flash.hpp +++ b/components/switch2_pro/include/switch2_pro_flash.hpp @@ -1,44 +1,74 @@ #pragma once #include +#include #include #include /// @file switch2_pro_flash.hpp /// @brief Simulated controller flash the console reads during init (command -/// 0x02 memory reads): device info and stick calibration. +/// 0x02 memory reads): device info, serial, colors and stick/IMU +/// calibration. /// -/// The console reads calibration/device-info blocks from the controller's -/// internal flash during bring-up. We emulate that flash in RAM and answer the -/// reads. The exact factory-calibration contents are controller-specific; the -/// values here are structurally valid placeholders (neutral stick calibration -/// centered at the 12-bit midpoint) sufficient for bring-up. Refine against a -/// real controller capture for pixel-accurate stick calibration. +/// The console reads several blocks from the controller's internal flash during +/// bring-up and validates them (e.g. the serial and VID/PID at 0x13000) before +/// it will pair. The blocks below are the exact contents captured from a real +/// Pro Controller 2 (ndeadly's btle_procon2_pairing capture). Unmapped regions +/// read back as 0xFF (erased flash), matching the reads that returned all-0xFF. +/// +/// The command 0x02/0x04 response wire format is: [len(4 LE)][addr(4 LE)][data], +/// where `data` is exactly these bytes — there is no separate status byte. namespace espp::switch2 { -/// Reads `len` bytes from the simulated flash at `addr` into `out`. Unknown -/// regions read back as zero. Returns the number of bytes written (== len). -inline size_t simulated_flash_read(uint32_t addr, size_t len, uint8_t *out) { - std::memset(out, 0, len); - - // Neutral stick calibration: center at 0x800 (12-bit midpoint), symmetric - // +/- range. Packed as the console expects (3 bytes per two 12-bit values). - // NOTE: placeholder — replace with captured factory calibration for exact - // stick behavior on real hardware. - static constexpr std::array kNeutralStickCal = {0x00, 0x08, 0x80, 0x00, 0x08, - 0x80, 0x00, 0x08, 0x80}; +// Real captured flash blocks (Pro Controller 2). Address = flash offset. +inline constexpr std::array kFlash_013000 = { + 0x01, 0x00, 0x48, 0x45, 0x4a, 0x37, 0x31, 0x30, 0x30, 0x31, 0x31, 0x32, 0x31, 0x32, 0x34, 0x37, + 0x00, 0x00, 0x7e, 0x05, 0x69, 0x20, 0x01, 0x06, 0x01, 0x23, 0x23, 0x23, 0xa0, 0xa0, 0xa0, 0xe6, + 0xe6, 0xe6, 0x32, 0x32, 0x32, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +inline constexpr std::array kFlash_013040 = { + 0x3b, 0xe0, 0xd3, 0x41, 0xc6, 0x60, 0x6a, 0xbc, 0x4d, 0xd7, 0xa2, 0xbb, 0x71, 0x1e, 0xdd, 0x37}; +inline constexpr std::array kFlash_013080 = { + 0x01, 0xad, 0xd9, 0x9a, 0x55, 0x56, 0x65, 0xa0, 0x00, 0x0a, 0xa0, 0x00, 0x0a, 0xe2, 0x20, 0x0e, + 0xe2, 0x20, 0x0e, 0x9a, 0xad, 0xd9, 0x9a, 0xad, 0xd9, 0x0a, 0xa5, 0x50, 0x0a, 0xa5, 0x50, 0x2f, + 0xf6, 0x62, 0x2f, 0xf6, 0x62, 0x0a, 0xff, 0xff, 0xb3, 0x67, 0x83, 0x2e, 0x66, 0x5e, 0x3a, 0x06, + 0x5f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +inline constexpr std::array kFlash_0130C0 = { + 0x01, 0xad, 0xd9, 0x9a, 0x55, 0x56, 0x65, 0xa0, 0x00, 0x0a, 0xa0, 0x00, 0x0a, 0xe2, 0x20, 0x0e, + 0xe2, 0x20, 0x0e, 0x9a, 0xad, 0xd9, 0x9a, 0xad, 0xd9, 0x0a, 0xa5, 0x50, 0x0a, 0xa5, 0x50, 0x2f, + 0xf6, 0x62, 0x2f, 0xf6, 0x62, 0x0a, 0xff, 0xff, 0x2c, 0x08, 0x84, 0xd1, 0x65, 0x63, 0x2a, 0x26, + 0x62, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; +inline constexpr std::array kFlash_013100 = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xa6, 0xf2, 0x62, 0xbd, 0xa8, 0x00, 0x08, 0x3d, 0x2f, 0xed, 0x20, 0x41}; - // Device-info region (~0x13000): serial/colors/etc. Left mostly zero; the - // console tolerates zeros here for bring-up. - switch (addr) { - case 0x0130A8: // primary stick calibration - case 0x0130E8: // secondary stick calibration - std::memcpy(out, kNeutralStickCal.data(), - len < kNeutralStickCal.size() ? len : kNeutralStickCal.size()); - break; - default: - break; +/// Reads `len` bytes from the simulated flash at `addr` into `out`. Bytes inside +/// a known block return the captured value; everything else returns 0xFF +/// (erased). Returns the number of bytes written (== len). +inline size_t simulated_flash_read(uint32_t addr, size_t len, uint8_t *out) { + struct Block { + uint32_t addr; + const uint8_t *data; + size_t len; + }; + static constexpr Block kBlocks[] = { + {0x013000, kFlash_013000.data(), kFlash_013000.size()}, + {0x013040, kFlash_013040.data(), kFlash_013040.size()}, + {0x013080, kFlash_013080.data(), kFlash_013080.size()}, + {0x0130C0, kFlash_0130C0.data(), kFlash_0130C0.size()}, + {0x013100, kFlash_013100.data(), kFlash_013100.size()}, + }; + for (size_t i = 0; i < len; ++i) { + const uint32_t a = addr + static_cast(i); + uint8_t value = 0xff; // erased flash default + for (const auto &blk : kBlocks) { + if (a >= blk.addr && a < blk.addr + blk.len) { + value = blk.data[a - blk.addr]; + break; + } + } + out[i] = value; } return len; } diff --git a/components/switch2_pro/include/switch2_pro_motion.hpp b/components/switch2_pro/include/switch2_pro_motion.hpp new file mode 100644 index 0000000000..6083fa59c8 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_motion.hpp @@ -0,0 +1,403 @@ +#pragma once + +#include +#include + +/// @file switch2_pro_motion.hpp +/// @brief Real Pro Controller 2 IMU motion sequence (report 0x09 bytes 0x0F..0x36). +/// +/// A contiguous run captured in order from a real controller, so the block's +/// internal (packed, undocumented) per-sample timestamps advance monotonically +/// when replayed in sequence. The console enables IMU during init and every real +/// report carries this 40-byte block; we replay this sequence (looping) so our +/// stream matches the device rather than sending an empty or non-monotonic block. + +namespace espp::switch2 { +inline constexpr std::array, 128> kMotionSequence = {{ + {{0x17, 0x80, 0x01, 0x0f, 0x43, 0xfb, 0xff, 0x7d, 0xff, 0x3f, 0x10, 0x00, 0xe8, 0x00, + 0xe0, 0xf4, 0x20, 0xd0, 0xf9, 0x3f, 0xfb, 0xff, 0x06, 0x40, 0x01, 0xe0, 0xf4, 0x0e, + 0xc8, 0xfc, 0x5f, 0xfd, 0x5f, 0x03, 0x80, 0x01, 0xb8, 0xe9, 0x3b, 0x20}}, + {{0x22, 0xb0, 0x00, 0x0e, 0x23, 0xf0, 0xff, 0x55, 0xfe, 0x3f, 0x3c, 0x00, 0xd8, 0x00, + 0xdc, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x60, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x2f, 0xd0, 0x00, 0x0e, 0xe3, 0xe8, 0xff, 0xa9, 0xfd, 0x3f, 0x53, 0x00, 0xd8, 0x00, + 0xe0, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x80, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x3a, 0xb0, 0x00, 0x0e, 0xa3, 0xe1, 0xff, 0xf1, 0xfc, 0xbf, 0x6a, 0x00, 0xc8, 0x00, + 0xd8, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x04, 0x50, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x47, 0xd0, 0x00, 0x0e, 0x83, 0xda, 0xff, 0x3d, 0xfc, 0x3f, 0x82, 0x00, 0x28, 0x01, + 0xdc, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x04, 0x40, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x52, 0xb0, 0x00, 0x0e, 0x03, 0xd4, 0xff, 0x91, 0xfb, 0x3f, 0x9d, 0x00, 0x38, 0x01, + 0xd4, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x6c, 0xfa, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x04, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x5e, 0xc0, 0x00, 0x0e, 0x03, 0xce, 0xff, 0xe1, 0xfa, 0x3f, 0xb5, 0x00, 0x18, 0x01, + 0xe0, 0xf4, 0x1c, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x04, 0xc0, 0x02, 0xb0, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x6a, 0xc0, 0x00, 0x0e, 0xa3, 0xc6, 0xff, 0x2d, 0xfa, 0xbf, 0xcc, 0x00, 0xc8, 0x00, + 0xf0, 0xf4, 0x1f, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x78, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0x40, 0x03, 0x80, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x76, 0xc0, 0x00, 0x0e, 0xc3, 0xbf, 0xff, 0x7d, 0xf9, 0x3f, 0xe7, 0x00, 0xd8, 0x00, + 0xd8, 0xf4, 0x1f, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x50, 0xd3, 0x73, 0x40, 0x00, 0x02}}, + {{0x81, 0xb0, 0x00, 0x0e, 0x83, 0xb8, 0xff, 0xe5, 0xf8, 0xbf, 0x00, 0x01, 0xe8, 0x00, + 0xdc, 0xf4, 0x1d, 0x10, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x80, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x8e, 0xd0, 0x00, 0x0e, 0x83, 0xb0, 0xff, 0x49, 0xf8, 0x3f, 0x1c, 0x01, 0xf8, 0x00, + 0xd8, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x08, 0x80, 0x03, 0x70, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x99, 0xb0, 0x00, 0x0e, 0x03, 0xa9, 0xff, 0x99, 0xf7, 0xbf, 0x38, 0x01, 0xd8, 0x00, + 0xe0, 0xf4, 0x1f, 0x10, 0xff, 0xf7, 0xff, 0x04, 0xc0, 0x00, 0x70, 0x7a, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x04, 0xc0, 0x02, 0x80, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xa6, 0xd0, 0x00, 0x0e, 0xc3, 0xa1, 0xff, 0xed, 0xf6, 0x3f, 0x52, 0x01, 0x98, 0x00, + 0xe0, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x06, 0x80, 0x03, 0x50, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xb1, 0xb0, 0x00, 0x0e, 0x83, 0x9a, 0xff, 0x25, 0xf6, 0x3f, 0x6b, 0x01, 0xd8, 0x00, + 0xd4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x00, 0x04, 0x20, 0xd3, 0x7b, 0x40, 0x05, 0x02}}, + {{0xbe, 0xd0, 0x00, 0x0e, 0xe3, 0x92, 0xff, 0x61, 0xf5, 0x3f, 0x85, 0x01, 0xd8, 0x00, + 0xcc, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xf3, 0xff, 0x06, 0xc0, 0x03, 0x60, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0xc9, 0xb0, 0x00, 0x0e, 0xc3, 0x8b, 0xff, 0xb9, 0xf4, 0x3f, 0x9f, 0x01, 0x18, 0x01, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x03, 0x00, 0x04, 0xa0, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xc9, 0xb0, 0x00, 0x0e, 0xc3, 0x8b, 0xff, 0xb9, 0xf4, 0x3f, 0x9f, 0x01, 0x18, 0x01, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x03, 0x00, 0x04, 0xa0, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xd6, 0xd0, 0x00, 0x0e, 0x43, 0x85, 0xff, 0x11, 0xf4, 0xbf, 0xb3, 0x01, 0x18, 0x01, + 0xe0, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x08, 0x80, 0x03, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xed, 0x70, 0x01, 0x0f, 0xa3, 0x7b, 0xff, 0x35, 0xf3, 0x3f, 0xd8, 0x01, 0xe8, 0x00, + 0xe8, 0xf4, 0x23, 0x10, 0xf9, 0xbf, 0xfa, 0x7f, 0x07, 0xc0, 0x01, 0xe8, 0xf4, 0x11, + 0x68, 0xfc, 0x7f, 0xfd, 0x1f, 0x03, 0xc0, 0x01, 0xd0, 0xe9, 0x41, 0x20}}, + {{0xf8, 0xb0, 0x00, 0x0e, 0xe3, 0x6f, 0xff, 0x0d, 0xf2, 0xbf, 0x04, 0x02, 0xd8, 0x00, + 0xd8, 0xf4, 0x1d, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x50, 0xd3, 0x7f, 0x40, 0x05, 0x02}}, + {{0x05, 0xd1, 0x00, 0x0e, 0x83, 0x69, 0xff, 0x5d, 0xf1, 0xbf, 0x1c, 0x02, 0xc8, 0x00, + 0xd0, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x02, 0x40, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x10, 0xb1, 0x00, 0x0e, 0x83, 0x61, 0xff, 0xa9, 0xf0, 0x3f, 0x34, 0x02, 0xa8, 0x00, + 0xd4, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x74, + 0xff, 0xeb, 0xff, 0x07, 0x00, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x1d, 0xd1, 0x00, 0x0e, 0x83, 0x5a, 0xff, 0xdd, 0xef, 0x3f, 0x4b, 0x02, 0xc8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x40, 0x04, 0x70, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x28, 0xb1, 0x00, 0x0e, 0xc3, 0x53, 0xff, 0x2d, 0xef, 0xbf, 0x63, 0x02, 0x18, 0x01, + 0xd4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xeb, 0xff, 0x06, 0x00, 0x04, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x35, 0xd1, 0x00, 0x0e, 0xc3, 0x4c, 0xff, 0x69, 0xee, 0x3f, 0x7c, 0x02, 0xe8, 0x00, + 0xe0, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x70, 0xfa, 0x08, 0x84, + 0xff, 0xe7, 0xff, 0x06, 0x40, 0x03, 0x50, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x40, 0xb1, 0x00, 0x0e, 0x63, 0x45, 0xff, 0xb5, 0xed, 0x3f, 0x98, 0x02, 0xe8, 0x00, + 0xd8, 0xf4, 0x24, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x4d, 0xd1, 0x00, 0x0e, 0x43, 0x3e, 0xff, 0x0d, 0xed, 0x3f, 0xb3, 0x02, 0xd8, 0x00, + 0xe0, 0xf4, 0x20, 0x10, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x90, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x58, 0xb1, 0x00, 0x0e, 0x63, 0x37, 0xff, 0x55, 0xec, 0xbf, 0xce, 0x02, 0xf8, 0x00, + 0xe0, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x74, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x04, 0x00, 0x04, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x64, 0xc1, 0x00, 0x0e, 0xc3, 0x30, 0xff, 0xb1, 0xeb, 0xbf, 0xe2, 0x02, 0x18, 0x01, + 0xd8, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x68, 0x7a, 0x08, 0xa4, + 0xff, 0xf3, 0xff, 0x06, 0x40, 0x03, 0x40, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x70, 0xc1, 0x00, 0x0e, 0x63, 0x2a, 0xff, 0x09, 0xeb, 0x3f, 0xfc, 0x02, 0xb8, 0x00, + 0xd0, 0xf4, 0x20, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x30, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x7c, 0xc1, 0x00, 0x0e, 0x63, 0x23, 0xff, 0x51, 0xea, 0x3f, 0x16, 0x03, 0xc8, 0x00, + 0xcc, 0xf4, 0x22, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x64, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0xc0, 0x02, 0x30, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x87, 0xb1, 0x00, 0x0e, 0x63, 0x1c, 0xff, 0xa1, 0xe9, 0x3f, 0x2f, 0x03, 0xd8, 0x00, + 0xd4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x04, 0x40, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x94, 0xd1, 0x00, 0x0e, 0x63, 0x15, 0xff, 0xdd, 0xe8, 0x3f, 0x48, 0x03, 0xe8, 0x00, + 0xdc, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x06, 0xc0, 0x03, 0x50, 0xd3, 0x8f, 0x40, 0x04, 0x02}}, + {{0x9f, 0xb1, 0x00, 0x0e, 0x23, 0x0e, 0xff, 0x45, 0xe8, 0x3f, 0x64, 0x03, 0xf8, 0x00, + 0xd8, 0xf4, 0x23, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x04, 0x80, 0x03, 0x20, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xac, 0xd1, 0x00, 0x0e, 0xe3, 0x06, 0xff, 0x9d, 0xe7, 0x3f, 0x7c, 0x03, 0xd8, 0x00, + 0xd0, 0xf4, 0x22, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0xa4, + 0xff, 0xe7, 0xff, 0x04, 0x40, 0x03, 0x50, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xb7, 0xb1, 0x00, 0x0e, 0x83, 0x00, 0xff, 0xed, 0xe6, 0xbf, 0x95, 0x03, 0xf8, 0x00, + 0xdc, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0xa0, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xc4, 0xd1, 0x00, 0x0e, 0xc3, 0xf9, 0xfe, 0x2d, 0xe6, 0xbf, 0xad, 0x03, 0xc8, 0x00, + 0xe4, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x04, 0xa0, 0x00, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0x40, 0x02, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xcf, 0xb1, 0x00, 0x0e, 0x43, 0xf2, 0xfe, 0x75, 0xe5, 0x3f, 0xca, 0x03, 0xb8, 0x00, + 0xe0, 0xf4, 0x1c, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x70, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xdc, 0xd1, 0x00, 0x0e, 0x63, 0xeb, 0xfe, 0xb5, 0xe4, 0x3f, 0xe2, 0x03, 0xd8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x70, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0xe7, 0xb1, 0x00, 0x0e, 0x83, 0xe4, 0xfe, 0x09, 0xe4, 0x3f, 0xfc, 0x03, 0x18, 0x01, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x04, 0x60, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xf3, 0xc1, 0x00, 0x0e, 0x43, 0xde, 0xfe, 0x6d, 0xe3, 0x3f, 0x14, 0x04, 0xe8, 0x00, + 0xd8, 0xf4, 0x20, 0x10, 0xff, 0xf7, 0xff, 0x04, 0x00, 0x01, 0x68, 0xfa, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xfe, 0xb1, 0x00, 0x0e, 0xa3, 0xd6, 0xfe, 0xc5, 0xe2, 0x3f, 0x31, 0x04, 0xf8, 0x00, + 0xdc, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x08, 0x80, 0x04, 0x60, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x0b, 0xd2, 0x00, 0x0e, 0xe3, 0xcf, 0xfe, 0x09, 0xe2, 0x3f, 0x4e, 0x04, 0xf8, 0x00, + 0xe0, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x80, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x16, 0xb2, 0x00, 0x0e, 0x43, 0xc8, 0xfe, 0x45, 0xe1, 0xbf, 0x69, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x6c, 0x7a, 0x09, 0x84, + 0xff, 0xeb, 0xff, 0x08, 0x80, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x0b, 0x02}}, + {{0x23, 0xd2, 0x00, 0x0e, 0x43, 0xc1, 0xfe, 0x8d, 0xe0, 0xbf, 0x83, 0x04, 0xf8, 0x00, + 0xe4, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xf3, 0xff, 0x08, 0x00, 0x03, 0x80, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x2e, 0xb2, 0x00, 0x0e, 0x43, 0xba, 0xfe, 0xe5, 0xdf, 0xbf, 0xa0, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x1b, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x40, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x3b, 0xd2, 0x00, 0x0e, 0x83, 0xb3, 0xfe, 0x41, 0xdf, 0x3f, 0xb8, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x20, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xe7, 0xff, 0x06, 0x00, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x46, 0xb2, 0x00, 0x0e, 0xa3, 0xac, 0xfe, 0x95, 0xde, 0x3f, 0xd0, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x22, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xe7, 0xff, 0x04, 0x80, 0x03, 0xa0, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0x53, 0xd2, 0x00, 0x0e, 0x03, 0xa6, 0xfe, 0xe5, 0xdd, 0x3f, 0xe7, 0x04, 0xc8, 0x00, + 0xe4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x70, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x40, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x5e, 0xb2, 0x00, 0x0e, 0xa3, 0x9e, 0xfe, 0x2d, 0xdd, 0x3f, 0xff, 0x04, 0xe8, 0x00, + 0xdc, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xf7, 0xff, 0x05, 0x80, 0x03, 0x20, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x6b, 0xd2, 0x00, 0x0e, 0x63, 0x97, 0xfe, 0x8d, 0xdc, 0x3f, 0x18, 0x05, 0xc8, 0x00, + 0xcc, 0xf4, 0x20, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0xc0, 0x02, 0x50, 0xd3, 0x87, 0x40, 0x04, 0x02}}, + {{0x76, 0xb2, 0x00, 0x0e, 0x23, 0x90, 0xfe, 0xed, 0xdb, 0x3f, 0x31, 0x05, 0xa8, 0x00, + 0xd8, 0xf4, 0x23, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0xb4, + 0xff, 0xeb, 0xff, 0x05, 0x00, 0x03, 0x30, 0xd3, 0x83, 0x40, 0x05, 0x02}}, + {{0x82, 0xc2, 0x00, 0x0e, 0x03, 0x8a, 0xfe, 0x3d, 0xdb, 0x3f, 0x48, 0x05, 0xc8, 0x00, + 0xcc, 0xf4, 0x1d, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x05, 0xc0, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x8d, 0xb2, 0x00, 0x0e, 0x83, 0x83, 0xfe, 0x91, 0xda, 0x3f, 0x5f, 0x05, 0xc8, 0x00, + 0xdc, 0xf4, 0x21, 0x50, 0xff, 0xf7, 0xff, 0x02, 0xe0, 0x00, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x9a, 0xd2, 0x00, 0x0e, 0xc3, 0x7c, 0xfe, 0xed, 0xd9, 0xbf, 0x78, 0x05, 0xe8, 0x00, + 0xdc, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0x00, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x04, 0x02}}, + {{0xa5, 0xb2, 0x00, 0x0e, 0x83, 0x76, 0xfe, 0x2d, 0xd9, 0x3f, 0x92, 0x05, 0xb8, 0x00, + 0xdc, 0xf4, 0x1e, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x50, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xb2, 0xd2, 0x00, 0x0e, 0xc3, 0x6f, 0xfe, 0x95, 0xd8, 0xbf, 0xae, 0x05, 0xe8, 0x00, + 0xdc, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0x00, 0x01, 0x70, 0xfa, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xbd, 0xb2, 0x00, 0x0e, 0xe3, 0x68, 0xfe, 0xe5, 0xd7, 0xbf, 0xc8, 0x05, 0x18, 0x01, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x40, 0x03, 0xa0, 0xd3, 0x73, 0x40, 0x00, 0x02}}, + {{0xca, 0xd2, 0x00, 0x0e, 0xc3, 0x61, 0xfe, 0x45, 0xd7, 0xbf, 0xe0, 0x05, 0xb8, 0x00, + 0xe8, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x74, 0xfa, 0x08, 0x94, + 0xff, 0xf3, 0xff, 0x05, 0x40, 0x03, 0x90, 0xd3, 0x87, 0x40, 0x04, 0x02}}, + {{0xd5, 0xb2, 0x00, 0x0e, 0xa3, 0x5a, 0xfe, 0x9d, 0xd6, 0xbf, 0xf9, 0x05, 0xc8, 0x00, + 0xd8, 0xf4, 0x22, 0x90, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x70, 0x7a, 0x09, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x02, 0x60, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xe2, 0xd2, 0x00, 0x0e, 0xa3, 0x54, 0xfe, 0xe5, 0xd5, 0xbf, 0x12, 0x06, 0xb8, 0x00, + 0xd8, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xed, 0xb2, 0x00, 0x0e, 0x43, 0x4c, 0xfe, 0x25, 0xd5, 0x3f, 0x2e, 0x06, 0xd8, 0x00, + 0xd4, 0xf4, 0x20, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x05, 0x00, 0x03, 0x60, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xf9, 0xc2, 0x00, 0x0e, 0xe3, 0x45, 0xfe, 0x7d, 0xd4, 0xbf, 0x44, 0x06, 0xe8, 0x00, + 0xe0, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x00, 0x03, 0x80, 0xd3, 0x8f, 0x40, 0x05, 0x02}}, + {{0x05, 0xc3, 0x00, 0x0e, 0xa3, 0x3e, 0xfe, 0xc5, 0xd3, 0x3f, 0x5e, 0x06, 0xf8, 0x00, + 0xd0, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x80, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x11, 0xc3, 0x00, 0x0e, 0xc3, 0x37, 0xfe, 0x15, 0xd3, 0xbf, 0x77, 0x06, 0xe8, 0x00, + 0xd4, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0x70, 0xd3, 0x8b, 0x40, 0x05, 0x02}}, + {{0x1c, 0xb3, 0x00, 0x0e, 0x83, 0x30, 0xfe, 0x59, 0xd2, 0x3f, 0x8f, 0x06, 0x18, 0x01, + 0xe4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x04, 0x80, 0x03, 0x80, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x29, 0xd3, 0x00, 0x0e, 0xe3, 0x29, 0xfe, 0x95, 0xd1, 0x3f, 0xa5, 0x06, 0xf8, 0x00, + 0xdc, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x04, 0x02}}, + {{0x34, 0xb3, 0x00, 0x0e, 0x83, 0x22, 0xfe, 0xe5, 0xd0, 0x3f, 0xbb, 0x06, 0xe8, 0x00, + 0xd8, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x01, 0x00, 0x01, 0x70, 0xfa, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x04, 0xc0, 0x03, 0x90, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x41, 0xd3, 0x00, 0x0e, 0x03, 0x1b, 0xfe, 0x21, 0xd0, 0xbf, 0xca, 0x06, 0xd8, 0x00, + 0xe4, 0xf4, 0x24, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x40, 0x03, 0x60, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x4c, 0xb3, 0x00, 0x0e, 0xa3, 0x13, 0xfe, 0x65, 0xcf, 0x3f, 0xe1, 0x06, 0xd8, 0x00, + 0xd8, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0x40, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x05, 0x02}}, + {{0x59, 0xd3, 0x00, 0x0e, 0xe3, 0x0c, 0xfe, 0xa9, 0xce, 0xbf, 0x01, 0x07, 0xf8, 0x00, + 0xe0, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x04, 0x00, 0x01, 0x70, 0x7a, 0x07, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x80, 0x03, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x64, 0xb3, 0x00, 0x0e, 0x43, 0x05, 0xfe, 0xf9, 0xcd, 0xbf, 0x1f, 0x07, 0xe8, 0x00, + 0xe4, 0xf4, 0x1d, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x64, 0xb3, 0x00, 0x0e, 0x43, 0x05, 0xfe, 0xf9, 0xcd, 0xbf, 0x1f, 0x07, 0xe8, 0x00, + 0xe4, 0xf4, 0x1d, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x71, 0xd3, 0x00, 0x0e, 0x83, 0xfe, 0xfd, 0x49, 0xcd, 0x3f, 0x36, 0x07, 0xf8, 0x00, + 0xdc, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x80, 0xd3, 0x7f, 0x40, 0x04, 0x02}}, + {{0x88, 0x73, 0x01, 0x0f, 0xa3, 0xf5, 0xfd, 0x5d, 0xcc, 0xbf, 0x59, 0x07, 0xd8, 0x00, + 0xe0, 0xf4, 0x20, 0x10, 0xf9, 0xbf, 0xfa, 0xbf, 0x07, 0x00, 0x02, 0xd8, 0xf4, 0x0f, + 0xa8, 0xfc, 0x7f, 0xfd, 0x5f, 0x03, 0xa0, 0x01, 0xb8, 0xe9, 0x45, 0x20}}, + {{0x93, 0xb3, 0x00, 0x0e, 0x43, 0xea, 0xfd, 0x45, 0xcb, 0xbf, 0x86, 0x07, 0x08, 0x01, + 0xe0, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x07, 0xb4, + 0xff, 0xf3, 0xff, 0x06, 0x00, 0x04, 0xa0, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0xa0, 0xd3, 0x00, 0x0e, 0x23, 0xe3, 0xfd, 0x9d, 0xca, 0xbf, 0x9f, 0x07, 0xe8, 0x00, + 0xe8, 0xf4, 0x21, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x74, 0x7a, 0x08, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x80, 0x03, 0xa0, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xab, 0xb3, 0x00, 0x0e, 0x43, 0xdc, 0xfd, 0x21, 0xca, 0x3f, 0xbb, 0x07, 0xb8, 0x00, + 0xe4, 0xf4, 0x21, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x80, 0x02, 0x90, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xb8, 0xd3, 0x00, 0x0e, 0x23, 0xd4, 0xfd, 0x79, 0xc9, 0xbf, 0xd6, 0x07, 0xd8, 0x00, + 0xdc, 0xf4, 0x23, 0x10, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x68, 0x7a, 0x09, 0x94, + 0xff, 0xef, 0xff, 0x08, 0x00, 0x04, 0x30, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xc3, 0xb3, 0x00, 0x0e, 0xa3, 0xcc, 0xfd, 0xd1, 0xc8, 0x3f, 0xf4, 0x07, 0xf8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x00, 0x04, 0x50, 0xd3, 0x7f, 0x40, 0x0b, 0x02}}, + {{0xd0, 0xd3, 0x00, 0x0e, 0xa3, 0xc5, 0xfd, 0x15, 0xc8, 0x3f, 0x0e, 0x08, 0xf8, 0x00, + 0xd4, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x40, 0x04, 0x90, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xdb, 0xb3, 0x00, 0x0e, 0x63, 0xbe, 0xfd, 0x59, 0xc7, 0xbf, 0x26, 0x08, 0x18, 0x01, + 0xe8, 0xf4, 0x1e, 0x50, 0xff, 0xf7, 0xff, 0x04, 0x00, 0x01, 0x74, 0x7a, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x05, 0x00, 0x04, 0x90, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xe8, 0xd3, 0x00, 0x0e, 0x43, 0xb7, 0xfd, 0xb1, 0xc6, 0xbf, 0x43, 0x08, 0xb8, 0x00, + 0xe4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x04, 0xc0, 0x02, 0x80, 0xd3, 0x87, 0x40, 0x09, 0x02}}, + {{0xf3, 0xb3, 0x00, 0x0e, 0x83, 0xb0, 0xfd, 0xf9, 0xc5, 0x3f, 0x5a, 0x08, 0xc8, 0x00, + 0xe4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0xc0, 0x03, 0x90, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0xff, 0xc3, 0x00, 0x0e, 0x03, 0xaa, 0xfd, 0x49, 0xc5, 0x3f, 0x72, 0x08, 0xd8, 0x00, + 0xec, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x74, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x0a, 0x02}}, + {{0x0b, 0xc4, 0x00, 0x0e, 0x83, 0xa2, 0xfd, 0x91, 0xc4, 0x3f, 0x8d, 0x08, 0xe8, 0x00, + 0xd0, 0xf4, 0x25, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x09, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x60, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x17, 0xc4, 0x00, 0x0e, 0xc3, 0x9b, 0xfd, 0xcd, 0xc3, 0x3f, 0xa5, 0x08, 0xd8, 0x00, + 0xdc, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0x50, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x22, 0xb4, 0x00, 0x0e, 0x63, 0x94, 0xfd, 0x19, 0xc3, 0x3f, 0xbe, 0x08, 0xe8, 0x00, + 0xcc, 0xf4, 0x1f, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x2f, 0xd4, 0x00, 0x0e, 0x83, 0x8d, 0xfd, 0x75, 0xc2, 0x3f, 0xd5, 0x08, 0xe8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x08, 0xc0, 0x03, 0x70, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x3a, 0xb4, 0x00, 0x0e, 0xc3, 0x86, 0xfd, 0xbd, 0xc1, 0x3f, 0xf1, 0x08, 0xd8, 0x00, + 0xd4, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x08, 0xa4, + 0xff, 0xf3, 0xff, 0x04, 0x80, 0x03, 0x30, 0xd3, 0x7f, 0x40, 0x05, 0x02}}, + {{0x47, 0xd4, 0x00, 0x0e, 0x23, 0x80, 0xfd, 0x19, 0xc1, 0xbf, 0x0a, 0x09, 0xd8, 0x00, + 0xd4, 0xf4, 0x1f, 0x10, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x6c, 0x7a, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x02, 0x70, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x52, 0xb4, 0x00, 0x0e, 0x03, 0x79, 0xfd, 0x75, 0xc0, 0xbf, 0x26, 0x09, 0xc8, 0x00, + 0xe4, 0xf4, 0x1c, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0xa0, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x5f, 0xd4, 0x00, 0x0e, 0x23, 0x72, 0xfd, 0xbd, 0xbf, 0xbf, 0x3d, 0x09, 0xd8, 0x00, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xf3, 0xff, 0x06, 0xc0, 0x02, 0x50, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x6a, 0xb4, 0x00, 0x0e, 0xe3, 0x6a, 0xfd, 0x19, 0xbf, 0x3f, 0x57, 0x09, 0xd8, 0x00, + 0xd4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x08, 0x40, 0x03, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x77, 0xd4, 0x00, 0x0e, 0xc3, 0x63, 0xfd, 0x59, 0xbe, 0xbf, 0x6f, 0x09, 0x08, 0x01, + 0xe0, 0xf4, 0x1d, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x04, 0x02}}, + {{0x82, 0xb4, 0x00, 0x0e, 0x43, 0x5c, 0xfd, 0xb1, 0xbd, 0x3f, 0x86, 0x09, 0xe8, 0x00, + 0xd4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x07, 0x40, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x8e, 0xc4, 0x00, 0x0e, 0x63, 0x55, 0xfd, 0x01, 0xbd, 0x3f, 0x9f, 0x09, 0xb8, 0x00, + 0xd4, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x64, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x30, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x9a, 0xc4, 0x00, 0x0e, 0xc3, 0x4d, 0xfd, 0x59, 0xbc, 0x3f, 0xb9, 0x09, 0xd8, 0x00, + 0xd4, 0xf4, 0x24, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x70, 0xd3, 0x9f, 0x40, 0x00, 0x02}}, + {{0xa6, 0xc4, 0x00, 0x0e, 0x03, 0x46, 0xfd, 0xad, 0xbb, 0xbf, 0xd3, 0x09, 0xb8, 0x00, + 0xe4, 0xf4, 0x23, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x04, 0x40, 0x03, 0x70, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xb1, 0xb4, 0x00, 0x0e, 0x23, 0x3f, 0xfd, 0xf9, 0xba, 0x3f, 0xe9, 0x09, 0xc8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x40, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xbe, 0xd4, 0x00, 0x0e, 0x03, 0x38, 0xfd, 0x3d, 0xba, 0x3f, 0x05, 0x0a, 0xd8, 0x00, + 0xd4, 0xf4, 0x22, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x09, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x04, 0x02}}, + {{0xc9, 0xb4, 0x00, 0x0e, 0xa3, 0x30, 0xfd, 0x9d, 0xb9, 0xbf, 0x1e, 0x0a, 0xf8, 0x00, + 0xd8, 0xf4, 0x24, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x09, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x40, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x05, 0x02}}, + {{0xd6, 0xd4, 0x00, 0x0e, 0xc3, 0x29, 0xfd, 0xe5, 0xb8, 0xbf, 0x37, 0x0a, 0xe8, 0x00, + 0xe0, 0xf4, 0x25, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x09, 0xa4, + 0xff, 0xf3, 0xff, 0x06, 0x00, 0x04, 0xa0, 0xd3, 0x8f, 0x40, 0x04, 0x02}}, + {{0xe1, 0xb4, 0x00, 0x0e, 0x23, 0x23, 0xfd, 0x49, 0xb8, 0xbf, 0x50, 0x0a, 0xe8, 0x00, + 0xe0, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x60, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0xee, 0xd4, 0x00, 0x0e, 0xc3, 0x1b, 0xfd, 0x91, 0xb7, 0x3f, 0x67, 0x0a, 0xc8, 0x00, + 0xe4, 0xf4, 0x24, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x40, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0xf9, 0xb4, 0x00, 0x0e, 0xc3, 0x14, 0xfd, 0xe9, 0xb6, 0xbf, 0x82, 0x0a, 0xc8, 0x00, + 0xcc, 0xf4, 0x21, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x09, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x03, 0x50, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0x06, 0xd5, 0x00, 0x0e, 0x23, 0x0e, 0xfd, 0x35, 0xb6, 0x3f, 0x9a, 0x0a, 0xf8, 0x00, + 0xd4, 0xf4, 0x24, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x05, 0x00, 0x04, 0x80, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0x11, 0xb5, 0x00, 0x0e, 0x23, 0x07, 0xfd, 0x89, 0xb5, 0x3f, 0xb1, 0x0a, 0xc8, 0x00, + 0xe0, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x80, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x1d, 0xc5, 0x00, 0x0e, 0x83, 0x00, 0xfd, 0xd9, 0xb4, 0x3f, 0xc8, 0x0a, 0xe8, 0x00, + 0xe0, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x80, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x28, 0xb5, 0x00, 0x0e, 0x63, 0xf9, 0xfc, 0x21, 0xb4, 0xbf, 0xe2, 0x0a, 0xd8, 0x00, + 0xe8, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x04, 0xc0, 0x00, 0x74, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0xc0, 0x02, 0x70, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x35, 0xd5, 0x00, 0x0e, 0x43, 0xf2, 0xfc, 0x75, 0xb3, 0xbf, 0xfe, 0x0a, 0xb8, 0x00, + 0xe4, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x02, 0xa0, 0xd3, 0x8f, 0x40, 0x09, 0x02}}, + {{0x40, 0xb5, 0x00, 0x0e, 0x83, 0xea, 0xfc, 0xbd, 0xb2, 0xbf, 0x16, 0x0b, 0xd8, 0x00, + 0xd8, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x06, 0x00, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x05, 0x02}}, + {{0x4d, 0xd5, 0x00, 0x0e, 0xa3, 0xe2, 0xfc, 0xf9, 0xb1, 0xbf, 0x2e, 0x0b, 0xc8, 0x00, + 0xd4, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xf3, 0xff, 0x05, 0x40, 0x03, 0x20, 0xd3, 0x6f, 0x40, 0x00, 0x02}}, + {{0x58, 0xb5, 0x00, 0x0e, 0x43, 0xdb, 0xfc, 0x55, 0xb1, 0x3f, 0x48, 0x0b, 0xc8, 0x00, + 0xcc, 0xf4, 0x1c, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x64, 0xfa, 0x06, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x30, 0xd3, 0x77, 0x40, 0x05, 0x02}}, + {{0x65, 0xd5, 0x00, 0x0e, 0x43, 0xd4, 0xfc, 0xb9, 0xb0, 0xbf, 0x5f, 0x0b, 0xe8, 0x00, + 0xc8, 0xf4, 0x1e, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x64, 0xfa, 0x07, 0x94, + 0xff, 0xf3, 0xff, 0x07, 0x00, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x70, 0xb5, 0x00, 0x0e, 0x43, 0xcd, 0xfc, 0x1d, 0xb0, 0x3f, 0x7e, 0x0b, 0xb8, 0x00, + 0xcc, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0xc0, 0x00, 0x64, 0xfa, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x06, 0x40, 0x02, 0x50, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x7d, 0xd5, 0x00, 0x0e, 0x23, 0xc6, 0xfc, 0x65, 0xaf, 0xbf, 0x9a, 0x0b, 0xa8, 0x00, + 0xd0, 0xf4, 0x24, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0x80, 0x02, 0x60, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x88, 0xb5, 0x00, 0x0e, 0x23, 0xbf, 0xfc, 0xd1, 0xae, 0xbf, 0xb5, 0x0b, 0xc8, 0x00, + 0xd8, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0xb4, + 0xff, 0xef, 0xff, 0x05, 0x00, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x94, 0xc5, 0x00, 0x0e, 0x43, 0xb8, 0xfc, 0x31, 0xae, 0xbf, 0xcc, 0x0b, 0xd8, 0x00, + 0xe0, 0xf4, 0x1f, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0xa0, 0xc5, 0x00, 0x0e, 0x43, 0xb1, 0xfc, 0x8d, 0xad, 0xbf, 0xe4, 0x0b, 0xe8, 0x00, + 0xe0, 0xf4, 0x24, 0x10, 0xff, 0xef, 0xff, 0x04, 0x20, 0x01, 0x70, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x04, 0x00, 0x04, 0x50, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0xac, 0xc5, 0x00, 0x0e, 0xc3, 0xa8, 0xfc, 0xcd, 0xac, 0xbf, 0xfe, 0x0b, 0x18, 0x01, + 0xd0, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x80, 0x03, 0x30, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xb7, 0xb5, 0x00, 0x0e, 0x43, 0xa1, 0xfc, 0x25, 0xac, 0x3f, 0x19, 0x0c, 0xc8, 0x00, + 0xd0, 0xf4, 0x1e, 0x50, 0xff, 0xe7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x80, 0x02, 0x40, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xc4, 0xd5, 0x00, 0x0e, 0x43, 0x9a, 0xfc, 0x59, 0xab, 0x3f, 0x33, 0x0c, 0xc8, 0x00, + 0xe0, 0xf4, 0x23, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0xc0, 0x02, 0x90, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xcf, 0xb5, 0x00, 0x0e, 0xc3, 0x92, 0xfc, 0xbd, 0xaa, 0x3f, 0x48, 0x0c, 0xc8, 0x00, + 0xe0, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x03, 0x40, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xdc, 0xd5, 0x00, 0x0e, 0x03, 0x8b, 0xfc, 0x0d, 0xaa, 0xbf, 0x5c, 0x0c, 0xf8, 0x00, + 0xe0, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x80, 0x04, 0x80, 0xd3, 0x83, 0x40, 0x04, 0x02}}, + {{0xe7, 0xb5, 0x00, 0x0e, 0xc3, 0x83, 0xfc, 0x65, 0xa9, 0x3f, 0x79, 0x0c, 0x28, 0x01, + 0xd4, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x03, 0x40, 0xd3, 0x7f, 0x40, 0x0b, 0x02}}, + {{0xf4, 0xd5, 0x00, 0x0e, 0x03, 0x7c, 0xfc, 0xad, 0xa8, 0x3f, 0x94, 0x0c, 0xd8, 0x00, + 0xc8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x60, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x02, 0xf0, 0xd2, 0x6f, 0x40, 0x04, 0x02}}, + {{0xff, 0xb5, 0x00, 0x0e, 0x23, 0x75, 0xfc, 0xf9, 0xa7, 0x3f, 0xaf, 0x0c, 0xd8, 0x00, + 0xb8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x64, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x07, 0x80, 0x02, 0x30, 0xd3, 0x83, 0x40, 0x0b, 0x02}}, +}}; + +} // namespace espp::switch2 diff --git a/components/switch2_pro/include/switch2_pro_protocol.hpp b/components/switch2_pro/include/switch2_pro_protocol.hpp index a67bbffb53..63efbe3f53 100644 --- a/components/switch2_pro/include/switch2_pro_protocol.hpp +++ b/components/switch2_pro/include/switch2_pro_protocol.hpp @@ -43,6 +43,34 @@ inline constexpr const char *FIRMWARE_UPDATE_UUID = "4147423d-fdae-4df7-a4f7-d23 inline constexpr const char *COMMAND_RESPONSE1_UUID = "c765a961-d9d8-4d36-a20a-5315b111836a"; /// Command response #2 — replies to writes on the vibration+command channel. NOTIFY. inline constexpr const char *COMMAND_RESPONSE2_UUID = "506d9f7d-4278-4e95-a549-326ba77657e0"; +/// Additional service-2 attributes a real Pro Controller 2 exposes; replicated so +/// the console's GATT discovery sees the same characteristic set (handles 0x0022, +/// 0x0026, 0x002a). Purpose unknown but their absence appears to make the console +/// reject the controller after discovery. +inline constexpr const char *UNKNOWN_INPUT1_UUID = + "d3bd69d2-841c-4241-ab15-f86f406d2a80"; // 0x0022 NOTIFY +inline constexpr const char *UNKNOWN_INPUT2_UUID = + "ab7de9be-89fe-49ad-828f-118f09df7fde"; // 0x0026 READ|NOTIFY +inline constexpr const char *UNKNOWN_OUTPUT_UUID = + "ab7de9be-89fe-49ad-828f-118f09df7fdf"; // 0x002a WRITE_NR + +/// Vendor descriptors a real controller attaches to its characteristics. The +/// "report rate" descriptor sits on the input-report characteristics; the other +/// on the command-response characteristics. Replicated for discovery parity. +inline constexpr const char *REPORT_RATE_DESC_UUID = "679d5510-5a24-4dee-9557-95df80486ecb"; +inline constexpr const char *CMD_RESPONSE_DESC_UUID = "b746df8c-f358-495b-9cd2-e3bbeda4f979"; + +/// Headset-audio attributes exposed by a Pro Controller 2 that has been updated +/// from factory firmware (handles 0x002c/0x002e/0x0032). Their presence (and a +/// valid DSP version in the 0x10 firmware-info reply) is how the console tells a +/// fully-updated controller from factory firmware; without them the console +/// treats us as un-updated and diverges (probing firmware-info, rejecting). +inline constexpr const char *AUDIO_OUTPUT_UUID = + "cc483f51-9258-427d-a939-630c31f72b06"; // 0x002c WRITE_NR +inline constexpr const char *AUDIO_INPUT_UUID = + "7492866c-ec3e-4619-8258-32755ffcc0f9"; // 0x002e READ|NOTIFY +inline constexpr const char *AUDIO_COMMAND_UUID = + "3dacbc7e-6955-40b5-8eaf-6f9809e8b380"; // 0x0032 WRITE_NR // --------------------------------------------------------------------------- // Advertising / identity @@ -53,11 +81,17 @@ inline constexpr uint16_t VENDOR_ID = 0x057E; ///< Nintendo inline constexpr uint16_t PRODUCT_ID_PRO2 = 0x2069; ///< Pro Controller 2 /// Manufacturer-specific advertising payload (AD type 0xFF) the console filters -/// on. Byte 0x0B is the wake indicator (0x00 discovery / 0x81 wake) and bytes -/// 0x0C..0x11 carry the bonded host BD_ADDR (byte-reversed); zero for discovery. -inline constexpr std::array MANUFACTURER_DATA_DISCOVERY = { - 0x53, 0x05, 0x01, 0x00, 0x03, 0x7e, 0x05, 0x69, 0x20, 0x00, - 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00}; +/// on. This must byte-for-byte match a real Pro Controller 2 "standard" +/// advertisement (26 bytes, verified against the procon2 pairing capture) — +/// company id 0x0553, VID 0x057E, PID 0x2069, then fixed/flags/host-addr fields +/// and 7 trailing reserved zeros. With the 3-byte Flags AD this is exactly the +/// 31-byte legacy-advertisement limit, so the device name goes in the scan +/// response. Byte 0x0B is the wake indicator (0x00 discovery / 0x81 wake) and +/// bytes 0x0C..0x11 carry the bonded host BD_ADDR (byte-reversed); zero for +/// discovery. +inline constexpr std::array MANUFACTURER_DATA_DISCOVERY = { + 0x53, 0x05, 0x01, 0x00, 0x03, 0x7e, 0x05, 0x69, 0x20, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; inline constexpr size_t MANUFACTURER_WAKE_FLAG_OFFSET = 0x0b; inline constexpr size_t MANUFACTURER_HOST_ADDR_OFFSET = 0x0c; inline constexpr uint8_t WAKE_FLAG = 0x81; @@ -76,17 +110,35 @@ inline constexpr uint8_t TRANSPORT_USB = 0x00; inline constexpr uint8_t TRANSPORT_BT = 0x01; inline constexpr uint8_t ACK_MARKER = 0x78; ///< seen in header byte 5 of replies +/// The vibration+command channel (0x0016) carries a fixed-size vibration payload +/// BEFORE the command, so every command written to it is preceded by this many +/// 0x00 bytes (verified: all init-sequence writes on 0x0016 have a 33-byte +/// prefix). The command-only channel (0x0014) has no prefix. +inline constexpr size_t VIBRATION_COMMAND_PREFIX_SIZE = 33; +/// The command-response channel (0x001e) likewise prefixes every response with a +/// fixed 14-byte (zero) report header before the 8-byte response header. +inline constexpr size_t RESPONSE_PREFIX_SIZE = 14; +/// Header byte[4]/byte[5] for a Bluetooth response. The USB transport uses +/// 0x00/0xf8 for bare ACKs, but every Pro Controller 2 BLE response (ACK or with +/// data) uses 0x10/0x78. +inline constexpr uint8_t RSP_BYTE4_BT = 0x10; +inline constexpr uint8_t RSP_BYTE5_BT = 0x78; + enum class Command : uint8_t { NFC = 0x01, FLASH_READ = 0x02, ///< read calibration / device info INIT = 0x03, + UNKNOWN_07 = 0x07, ///< init handshake; response is 1 zero data byte PLAYER_LEDS = 0x09, VIBRATION = 0x0a, BATTERY = 0x0b, - FEATURE_SELECT = 0x0c, ///< enable motion / mouse / rumble / magnetometer + FEATURE_SELECT = 0x0c, ///< enable motion / mouse / rumble / magnetometer; response 4 zero bytes FIRMWARE_UPDATE = 0x0d, FIRMWARE_INFO = 0x10, + UNKNOWN_11 = 0x11, ///< init handshake (post-pairing); response is a device blob + UNKNOWN_16 = 0x16, ///< init handshake; response is 24 zero data bytes PAIRING = 0x15, + UNKNOWN_18 = 0x18, ///< late-init probe; 0x18/0x01 response is an 8-byte device blob }; /// Subcommands of Command::PAIRING (0x15). diff --git a/components/switch2_pro/include/switch2_pro_report.hpp b/components/switch2_pro/include/switch2_pro_report.hpp index 162028f3fd..6de81315d6 100644 --- a/components/switch2_pro/include/switch2_pro_report.hpp +++ b/components/switch2_pro/include/switch2_pro_report.hpp @@ -31,6 +31,7 @@ class Pro2InputReport { void reset() { data_.fill(0); + data_[0x0b] = 0x30; // "unknown" byte: 0x30 unless feature bit 5 is set (0x38) set_left_stick(0.f, 0.f); set_right_stick(0.f, 0.f); } diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index 99c465b5ff..e04c71dfe6 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -1,12 +1,24 @@ #include "switch2_pro.hpp" +#include #include +#include #include #include "esp_log.h" #include "esp_mac.h" +#include "esp_pthread.h" // esp_pthread_set_cfg — size the streaming task's stack +#include "esp_timer.h" // esp_timer_get_time — µs timestamps for tx-wedge telemetry +#include "nvs.h" +#include "os/os_mempool.h" // os_mempool_info_get_next — mbuf pool free/low-water telemetry + +#include "host/ble_gatt.h" // ble_gatts_notify_custom — low-level notify (exposes rc) +#include "host/ble_hs.h" // ble_hs_id_infer_auto / ble_hs_id_copy_addr +#include "host/ble_hs_mbuf.h" // ble_hs_mbuf_from_flat +#include "host/ble_store.h" // ble_store_write_our_sec — inject the pairing LTK #include "switch2_pro_flash.hpp" +#include "switch2_pro_motion.hpp" namespace espp { @@ -25,8 +37,12 @@ class ChannelCallbacks : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo & /*conn*/) override { auto value = characteristic->getValue(); - owner_->logger_.info("WRITE {} ({} bytes)", name_, value.size()); - owner_->log_hex(name_, value.data(), value.size()); + // Command channels log their own decoded hex in on_command_write; only log + // a raw dump here for the passive channels (role 0) to avoid duplication. + if (role_ == 0) { + owner_->logger_.debug("WRITE {} ({} bytes)", name_, value.size()); + owner_->log_hex(name_, value.data(), value.size()); + } if (role_ == 1) owner_->on_command_write(/*via_vibration_command=*/false, value.data(), value.size()); else if (role_ == 2) @@ -35,10 +51,18 @@ class ChannelCallbacks : public NimBLECharacteristicCallbacks { void onRead(NimBLECharacteristic * /*c*/, NimBLEConnInfo & /*conn*/) override { owner_->logger_.info("READ {}", name_); } - void onSubscribe(NimBLECharacteristic * /*c*/, NimBLEConnInfo & /*conn*/, + void onSubscribe(NimBLECharacteristic *c, NimBLEConnInfo & /*conn*/, uint16_t sub_value) override { owner_->logger_.info("SUBSCRIBE {} value=0x{:04x} ({})", name_, sub_value, sub_value ? "on" : "off"); + owner_->on_subscribe(c, sub_value); + } + // Fires (BLE_GAP_EVENT_NOTIFY_TX) once a notification we sent has been + // transmitted, freeing its tx buffer. Used to flow-control the input stream so + // we never queue faster than the link drains (which otherwise saturates the + // tx pool and makes every subsequent notify fail). + void onStatus(NimBLECharacteristic *c, NimBLEConnInfo & /*conn*/, int /*code*/) override { + owner_->on_notify_tx(c); } private: @@ -48,11 +72,11 @@ class ChannelCallbacks : public NimBLECharacteristicCallbacks { }; bool Switch2Pro::init() { - // Bring-up debugging: turn the NimBLE host log up to DEBUG for its tags only, - // so we see the console's ATT service discovery / reads / writes without - // flooding every other component. (Compiled in via CONFIG_LOG_MAXIMUM_LEVEL.) - esp_log_level_set("NimBLE", ESP_LOG_DEBUG); - esp_log_level_set("NimBLEGATTS", ESP_LOG_DEBUG); + // Keep the NimBLE host log quiet — our own Switch2Pro trace carries the + // protocol flow. Bump these to ESP_LOG_DEBUG when the raw stack-level view + // (every ATT/ACL byte) is needed. + esp_log_level_set("NimBLE", ESP_LOG_WARN); + esp_log_level_set("NimBLEGATTS", ESP_LOG_WARN); // The pairing crypto is the load-bearing part; verify it against the golden // vector up front so a broken build fails loudly rather than at the console. @@ -64,10 +88,45 @@ bool Switch2Pro::init() { } configure_callbacks(); + // A real Pro Controller 2 exposes ONLY its two vendor services (plus GAP/GATT) + // — no Device Information or Battery service. Suppress BleGattServer's built-in + // DIS/BAS so the console's GATT discovery sees the same attribute set; the + // extra services (and the handle shift they cause) make the console reject us + // after discovery. + ble_gatt_server_.set_builtin_info_services_enabled(false); if (!ble_gatt_server_.init(device_name_)) { logger_.error("failed to init BLE GATT server"); return false; } + // A real Pro Controller 2 advertises with a FIXED address; esp-nimble-cpp + // defaults to a random address that also changes every boot. The console + // stores the controller's address during exchange-addresses (0x15/0x01) and + // rejects an unstable one. Prefer the public address; if the S3 controller + // exposes none, derive a STABLE static-random address from the factory MAC so + // it never changes between boots. local_bt_address() reports whatever we set, + // so the exchange always matches our advertisement. + if (NimBLEDevice::setOwnAddrType(BLE_OWN_ADDR_PUBLIC)) { + logger_.info("BLE address: using PUBLIC"); + } else { + uint8_t mac[6] = {}; + esp_read_mac(mac, ESP_MAC_BT); // stable factory MAC, big-endian (display order) + // ble_hs_id_set_rnd wants little-endian; a static-random address needs the + // two most-significant bits of the MSB set. + std::array rnd = {mac[5], mac[4], mac[3], mac[2], mac[1], mac[0]}; + rnd[5] |= 0xC0; + NimBLEDevice::setOwnAddr(rnd.data()); + NimBLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM); + logger_.warn("BLE address: PUBLIC unavailable; using STABLE static-random {:02x}:{:02x}:{:02x}:" + "{:02x}:{:02x}:{:02x}", + rnd[5], rnd[4], rnd[3], rnd[2], rnd[1], rnd[0]); + } + // Load any persisted bond BEFORE configure_security (which otherwise clears + // bonds): if present we reconnect instead of re-pairing. + reconnect_mode_ = load_bond(); + if (reconnect_mode_) { + paired_ = true; + logger_.info("loaded stored bond — reconnection mode (console address persisted)"); + } configure_security(); if (!build_gatt()) { logger_.error("failed to build GATT services"); @@ -76,11 +135,40 @@ bool Switch2Pro::init() { ble_gatt_server_.start_services(); ble_gatt_server_.start(); log_handle_map(); // after start(), so handles are assigned - start_advertising(/*wake=*/false); + // On reconnect the console skips the 0x15 pairing and jumps straight to LL + // encryption, so the LTK must already be in NimBLE's store before it connects. + if (reconnect_mode_) { + inject_ltk(bond_peer_type_, bond_peer_val_.data()); + if (wake_console_on_boot_) { + logger_.info( + "wake-on-boot: broadcasting the wake advertisement every {:.0f}s until connected", + wake_interval_.count()); + start_wake_timer(); + } + } + advertise(); logger_.info("Switch2Pro advertising as '{}'", device_name_); + + // Start the driver-owned input-streaming task. It notifies the latest report + // once per connection interval while the console is subscribed. Give it a + // generous stack (ble_gatts_notify_custom is a deep call) and pin it to core 0, + // away from the BLE controller/host on core 1. + esp_pthread_cfg_t cfg = esp_pthread_get_default_config(); + cfg.stack_size = 8192; + cfg.prio = 5; + cfg.pin_to_core = 0; + cfg.thread_name = "s2p_stream"; + esp_pthread_set_cfg(&cfg); + input_stream_thread_ = std::thread(&Switch2Pro::input_stream_loop, this); return true; } +Switch2Pro::~Switch2Pro() { + stream_stop_.store(true); + if (input_stream_thread_.joinable()) + input_stream_thread_.join(); +} + void Switch2Pro::configure_security() { // The Switch 2 does its own app-level pairing over the command channel (the // 0x15 exchange), NOT BLE SMP. BLE-level bonding here just creates a bond the @@ -88,28 +176,71 @@ void Switch2Pro::configure_security() { // on reconnect and get stuck. So: no bonding, no SMP-initiated security. ble_gatt_server_.set_security(/*bonding=*/false, /*mitm=*/false, /*secure=*/false); ble_gatt_server_.set_io_capabilities(BLE_HS_IO_NO_INPUT_OUTPUT); - // Clear any bonds left from earlier rounds so the console re-discovers our - // GATT cleanly instead of using a stale cache. - size_t cleared = ble_gatt_server_.unpair_all().size(); - if (cleared) - logger_.info("cleared {} stale BLE bond(s)", cleared); + // Only clear bonds on a FRESH start. If we have a persisted bond we are in + // reconnection mode and must KEEP the injected LTK so the console can re-encrypt + // without re-pairing. + if (!reconnect_mode_) { + size_t cleared = ble_gatt_server_.unpair_all().size(); + if (cleared) + logger_.info("cleared {} stale BLE bond(s)", cleared); + } } void Switch2Pro::configure_callbacks() { BleGattServer::Callbacks callbacks; callbacks.connect_callback = [this](NimBLEConnInfo &info) { + // Remember the connection so the pairing exchange can report the exact + // over-the-air address the console connected to (see local_bt_address()). + active_conn_handle_ = info.getConnHandle(); + wake_pending_ = false; // wake accomplished — subsequent advertising can be passive // The connection interval right after connect is the key diagnostic: the // Switch 2 drives 5 ms (interval == 4 units). If a controller can't hold // that, the console typically disconnects with a supervision timeout. + // Connection interval is logged for reference, but note: a real console + // pairs entirely at the initial 15 ms interval (verified against the + // procon2 pairing capture) — it does NOT move to 5 ms until after pairing. + // So this value is not a pairing gate; it matters only for post-pairing + // low-latency input streaming. logger_.info("connected: peer={} interval={:.2f}ms supervision={}ms latency={}", info.getAddress().toString(), info.getConnInterval() * 1.25f, info.getConnTimeout() * 10, info.getConnLatency()); + // NOTE: we deliberately do NOT initiate a connection-parameter update here. + // It cannot lower us below 7.5 ms anyway (NimBLE floors ble_gap_update_params + // at the spec minimum), the console keeps 15 ms regardless, and the real 5 ms + // arrives in the console's CONNECT_IND on reconnect (needs the controller + // patch), not via an update. On the ESP32-S3 BTDM controller, kicking off an + // update procedure right after connect correlated with a degraded/limping tx + // link, so it is removed. See request_fast_interval() history in git if you + // want to re-test it. }; callbacks.disconnect_callback = [this](NimBLEConnInfo &info, BleGattServer::DisconnectReason r) { logger_.warn("disconnected: peer={} reason={} (paired={})", info.getAddress().toString(), r, paired_); + // Tie the disconnect to the tx-wedge timeline: how long we streamed, whether + // we had wedged, and how stale the last completion was. A disconnect ~1 SVN + // timeout after the wedge with a large since_last_tx = over-air exchange + // stopped at the wedge; staying up long after = the link outlived our tx stall. + if (stream_start_us_ != 0) { + const int64_t now = esp_timer_get_time(); + logger_.warn(" @disconnect: streamed {:.1f}s, {} completions, {} enomem, wedged={}, " + "since_last_tx={:.0f}ms | {}", + (now - stream_start_us_) / 1e6f, tx_completions_.load(), enomem_count_, + wedge_reported_, (now - last_tx_complete_us_.load()) / 1000.0f, pool_stats()); + } paired_ = false; - start_advertising(/*wake=*/false); + input_subscribed_ = false; + active_conn_handle_ = 0xffff; // so the wake timer knows we're disconnected + advertise(); + }; + callbacks.conn_params_update_callback = [this](NimBLEConnInfo &info) { + // Fires when ANY connection-parameter-update procedure completes — including + // the console's answer to our at-connect offer, and any console-initiated + // update. If the interval here is still 15 ms, the console REJECTED (or + // no-op'd) the procedure; if it moved (5/7.5 ms), it accepted. Before this + // callback existed we were blind to the difference between "rejected" and + // "console never responded". + logger_.info("CONN PARAMS UPDATE: itvl={:.2f}ms latency={} timeout={}ms", + info.getConnInterval() * 1.25f, info.getConnLatency(), info.getConnTimeout() * 10); }; callbacks.authentication_complete_callback = [this](const NimBLEConnInfo &info) { // If this fires, the console ran BLE SMP (which the research says it should @@ -128,9 +259,10 @@ void Switch2Pro::log_hex(const char *prefix, const uint8_t *data, size_t len) { std::snprintf(tmp, sizeof(tmp), "%02x ", data[i]); hex += tmp; } - // INFO during bring-up so the raw command/response bytes always show; dial - // back to debug once the protocol is settled. - logger_.info("{} [{}]: {}", prefix, len, hex); + // DEBUG: the raw command/response bytes are verbose (and, streamed over serial + // during the rapid init sequence, can saturate the UART). Set the component + // log level to DEBUG to see them. + logger_.debug("{} [{}]: {}", prefix, len, hex); } bool Switch2Pro::build_gatt() { @@ -138,6 +270,15 @@ bool Switch2Pro::build_gatt() { if (server == nullptr) return false; + // Register our Nintendo services BEFORE NimBLE's GAP/GATT so they occupy the + // low attribute handles (0x0001+) with GAP/GATT last — matching a real Pro + // Controller 2's exact handle layout. A real console addresses the controller + // by fixed handles (0x0016 command, 0x001e response, …) and never discovers; + // with our services shifted to 0x0022+ the console is forced into a discovery + // + firmware-probe fallback path that rejects at the pairing commit. Must be + // set before start_services() (below) starts the GATT server. + server->registerServicesFirst(true); + // Attach a tracing callback to every characteristic so bring-up logs show // exactly what the console does. Roles: 1 = command 0x0014, 2 = vibration+ // command 0x0016, 0 = passive. @@ -155,14 +296,26 @@ bool Switch2Pro::build_gatt() { attach(svc1->createCharacteristic(NimBLEUUID(SERVICE1_CHR_283_UUID), NIMBLE_PROPERTY::READ), "svc1.283", 0); - // Service 2 — the main HID-like service. + // Service 2 — the main HID-like service. The full characteristic + descriptor + // set is replicated from a real Pro Controller 2 (bluetooth_interface.md GATT + // table): the NOTIFY characteristics auto-get a 0x2902 CCCD from NimBLE, and a + // real controller additionally hangs a vendor descriptor off each report / + // response characteristic (0x679d5510 "report rate" on inputs, 0xb746df8c on + // responses). The console reads the whole table during discovery, so a missing + // characteristic or descriptor makes it reject us. + auto add_desc = [](NimBLECharacteristic *c, const char *uuid) { + c->createDescriptor(NimBLEUUID(uuid), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE, 32); + }; auto *svc2 = server->createService(NimBLEUUID(SERVICE2_UUID)); + common_input_ = svc2->createCharacteristic(NimBLEUUID(COMMON_INPUT_UUID), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); attach(common_input_, "common_input(0x000a)", 0); + add_desc(common_input_, REPORT_RATE_DESC_UUID); pro2_input_ = svc2->createCharacteristic(NimBLEUUID(PRO2_INPUT_UUID), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); attach(pro2_input_, "pro2_input(0x000e)", 0); + add_desc(pro2_input_, REPORT_RATE_DESC_UUID); attach(svc2->createCharacteristic(NimBLEUUID(VIBRATION_UUID), NIMBLE_PROPERTY::WRITE_NR), "vibration(0x0012)", 0); command_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); @@ -170,14 +323,42 @@ bool Switch2Pro::build_gatt() { vibration_command_ = svc2->createCharacteristic(NimBLEUUID(VIBRATION_COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); attach(vibration_command_, "vib_command(0x0016)", 2); - attach(svc2->createCharacteristic(NimBLEUUID(FIRMWARE_UPDATE_UUID), NIMBLE_PROPERTY::WRITE), + // Firmware-update output is WRITE-NO-RESPONSE on a real controller, not WRITE. + attach(svc2->createCharacteristic(NimBLEUUID(FIRMWARE_UPDATE_UUID), NIMBLE_PROPERTY::WRITE_NR), "firmware(0x0018)", 0); command_response1_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE1_UUID), NIMBLE_PROPERTY::NOTIFY); attach(command_response1_, "resp1(0x001a)", 0); + add_desc(command_response1_, CMD_RESPONSE_DESC_UUID); command_response2_ = svc2->createCharacteristic(NimBLEUUID(COMMAND_RESPONSE2_UUID), NIMBLE_PROPERTY::NOTIFY); attach(command_response2_, "resp2(0x001e)", 0); + add_desc(command_response2_, CMD_RESPONSE_DESC_UUID); + + // Additional attributes a real Pro Controller 2 exposes (purpose unknown); + // replicated so the console's discovery sees the full characteristic set. + auto *unknown_input1 = + svc2->createCharacteristic(NimBLEUUID(UNKNOWN_INPUT1_UUID), NIMBLE_PROPERTY::NOTIFY); + attach(unknown_input1, "unk_input1(0x0022)", 0); + add_desc(unknown_input1, CMD_RESPONSE_DESC_UUID); + auto *unknown_input2 = svc2->createCharacteristic( + NimBLEUUID(UNKNOWN_INPUT2_UUID), NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + attach(unknown_input2, "unk_input2(0x0026)", 0); + add_desc(unknown_input2, REPORT_RATE_DESC_UUID); + attach(svc2->createCharacteristic(NimBLEUUID(UNKNOWN_OUTPUT_UUID), NIMBLE_PROPERTY::WRITE_NR), + "unk_output(0x002a)", 0); + + // Headset-audio attributes of an updated Pro Controller 2 — presence signals + // fully-updated firmware so the console treats us as a genuine (not factory) + // controller. + attach(svc2->createCharacteristic(NimBLEUUID(AUDIO_OUTPUT_UUID), NIMBLE_PROPERTY::WRITE_NR), + "audio_output(0x002c)", 0); + auto *audio_input = svc2->createCharacteristic(NimBLEUUID(AUDIO_INPUT_UUID), + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + attach(audio_input, "audio_input(0x002e)", 0); + add_desc(audio_input, REPORT_RATE_DESC_UUID); + attach(svc2->createCharacteristic(NimBLEUUID(AUDIO_COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR), + "audio_command(0x0032)", 0); svc1->start(); svc2->start(); @@ -199,19 +380,29 @@ void Switch2Pro::log_handle_map() { logger_.info(" resp2 = 0x{:04x} (0x001e)", command_response2_->getHandle()); } -void Switch2Pro::start_advertising(bool wake, const std::array &host_addr) { +void Switch2Pro::start_advertising(AdvMode mode, const std::array &host_addr_le) { auto mfr = MANUFACTURER_DATA_DISCOVERY; - if (wake) { - mfr[MANUFACTURER_WAKE_FLAG_OFFSET] = WAKE_FLAG; - for (size_t i = 0; i < 6; ++i) // host address is byte-reversed on the wire - mfr[MANUFACTURER_HOST_ADDR_OFFSET + i] = host_addr[5 - i]; + if (mode != AdvMode::Discovery) { + if (mode == AdvMode::Wake) + mfr[MANUFACTURER_WAKE_FLAG_OFFSET] = WAKE_FLAG; + // The paired console's address is embedded verbatim (already wire order); + // this is how the console recognises a known controller on reconnect/wake. + for (size_t i = 0; i < 6; ++i) + mfr[MANUFACTURER_HOST_ADDR_OFFSET + i] = host_addr_le[i]; } // The console filters on the Nintendo manufacturer data, so it MUST be in the - // primary advertisement. Flags (3) + manufacturer data (22) = 25 bytes, which - // fits the 31-byte legacy limit; the name goes in the scan response so the - // whole thing doesn't overflow (which would silently drop the manufacturer + // primary advertisement. Flags (3) + manufacturer data (2 + 26 = 28) = 31 + // bytes, exactly the 31-byte legacy limit; the name goes in the scan response + // so the whole thing doesn't overflow (which would silently drop the manufacturer // data and make the controller invisible to the console). + // Stop any active advertising FIRST: NimBLE's start() early-returns when + // already advertising, and updating adv data mid-advertising (HCI Set + // Advertising Data while enabled) is not honoured by every controller — so a + // variant switch (e.g. Reconnect -> Wake on a button press) is only + // guaranteed to air after a clean stop/set/start cycle. + ble_gatt_server_.stop_advertising(); + BleGattServer::AdvertisedData adv_data; adv_data.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP); if (!adv_data.setManufacturerData(mfr.data(), mfr.size())) @@ -226,12 +417,91 @@ void Switch2Pro::start_advertising(bool wake, const std::array &host params.connectable = true; params.scan_response = true; ble_gatt_server_.start_advertising(params); - logger_.info("advertising: flags+mfr({} B) in adv, name in scan response", mfr.size()); + logger_.info("advertising ({}): flags+mfr({} B) in adv, name in scan response", + mode == AdvMode::Discovery ? "discovery" + : mode == AdvMode::Reconnect ? "reconnect" + : "wake", + mfr.size()); +} + +void Switch2Pro::advertise() { + // Embed the console's STABLE IDENTITY address (from the 0x15 exchange, persisted + // in the bond) — that is what the real controller advertises on reconnect, and + // what the console matches to recognise us and grant the fast 5 ms interval. Do + // NOT use bond_peer_val_ (the NimBLE connection address), which can be a + // rotating private address we cannot resolve without an SMP bond. + // + // Use the WAKE variant (0x81 flag = "user pressed a button, connect to me") + // while a wake is pending: an awake console ignores the flag-less Reconnect + // variant from its idle screens, and a waking console may transiently + // connect/drop (which re-enters here) — the latch keeps the wake variant on + // the air until a connection actually completes. + if (reconnect_mode_ && (wake_console_on_boot_ || wake_pending_)) + start_advertising(AdvMode::Wake, host_addr_); + else if (reconnect_mode_) + start_advertising(AdvMode::Reconnect, host_addr_); + else + start_advertising(AdvMode::Discovery); +} + +bool Switch2Pro::wake_console() { + if (active_conn_handle_ != 0xffff) + return false; // already connected — nothing to wake + static constexpr std::array kZeroAddr{}; + if (host_addr_ == kZeroAddr) + return false; // no bonded console identity to address the wake to + logger_.info("wake: broadcasting wake advertisement (user-requested)"); + wake_pending_ = true; // keep the wake variant on the air (across any transient + // connect/drop while the console boots) until connected + start_advertising(AdvMode::Wake, host_addr_); + return true; +} + +void Switch2Pro::start_wake_timer() { + if (wake_timer_) + return; + wake_timer_ = std::make_shared(espp::Timer::Config{ + .name = "switch2 wake", + .period = wake_interval_, + .callback = [this]() -> bool { + // While disconnected, keep re-issuing the wake advertisement so a + // sleeping console is repeatedly nudged; do nothing once connected. + if (active_conn_handle_ == 0xffff) { + logger_.info("wake: re-broadcasting wake advertisement (waiting for console)"); + advertise(); + } + return false; // never cancel — resume nudging after any disconnect + }, + .auto_start = true, + .stack_size_bytes = 8192, // advertise() → NimBLE is a deep call; 4096 can overflow + }); } std::array Switch2Pro::local_bt_address() const { + // Return the exact BLE address the console connected to, in on-air + // little-endian order (LSB first) — this is what the pairing exchange expects + // (the console sends its own addresses byte-reversed too). ble_hs_id_copy_addr + // gives NimBLE's address in that order and accounts for public-vs-random, so it + // always matches our advertisement. esp_read_mac (display/big-endian order, and + // not necessarily the advertised address) is only a fallback. std::array addr{}; + // Best source: the exact over-the-air address this connection was established + // with (our_ota_addr, already little-endian). This is precisely what the + // console connected to, so the exchange can never disagree with our + // advertisement regardless of public-vs-random. + struct ble_gap_conn_desc desc; + if (active_conn_handle_ != BLE_HS_CONN_HANDLE_NONE && + ble_gap_conn_find(active_conn_handle_, &desc) == 0) { + std::copy(std::begin(desc.our_ota_addr.val), std::end(desc.our_ota_addr.val), addr.begin()); + return addr; + } + // Fallbacks (not in a connection): whatever address id NimBLE holds, else MAC. + if (ble_hs_id_copy_addr(BLE_ADDR_PUBLIC, addr.data(), nullptr) == 0) + return addr; + if (ble_hs_id_copy_addr(BLE_ADDR_RANDOM, addr.data(), nullptr) == 0) + return addr; esp_read_mac(addr.data(), ESP_MAC_BT); + std::reverse(addr.begin(), addr.end()); // esp_read_mac is big-endian; exchange is little-endian return addr; } @@ -246,7 +516,12 @@ void Switch2Pro::send_response(bool via_vibration_command, uint8_t cmd, uint8_t if (response_char == nullptr) return; std::vector out; - out.reserve(COMMAND_HEADER_SIZE + payload_len); + out.reserve(RESPONSE_PREFIX_SIZE + COMMAND_HEADER_SIZE + payload_len); + // Responses on the 0x001e channel are prefixed with a fixed 14-byte (zero) + // report header, mirroring the command channel's vibration prefix; the console + // reads the 8-byte response header at that offset. + if (via_vibration_command) + out.resize(RESPONSE_PREFIX_SIZE, 0x00); // Device->host header: [cmd, 0x01, transport, sub, byte4, byte5, 0x00, 0x00]. out.insert(out.end(), {cmd, DIR_DEVICE_TO_HOST, transport, sub, byte4, byte5, 0x00, 0x00}); if (payload != nullptr && payload_len > 0) @@ -257,11 +532,11 @@ void Switch2Pro::send_response(bool via_vibration_command, uint8_t cmd, uint8_t } void Switch2Pro::send_ack(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub) { - // Header-only ACK: byte4=0x00, byte5=0xf8, payload {0x01,0,0,0} (matches the - // captured 0x03/0x0d init ACK). - static constexpr std::array kAckPayload = {0x01, 0x00, 0x00, 0x00}; - send_response(via_vibration_command, cmd, transport, sub, 0x00, 0xf8, kAckPayload.data(), - kAckPayload.size()); + // Bare BLE ACK: header only, byte4=0x10, byte5=0x78, no payload. Every Pro + // Controller 2 Bluetooth response uses 0x10/0x78 (the 0x00/0xf8 form is the USB + // transport); the captured init-sequence ACKs (e.g. 0x0a/0x02, 0x09/0x07) are + // header-only with no trailing data. + send_response(via_vibration_command, cmd, transport, sub, RSP_BYTE4_BT, RSP_BYTE5_BT, nullptr, 0); } // --------------------------------------------------------------------------- @@ -270,6 +545,17 @@ void Switch2Pro::send_ack(bool via_vibration_command, uint8_t cmd, uint8_t trans void Switch2Pro::on_command_write(bool via_vibration_command, const uint8_t *data, size_t len) { log_hex(via_vibration_command ? "cmd<-0x0016" : "cmd<-0x0014", data, len); + // On the vibration+command channel (0x0016) the 8-byte command header follows a + // fixed 33-byte vibration payload; skip it so the command id/subcommand parse + // from the right offset. The command-only channel (0x0014) has no such prefix. + if (via_vibration_command) { + if (len < VIBRATION_COMMAND_PREFIX_SIZE + COMMAND_HEADER_SIZE) { + logger_.warn("short vibration+command write ({} bytes)", len); + return; + } + data += VIBRATION_COMMAND_PREFIX_SIZE; + len -= VIBRATION_COMMAND_PREFIX_SIZE; + } if (len < COMMAND_HEADER_SIZE) { logger_.warn("short command write ({} bytes)", len); return; @@ -280,6 +566,9 @@ void Switch2Pro::on_command_write(bool via_vibration_command, const uint8_t *dat const uint8_t *payload = data + COMMAND_HEADER_SIZE; const size_t payload_len = len - COMMAND_HEADER_SIZE; + // Concise trace of the init/command flow (the full byte dump is also at DEBUG). + logger_.debug("cmd 0x{:02x}/0x{:02x} ({}B data)", static_cast(cmd), sub, payload_len); + if (cmd == Command::PAIRING) { handle_pairing(via_vibration_command, transport, static_cast(sub), payload, payload_len); @@ -288,15 +577,345 @@ void Switch2Pro::on_command_write(bool via_vibration_command, const uint8_t *dat } } +namespace { +// NVS-persisted bond: the paired console's address and the negotiated LTK, so +// the controller can reconnect / wake without re-running the 0x15 pairing. +constexpr const char *kNvsNamespace = "switch2pro"; +constexpr const char *kNvsBondKey = "bond"; +constexpr uint8_t kBondMagic = 0xB2; +struct StoredBond { + uint8_t magic; + uint8_t peer_type; + uint8_t peer_val[6]; // NimBLE peer_id_addr (wire/little-endian order) + uint8_t ltk[16]; // ltk_ (= A1 ^ B1), natural order + uint8_t host_addr[6]; // console identity addr from 0x15/01 (embedded in reconnect adv) +}; +} // namespace + +void Switch2Pro::on_subscribe(NimBLECharacteristic *characteristic, uint16_t sub_value) { + // The console enables input-report notifications on the Pro Controller 2 input + // characteristic (0x000e) near the end of init; only then do we stream. + if (characteristic == pro2_input_) { + input_subscribed_ = (sub_value != 0); + if (input_subscribed_) { + report_counter_ = 0; // fresh sequence for the console to track + motion_idx_ = 0; + enomem_count_ = 0; + notify_in_flight_.store(0); + tx_completions_.store(0); + backpressure_skips_ = 0; + last_itvl_ = 0; // force a fresh LINK baseline log + last_latency_ = 0xffff; + last_tx_phy_ = 0; + last_rx_phy_ = 0; + } + logger_.info("input-report streaming {}", input_subscribed_ ? "ENABLED (0x000e)" : "disabled"); + } +} + +// notify_in_flight_ / tx_completions_ remain as telemetry only (NOTIFY_TX count). +// Effective backpressure is msys1_headroom() — see send_input_report(). + +void Switch2Pro::on_notify_tx(NimBLECharacteristic *characteristic) { + // A notification we queued has been transmitted; free its flow-control slot. + if (characteristic == pro2_input_) { + tx_completions_.fetch_add(1); + last_tx_complete_us_.store(esp_timer_get_time()); + if (notify_in_flight_.load() > 0) + notify_in_flight_.fetch_sub(1); + } +} + +std::string Switch2Pro::pool_stats() { + // Walk every NimBLE mempool. The host mbuf (MSYS) pools are what a notify draws + // from; if their free count trends to 0 (min_free==0), the host ran out of + // buffers → ENOMEM originates host-side. If they stay healthy while we still + // ENOMEM, the stall is downstream at the controller's ACL tx buffers. + std::string s; + struct os_mempool *mp = nullptr; + struct os_mempool_info info; + char line[80]; + while ((mp = os_mempool_info_get_next(mp, &info)) != nullptr) { + if (info.omi_num_blocks <= 1) // skip tiny 1-block control pools — noise + continue; + snprintf(line, sizeof(line), "%s=%d/%d(min%d) ", info.omi_name[0] ? info.omi_name : "?", + info.omi_num_free, info.omi_num_blocks, info.omi_min_free); + s += line; + } + return s; +} + +bool Switch2Pro::msys1_headroom() { + struct os_mempool *mp = nullptr; + struct os_mempool_info info; + while ((mp = os_mempool_info_get_next(mp, &info)) != nullptr) { + if (std::strcmp(info.omi_name, "msys_1") == 0) + return (info.omi_num_blocks - info.omi_num_free) < kMaxOutstandingMbufs; + } + return true; // pool not found (shouldn't happen) — fail open, don't block the stream +} + +void Switch2Pro::poll_conn_state() { + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(active_conn_handle_, &desc) != 0) + return; + uint8_t tx_phy = 0, rx_phy = 0; + ble_gap_read_le_phy(active_conn_handle_, &tx_phy, &rx_phy); + if (desc.conn_itvl == last_itvl_ && desc.conn_latency == last_latency_ && + tx_phy == last_tx_phy_ && rx_phy == last_rx_phy_) + return; + last_itvl_ = desc.conn_itvl; + last_latency_ = desc.conn_latency; + last_tx_phy_ = tx_phy; + last_rx_phy_ = rx_phy; + // PHY: 1 = 1M, 2 = 2M, 3 = coded. Interval in 1.25 ms units, timeout in 10 ms. + logger_.info("LINK CHANGE: itvl={:.2f}ms latency={} timeout={}ms tx_phy={} rx_phy={}", + desc.conn_itvl * 1.25f, desc.conn_latency, desc.supervision_timeout * 10, tx_phy, + rx_phy); +} + +void Switch2Pro::input_stream_loop() { + // Two streaming models (Config::continuous_streaming): + // + // * continuous (default): send one report every connection interval with the + // counter incrementing every time, exactly like a real controller (the + // fresh-pair capture shows a real device streaming 62 Hz at 15 ms). Verified + // stable and lag-free on the C6-class chips. + // * on-change: notify only when the app's button/stick state changed since the + // last delivered report, plus a keepalive every kKeepaliveIntervals. A + // reduced-traffic fallback that partially masks the ESP32-S3 BTDM + // controller's tx-servicing bug (it stops draining tx ~3 s into any + // sustained encrypted stream — see README "Known issues"). + while (!stream_stop_.load()) { + if (!input_subscribed_ || active_conn_handle_ == 0xffff || pro2_input_ == nullptr) { + have_streamed_ = false; // (re)subscribe forces a fresh initial send + idle_intervals_ = 0; + stream_start_us_ = 0; // reset the wedge diagnostics for the next run + wedge_reported_ = false; + send_attempts_ = 0; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + // NOTE: poll_conn_state() is NOT called here — it issues an HCI LE-Read-PHY + // command, and running that at the 62 Hz stream rate floods the HCI path and + // wedges the host's data-tx draining after ~3 s. It runs in the 500 ms + // heartbeat below instead. ble_gap_conn_find() is local (no HCI) so it's cheap. + uint32_t itvl_us = 15000; + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(active_conn_handle_, &desc) == 0 && desc.conn_itvl > 0) + itvl_us = static_cast(desc.conn_itvl) * 1250; // 1.25 ms units -> us + + // --- tx-wedge telemetry --- + const int64_t now_us = esp_timer_get_time(); + if (stream_start_us_ == 0) { // first live tick of this streaming run + stream_start_us_ = hb_last_us_ = now_us; + last_tx_complete_us_.store(now_us); + hb_last_completions_ = tx_completions_.load(); + hb_last_enomem_ = enomem_count_; + } + if (now_us - hb_last_us_ >= 500000) { // 500 ms heartbeat + poll_conn_state(); // interval/PHY-change log — 2 Hz, off the hot path + const uint32_t c = tx_completions_.load(), e = enomem_count_; + const float dt = (now_us - hb_last_us_) / 1e6f; + const int64_t since_tx = now_us - last_tx_complete_us_.load(); + logger_.debug( + "stream@{:.1f}s drain={:.0f}Hz(Δ{}) attempts={} skips={} enomemΔ={} inflight={} " + "since_tx={:.0f}ms itvl={:.1f}ms | {}", + (now_us - stream_start_us_) / 1e6f, (c - hb_last_completions_) / dt, + c - hb_last_completions_, send_attempts_, backpressure_skips_, e - hb_last_enomem_, + notify_in_flight_.load(), since_tx / 1000.0f, itvl_us / 1000.0f, pool_stats()); + hb_last_us_ = now_us; + hb_last_completions_ = c; + hb_last_enomem_ = e; + } + + bool should_send = true; + if (continuous_streaming_) { + // Rate-halving probe: send only every Nth interval (N=1 → every interval). + should_send = (interval_tick_++ % continuous_stream_divisor_) == 0; + } else { + bool changed; + { + std::lock_guard lk(input_mutex_); + changed = !have_streamed_ || input_report_.data() != last_streamed_.data(); + } + if (changed || ++idle_intervals_ >= kKeepaliveIntervals) + should_send = true, idle_intervals_ = 0; + else + should_send = false; + } + + if (should_send && send_input_report() && !continuous_streaming_) { + // Only advance the on-change baseline on an ACTUAL send, so a flow-control + // skip retries the change next interval instead of dropping the input. + std::lock_guard lk(input_mutex_); + last_streamed_ = input_report_; + have_streamed_ = true; + } + std::this_thread::sleep_for(std::chrono::microseconds(itvl_us)); + } +} + +bool Switch2Pro::send_input_report() { + ++send_attempts_; // telemetry: every call the loop wanted to send + // Real backpressure. The old notify_in_flight_/NOTIFY_TX cap is INERT here: + // NOTIFY_TX fires at host->controller handoff, not over-air completion, so the + // counter reads ~1 while mbufs actually pile up in the host tx queue until the + // msys_1 pool hits 0 and every notify ENOMEMs (confirmed on-HW). Gate on the + // pool's true un-drained count instead: only queue another report while the + // backlog is under kMaxOutstandingMbufs. This rate-matches the link (like a real + // controller sending one packet per connection event) and the pool never empties. + if (!msys1_headroom()) { + ++backpressure_skips_; + return false; + } + + // Latest stored app state + the protocol fields the app doesn't manage: byte 0 + // counter, byte 0x0B rumble flag, and the 40-byte IMU motion block (replayed + // from a captured monotonic sequence when the console has enabled IMU). + std::array buf; + { + std::lock_guard lk(input_mutex_); + buf = input_report_.data(); + } + buf[0] = report_counter_; + buf[0x0b] = 0x38; // constant on a real controller (and the known-working emulator) + if (enabled_features_ & switch2::FEATURE_IMU) { + buf[0x0e] = 0x28; // motion data length (40) — always present once IMU is enabled + if (stream_imu_motion_) { + // Replay captured resting-motion frames. NOTE: the sequence loops (128 + // frames ≈ 2 s at 62 Hz), so its embedded timestamps jump backwards at the + // wrap; the known-working emulator streams ALL-ZERO motion instead, which + // the console accepts. Disable stream_imu_motion for zero-motion parity. + const auto &blk = switch2::kMotionSequence[motion_idx_++ % switch2::kMotionSequence.size()]; + std::copy(blk.begin(), blk.end(), buf.begin() + 0x0f); + } // else: motion block stays zeroed, like the known-working emulator + } + + // Low-level notify so the exact rc is visible (esp-nimble-cpp's notify() hides it). + struct os_mbuf *om = ble_hs_mbuf_from_flat(buf.data(), buf.size()); + const bool mbuf_alloc_failed = (om == nullptr); // host MSYS pool exhausted vs downstream + int rc = om ? ble_gatts_notify_custom(active_conn_handle_, pro2_input_->getHandle(), om) + : BLE_HS_ENOMEM; + if (rc == 0) { + ++report_counter_; // +1 per delivered report, matching the real device + notify_in_flight_.fetch_add(1); + } else if (rc == BLE_HS_ENOMEM) { + ++enomem_count_; + if (!wedge_reported_) { // one-shot snapshot at the exact moment the stall begins + wedge_reported_ = true; + const int64_t now = esp_timer_get_time(); + const int64_t since_tx = now - last_tx_complete_us_.load(); + logger_.warn( + "TX WEDGE: first ENOMEM at {:.1f}s after {} completions / {} attempts; " + "source={} inflight={} since_last_tx={:.0f}ms → {}", + stream_start_us_ ? (now - stream_start_us_) / 1e6f : 0.f, tx_completions_.load(), + send_attempts_, mbuf_alloc_failed ? "HOST-mbuf-alloc" : "notify_custom(downstream)", + notify_in_flight_.load(), since_tx / 1000.0f, + since_tx < 50000 ? "completions still recent → console polling, our pool/pacing bug" + : "completions STALLED → tx drain stopped"); + logger_.warn(" pools @wedge: {}", pool_stats()); + } + } + const bool sent = (rc == 0); + + // Per-second stream health at DEBUG: reports delivered (txdone), tx-pool + // deferrals (enomem), the counter, and the buttons on the wire. + static uint32_t dbg_tick = 0; + if ((dbg_tick++ % 66) == 0) + logger_.debug("input stream: inflight={} txdone={} enomem={} ctr=0x{:02x} btn=[{:02x} {:02x} " + "{:02x}] 0x0b={:02x} feat={:02x}", + notify_in_flight_.load(), tx_completions_.load(), enomem_count_, buf[0], buf[2], + buf[3], buf[4], buf[0x0b], enabled_features_); + return sent; +} + +void Switch2Pro::inject_ltk(uint8_t peer_type, const uint8_t *peer_val_le) { + struct ble_store_value_sec sec = {}; + sec.peer_addr.type = peer_type; + std::copy(peer_val_le, peer_val_le + 6, sec.peer_addr.val); + sec.key_size = 16; + sec.ediv = 0; // no SMP key distribution — the console uses the LTK directly + sec.rand_num = 0; + // NimBLE hands ltk[] straight to the controller with no byte-swap, so it must + // be in the same order the console's controller uses: ltk_ (= A1 ^ B1) as + // computed. (The 0x03/0x07 "send pairing info" blob is this value reversed, + // but that is just the on-wire transmission form, not the key order.) + std::copy(ltk_.begin(), ltk_.end(), sec.ltk); + sec.ltk_present = 1; + sec.authenticated = 1; + int rc = ble_store_write_our_sec(&sec); + logger_.info("injected LTK into NimBLE store (rc={}) — ready for LL encryption", rc); +} + +void Switch2Pro::inject_pairing_ltk() { + struct ble_gap_conn_desc desc; + if (active_conn_handle_ == 0xffff || ble_gap_conn_find(active_conn_handle_, &desc) != 0) { + logger_.warn("cannot inject LTK: no active connection"); + return; + } + inject_ltk(desc.peer_id_addr.type, desc.peer_id_addr.val); +} + +void Switch2Pro::save_bond() { + struct ble_gap_conn_desc desc; + if (active_conn_handle_ == 0xffff || ble_gap_conn_find(active_conn_handle_, &desc) != 0) + return; + StoredBond b{}; + b.magic = kBondMagic; + b.peer_type = desc.peer_id_addr.type; + std::copy(std::begin(desc.peer_id_addr.val), std::end(desc.peer_id_addr.val), b.peer_val); + std::copy(ltk_.begin(), ltk_.end(), b.ltk); + std::copy(host_addr_.begin(), host_addr_.end(), b.host_addr); + nvs_handle_t h; + if (nvs_open(kNvsNamespace, NVS_READWRITE, &h) != ESP_OK) { + logger_.error("save_bond: nvs_open failed"); + return; + } + nvs_set_blob(h, kNvsBondKey, &b, sizeof(b)); + nvs_commit(h); + nvs_close(h); + bond_peer_type_ = b.peer_type; + std::copy(std::begin(b.peer_val), std::end(b.peer_val), bond_peer_val_.begin()); + logger_.info("saved bond to NVS (console addr + LTK)"); +} + +bool Switch2Pro::load_bond() { + nvs_handle_t h; + if (nvs_open(kNvsNamespace, NVS_READONLY, &h) != ESP_OK) + return false; + StoredBond b{}; + size_t sz = sizeof(b); + esp_err_t err = nvs_get_blob(h, kNvsBondKey, &b, &sz); + nvs_close(h); + if (err != ESP_OK || sz != sizeof(b) || b.magic != kBondMagic) + return false; + bond_peer_type_ = b.peer_type; + std::copy(std::begin(b.peer_val), std::end(b.peer_val), bond_peer_val_.begin()); + std::copy(std::begin(b.ltk), std::end(b.ltk), ltk_.begin()); + std::copy(std::begin(b.host_addr), std::end(b.host_addr), host_addr_.begin()); + return true; +} + void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, PairingSub sub, const uint8_t *payload, size_t len) { // Pairing responses use byte4=0x10, byte5=0x78, and a payload that begins // with a 0x01 status byte (exact framing from ndeadly's captures). switch (sub) { case PairingSub::EXCHANGE_ADDRESSES: { - if (len >= 6) { - for (size_t i = 0; i < 6; ++i) // console host address, byte-reversed - host_addr_[i] = payload[5 - i]; + // Request data: [0x00][count][addr1 (6, LE wire order)][addr2 (6)...]. addr1 + // is the console's STABLE IDENTITY address. Store it VERBATIM — the real + // controller embeds exactly this in its reconnect/wake advertisement so the + // console recognises the reconnect and grants the fast 5 ms interval. (We run + // bonding=false, so NimBLE can't resolve the console's rotating private + // connection address to its identity; this app-level exchange is where we get + // the stable address.) Previously we byte-reversed payload[0..5], which read + // the 0x00/count prefix as the address — garbage the console never recognises. + if (len >= 8) { + std::copy(payload + 2, payload + 8, host_addr_.begin()); + logger_.info( + "pairing: stored console identity addr {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + host_addr_[5], host_addr_[4], host_addr_[3], host_addr_[2], host_addr_[1], host_addr_[0]); } // Reply: {0x01, 0x04, 0x01} + our BT address. The 0x04/0x01 prefix bytes // are as observed in captures; address byte order to be confirmed on HW. @@ -305,13 +924,16 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P addr[2], addr[3], addr[4], addr[5]}; send_response(via_vibration_command, 0x15, transport, 0x01, 0x10, 0x78, reply.data(), reply.size()); - logger_.info("pairing: exchange addresses -> replied with our address"); + logger_.info("pairing: exchange addresses -> replied with our address {:02x} {:02x} {:02x} " + "{:02x} {:02x} {:02x} (little-endian)", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5]); break; } case PairingSub::EXCHANGE_KEYS: { - if (len >= 16) { + // Request data is [0x00][A1 (16 bytes)] — skip the leading 0x00. + if (len >= 17) { std::array a1{}; - std::copy(payload, payload + 16, a1.begin()); + std::copy(payload + 1, payload + 17, a1.begin()); ltk_ = PairingCrypto::derive_ltk(a1); } // Reply: {0x01} + fixed controller key B1. @@ -323,10 +945,11 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P break; } case PairingSub::CONFIRM_LTK: { + // Request data is [0x00][A2 challenge (16 bytes)] — skip the leading 0x00. std::array b2{}; - if (len >= 16) { + if (len >= 17) { std::array a2{}; - std::copy(payload, payload + 16, a2.begin()); + std::copy(payload + 1, payload + 17, a2.begin()); b2 = PairingCrypto::confirm(ltk_, a2); } // Reply: {0x01} + B2 = AES-128-ECB(rev(LTK), rev(A2)). @@ -343,7 +966,15 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P reply.size()); paired_ = true; logger_.info("pairing: finalised — bonded"); - // TODO(milestone-4): persist {host_addr_, ltk_} to NVS for reconnect/wake. + // Right after finalise the console starts standard BLE link-layer encryption + // using the LTK we just negotiated (there is no SMP key distribution). Inject + // the LTK into NimBLE's security store so the controller can answer the + // console's LTK request; without it encryption fails and the console drops us. + inject_pairing_ltk(); + // Persist {console address, LTK} so we can reconnect/wake after a reboot + // without re-running the pairing exchange. + save_bond(); + reconnect_mode_ = true; break; } default: @@ -366,6 +997,9 @@ void Switch2Pro::handle_command(bool via_vibration_command, Command cmd, uint8_t const uint32_t addr = static_cast(payload[4]) | (static_cast(payload[5]) << 8) | (static_cast(payload[6]) << 16) | (static_cast(payload[7]) << 24); + // Response payload: [len(4 LE)][addr(4 LE)][data]. There is NO status byte — + // the flash contents follow the address directly (the leading 0x01 seen at + // 0x13000 is real flash data, not a status). std::vector reply(8u + read_len, 0); reply[0] = read_len; reply[4] = payload[4]; @@ -373,20 +1007,96 @@ void Switch2Pro::handle_command(bool via_vibration_command, Command cmd, uint8_t reply[6] = payload[6]; reply[7] = payload[7]; simulated_flash_read(addr, read_len, reply.data() + 8); - send_response(via_vibration_command, static_cast(cmd), transport, sub, 0x10, 0x78, - reply.data(), reply.size()); + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, reply.data(), reply.size()); logger_.debug("flash read {} bytes @ 0x{:06x}", read_len, addr); break; } - case Command::FEATURE_SELECT: - if (sub == 0x02 && len >= 1) - feature_mask_ = payload[0]; // set feature mask - send_ack(via_vibration_command, static_cast(cmd), transport, sub); + case Command::UNKNOWN_07: { + // Init handshake: response is the header plus a single zero data byte. + static constexpr std::array d = {0x00}; + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, d.data(), d.size()); + break; + } + case Command::UNKNOWN_16: { + // Init handshake: response is the header plus 24 zero data bytes. + static constexpr std::array d = {}; + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, d.data(), d.size()); + break; + } + case Command::UNKNOWN_11: { + // Late-init handshake. The response is subcommand-specific and each must + // match a real Pro Controller 2 exactly, or the console keeps re-probing and + // never enables input streaming: + // 0x11/0x03 -> a fixed 29-byte blob (looks like report/sensor config). + // 0x11/0x01 -> {0x01,0,0,0}. + // A header-only ACK (or the wrong subcommand's blob) stalls the init. + static constexpr std::array blob03 = { + 0x01, 0x20, 0x03, 0x00, 0x00, 0x0a, 0xe8, 0x1c, 0x3b, 0x79, 0x7d, 0x8b, 0x3a, 0x0a, 0xe8, + 0x9c, 0x42, 0x58, 0xa0, 0x0b, 0x42, 0x0a, 0xe8, 0x9c, 0x41, 0x58, 0xa0, 0x0b, 0x41}; + static constexpr std::array blob01 = {0x01, 0x00, 0x00, 0x00}; + if (sub == 0x01) + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, blob01.data(), blob01.size()); + else + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, blob03.data(), blob03.size()); break; + } + case Command::UNKNOWN_18: { + // Late-init probe. 0x18/0x01 expects a fixed 8-byte device blob; a header-only + // ACK leaves the console unsatisfied and it keeps probing instead of + // activating input. + if (sub == 0x01) { + static constexpr std::array d = {0x00, 0x00, 0x40, 0xf0, 0x00, 0x00, 0x60, 0x00}; + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, d.data(), d.size()); + } else { + send_ack(via_vibration_command, static_cast(cmd), transport, sub); + } + break; + } + case Command::FEATURE_SELECT: { + // Track which features the console enables so our input report can reflect + // them (see notify_input_report). 0x02 = set mask, 0x04 = enable (within the + // mask), 0x05 = disable. The console typically enables mask 0x2f + // (buttons+sticks+IMU+rumble). + if (len >= 1) { + if (sub == 0x02) { + feature_mask_ = payload[0]; + // On RECONNECT the console only sets the mask (0x0c/02) and never sends + // 0x0c/04 (enable) — it expects the controller to have persisted its + // enabled features. So treat set-mask as enabling those features; on a + // fresh pair the 0x0c/04 that follows is then just idempotent. + enabled_features_ = payload[0]; + } else if (sub == 0x04) + enabled_features_ |= static_cast(payload[0] & feature_mask_); + else if (sub == 0x05) + enabled_features_ &= static_cast(~payload[0]); + } + // Response is the header plus 4 zero data bytes (both 0x0c/0x02 and 0x0c/0x04). + static constexpr std::array d = {}; + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, d.data(), d.size()); + break; + } case Command::FIRMWARE_INFO: { - // Captured reply for 0x10/0x01. - static constexpr std::array fw = {0x01, 0x00, 0x0e, 0x01, 0x0c, 0x00, - 0x00, 0x00, 0xff, 0xff, 0xff, 0xff}; + // 0x10/0x01 response: [fw ver major.minor.micro (3)][controller type (1)] + // [BT patch ver (3)][pad][DSP ver (3, updated Pro only)]. Byte 3 is the + // controller type: 0x02 = Pro Controller (the doc's example uses 0x01 = + // JoyCon (R), which must NOT be used here — the console cross-checks this + // against the VID/PID and GATT and rejects a controller whose firmware type + // disagrees with the rest of its identity). Bytes 8-10 are the DSP (audio) + // firmware version: a real un-updated controller reports ff ff ff (no DSP), + // but since we expose the headset-audio characteristics we report a valid + // DSP version so the identity is consistent (updated firmware). Bytes match + // the known-working zhantss emulator (fw 2.1.4 | Pro | BT 12.0.0 | pad | + // DSP 2.3.0) — a controller identity the console demonstrably accepts for + // sustained streaming, and current enough not to trigger the update path. + static constexpr std::array fw = {0x02, 0x01, 0x04, 0x02, 0x0c, 0x00, + 0x00, 0x00, 0x00, 0x02, 0x03, 0x00}; send_response(via_vibration_command, static_cast(cmd), transport, sub, 0x10, 0x78, fw.data(), fw.size()); break; @@ -396,11 +1106,21 @@ void Switch2Pro::handle_command(bool via_vibration_command, Command cmd, uint8_t // TODO(hw): confirm the exact bytes the console needs to skip the prompt. send_ack(via_vibration_command, static_cast(cmd), transport, sub); break; + case Command::NFC: + // Command 0x01 (NFC). During init the console probes 0x01/0x0c and expects a + // fixed 4-byte reply; other subcommands are ACKed for now. + if (sub == 0x0c) { + static constexpr std::array d = {0x61, 0x12, 0x50, 0x0d}; + send_response(via_vibration_command, static_cast(cmd), transport, sub, RSP_BYTE4_BT, + RSP_BYTE5_BT, d.data(), d.size()); + } else { + send_ack(via_vibration_command, static_cast(cmd), transport, sub); + } + break; case Command::INIT: case Command::PLAYER_LEDS: case Command::VIBRATION: case Command::BATTERY: - case Command::NFC: default: // Acknowledge so the console's init state machine advances. Command-specific // payloads (battery level, etc.) are refined in later work. diff --git a/components/switch2_pro/tools/patch_nimble_5ms.py b/components/switch2_pro/tools/patch_nimble_5ms.py index 47eb729691..c2e50bc096 100644 --- a/components/switch2_pro/tools/patch_nimble_5ms.py +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -1,24 +1,39 @@ #!/usr/bin/env python3 -"""Patch the prebuilt ESP-IDF NimBLE controller library to accept a sub-spec -5 ms BLE connection interval, which the Nintendo Switch 2 console requires of -its controllers. - -The check that rejects intervals below 7.5 ms (6 units of 1.25 ms) is compiled -into `ble_ll_conn.c.o` inside the closed `libble_app.a` shipped with ESP-IDF for -the RISC-V targets. This flips the immediate `-6` to `-4` (5 ms), i.e. - - addi a5, a4, -6 (93 07 a7 ff) -> addi a5, a4, -4 (93 07 c7 ff) +"""Patch the prebuilt ESP-IDF BLE controller library to accept a sub-spec 5 ms +connection interval, which the Nintendo Switch 2 console requires of its +controllers. + +BLE's minimum connection interval is 7.5 ms (6 units of 1.25 ms). The console +drives its controllers at 5 ms (4 units), so a stock controller rejects it. The +7.5 ms floor is compiled into the closed controller library ESP-IDF ships, and +differs by chip family: + + * RISC-V NimBLE controller (esp32c6/c61/c2/h2) — `libble_app.a`, object + `ble_ll_conn.c.o`. The floor is an `addi a5, a4, -6`; flip the immediate to + -4: 93 07 a7 ff -> 93 07 c7 ff. + + * BTDM / RivieraWaves controller (esp32s3 Xtensa, esp32c3 RISC-V) — + `libbtdm_app.a` (and the `_flash` variant), object `llc_con_upd.o`, function + `r_llc_con_upd_param_in_range`. The floor is a compare of the requested + min-interval against 6: + S3 (Xtensa): bltui a4, 6 (b6 64 01) -> bltui a4, 4 (b6 44 01) + C3 (RISC-V): li a6,5;bgeu a6,a2 (15 48) -> li a6,3;bgeu a6,a2 (0d 48) + Both give a new floor of 4 units = 5 ms. This is the peripheral-side + validator the console's LL_CONNECTION_PARAM_REQ / _UPDATE_IND path runs + through (verified: its only caller is the RivieraWaves ip_funcs jump table, + and its siblings are ll_connection_param_req_handler / + ll_connection_update_ind_handler). WARNING: this modifies files inside your global $IDF_PATH install, affecting -every project that uses that IDF. A `.original` backup is written next to the -patched archive; `--restore` puts it back. Only RISC-V targets with the open -NimBLE controller (esp32c6/c61/c2/h2) are supported — S3/C3 use a different -closed controller and are not handled here. - -Approach adapted from the MIT-licensed zhantss/ESP32-BLE5-NSController-Emulator; -the reverse-engineered requirement is documented in ndeadly/switch2_controller_research. -This script ships no Espressif or Nintendo binaries — it only edits the archive -already present in the user's local ESP-IDF. +every project that uses that IDF. A `.original` backup is written next to each +patched archive; `--restore` puts them back. + +Approach adapted from the MIT-licensed zhantss/ESP32-BLE5-NSController-Emulator +(RISC-V/NimBLE); the S3/C3 BTDM equivalent was reverse-engineered here from the +same reject-below-6 semantics. The reverse-engineered requirement is documented +in ndeadly/switch2_controller_research. This script ships no Espressif or +Nintendo binaries — it only edits the archives already present in the user's +local ESP-IDF. """ import argparse @@ -28,96 +43,191 @@ import sys import tempfile -OBJECT = "ble_ll_conn.c.o" -OLD = bytes([0x93, 0x07, 0xA7, 0xFF]) # min interval 6 units (7.5 ms) -NEW = bytes([0x93, 0x07, 0xC7, 0xFF]) # min interval 4 units (5 ms) +# Per-target patch spec. `arch` selects the toolchain archiver/objdump (the +# archives are GNU-format; macOS BSD `ar` cannot read them). `archives` is a +# list because the BTDM family ships two variants (IRAM + flash-only, chosen by +# CONFIG_BT_CTRL_RUN_IN_FLASH_ONLY) — we patch whichever are present. `old`/`new` +# are the byte patterns inside `object`; `disasm_old`/`disasm_new` are the +# human-readable instruction each corresponds to (used by smoke_test_5ms.py). +TARGETS = { + # --- RISC-V NimBLE controller: libble_app.a, ble_ll_conn.c.o --- + "esp32c6": { + "arch": "riscv", + "archives": ["components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c6/libble_app.a"], + "object": "ble_ll_conn.c.o", + "old": bytes([0x93, 0x07, 0xA7, 0xFF]), # addi a5,a4,-6 (min 6 units / 7.5 ms) + "new": bytes([0x93, 0x07, 0xC7, 0xFF]), # addi a5,a4,-4 (min 4 units / 5 ms) + "disasm_old": r"addi\s+a5,a4,-6", + "disasm_new": r"addi\s+a5,a4,-4", + }, + "esp32c61": { + "arch": "riscv", + "archives": ["components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c61/libble_app.a"], + "object": "ble_ll_conn.c.o", + "old": bytes([0x93, 0x07, 0xA7, 0xFF]), + "new": bytes([0x93, 0x07, 0xC7, 0xFF]), + "disasm_old": r"addi\s+a5,a4,-6", + "disasm_new": r"addi\s+a5,a4,-4", + }, + "esp32c2": { + "arch": "riscv", + "archives": ["components/bt/controller/lib_esp32c2/esp32c2-bt-lib/libble_app.a"], + "object": "ble_ll_conn.c.o", + "old": bytes([0x93, 0x07, 0xA7, 0xFF]), + "new": bytes([0x93, 0x07, 0xC7, 0xFF]), + "disasm_old": r"addi\s+a5,a4,-6", + "disasm_new": r"addi\s+a5,a4,-4", + }, + "esp32h2": { + "arch": "riscv", + "archives": ["components/bt/controller/lib_esp32h2/esp32h2-bt-lib/libble_app.a"], + "object": "ble_ll_conn.c.o", + "old": bytes([0x93, 0x07, 0xA7, 0xFF]), + "new": bytes([0x93, 0x07, 0xC7, 0xFF]), + "disasm_old": r"addi\s+a5,a4,-6", + "disasm_new": r"addi\s+a5,a4,-4", + }, + # --- BTDM / RivieraWaves controller: libbtdm_app.a[+_flash], llc_con_upd.o --- + "esp32s3": { + "arch": "xtensa-esp32s3", + "archives": [ + "components/bt/controller/lib_esp32c3_family/esp32s3/libbtdm_app.a", + "components/bt/controller/lib_esp32c3_family/esp32s3/libbtdm_app_flash.a", + ], + "object": "llc_con_upd.o", + "old": bytes([0xB6, 0x64, 0x01]), # bltui a4,6 (reject min < 6 / 7.5 ms) + "new": bytes([0xB6, 0x44, 0x01]), # bltui a4,4 (reject min < 4 / 5 ms) + "disasm_old": r"bltui\s+a4, ?6,", + "disasm_new": r"bltui\s+a4, ?4,", + }, + "esp32c3": { + "arch": "riscv", + "archives": [ + "components/bt/controller/lib_esp32c3_family/esp32c3/libbtdm_app.a", + "components/bt/controller/lib_esp32c3_family/esp32c3/libbtdm_app_flash.a", + ], + "object": "llc_con_upd.o", + "old": bytes([0x15, 0x48]), # c.li a6,5; bgeu a6,a2 -> reject min <= 5 (floor 6) + "new": bytes([0x0D, 0x48]), # c.li a6,3; bgeu a6,a2 -> reject min <= 3 (floor 4) + "disasm_old": r"li\s+a6,5", + "disasm_new": r"li\s+a6,3", + }, +} -def resolve_ar(explicit: str | None) -> str: - """The controller archives are GNU-format (long-name symbol/string tables). - macOS's BSD `ar` cannot extract them, so prefer the RISC-V toolchain's GNU - `ar` (on PATH after the ESP-IDF export script), then llvm-ar, then `ar`.""" +def resolve_tool(kind: str, arch: str, explicit: str | None) -> str: + """Resolve the GNU `ar`/`objdump` for the target arch. The controller + archives are GNU-format (long-name symbol/string tables); macOS's BSD `ar` + cannot extract them, so prefer the ESP toolchain's GNU tools (on PATH after + the ESP-IDF export script), then llvm-*, then the bare tool.""" if explicit: return explicit - for cand in ("riscv32-esp-elf-ar", "llvm-ar"): + prefixes = { + "riscv": ["riscv32-esp-elf-"], + "xtensa-esp32s3": ["xtensa-esp32s3-elf-"], + }.get(arch, []) + cands = [p + kind for p in prefixes] + [f"llvm-{kind}", kind] + for cand in cands: if shutil.which(cand): return cand - return "ar" - -# Target -> relative path of libble_app.a under $IDF_PATH. -LIBS = { - "esp32c6": "components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c6/libble_app.a", - "esp32c61": "components/bt/controller/lib_esp32c6/esp32c6-bt-lib/esp32c61/libble_app.a", - "esp32c2": "components/bt/controller/lib_esp32c2/esp32c2-bt-lib/libble_app.a", - "esp32h2": "components/bt/controller/lib_esp32h2/esp32h2-bt-lib/libble_app.a", -} + return kind + +def spec_for(target: str) -> dict: + spec = TARGETS.get(target) + if spec is None: + sys.exit(f"unsupported target '{target}'; supported: {', '.join(TARGETS)}") + return spec -def lib_path(idf_path: str, target: str) -> str: - rel = LIBS.get(target) - if rel is None: - sys.exit(f"unsupported target '{target}'; supported: {', '.join(LIBS)}") - path = os.path.join(idf_path, rel) - if not os.path.isfile(path): - sys.exit(f"library not found: {path}") - return path +def archive_paths(idf_path: str, spec: dict) -> list[str]: + """Absolute paths of the target's archives that actually exist on disk.""" + paths = [] + for rel in spec["archives"]: + p = os.path.join(idf_path, rel) + if os.path.isfile(p): + paths.append(p) + if not paths: + sys.exit(f"no controller archive found under {idf_path} for this target:\n " + + "\n ".join(spec["archives"])) + return paths -def read_object(ar: str, lib: str) -> bytes: + +def read_object(ar: str, lib: str, obj: str) -> bytes: with tempfile.TemporaryDirectory() as tmp: - subprocess.run([ar, "x", lib, OBJECT], cwd=tmp, check=True) - with open(os.path.join(tmp, OBJECT), "rb") as f: + subprocess.run([ar, "x", lib, obj], cwd=tmp, check=True) + with open(os.path.join(tmp, obj), "rb") as f: return f.read() -def write_object(ar: str, lib: str, data: bytes) -> None: +def write_object(ar: str, lib: str, obj: str, data: bytes) -> None: with tempfile.TemporaryDirectory() as tmp: - obj = os.path.join(tmp, OBJECT) - with open(obj, "wb") as f: + path = os.path.join(tmp, obj) + with open(path, "wb") as f: f.write(data) - subprocess.run([ar, "r", lib, obj], cwd=os.path.dirname(obj) or ".", check=True) + subprocess.run([ar, "r", lib, path], cwd=os.path.dirname(path) or ".", check=True) def main() -> int: - ap = argparse.ArgumentParser(description=__doc__) + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--idf-path", default=os.environ.get("IDF_PATH"), help="ESP-IDF root") - ap.add_argument("--target", required=True, help="esp32c6 / esp32c61 / esp32c2 / esp32h2") + ap.add_argument("--target", required=True, help="/ ".join(TARGETS)) ap.add_argument("--verify-only", action="store_true", help="report state, change nothing") - ap.add_argument("--restore", action="store_true", help="restore the .original backup") + ap.add_argument("--restore", action="store_true", help="restore the .original backups") ap.add_argument("--ar", default=None, - help="archiver to use (default: riscv32-esp-elf-ar / llvm-ar / ar). " + help="archiver to use (default: the ESP toolchain GNU ar for the target). " "macOS BSD ar cannot read these GNU-format archives.") args = ap.parse_args() if not args.idf_path: sys.exit("set --idf-path or the IDF_PATH environment variable") - ar = resolve_ar(args.ar) - lib = lib_path(args.idf_path, args.target) - backup = lib + ".original" + spec = spec_for(args.target) + ar = resolve_tool("ar", spec["arch"], args.ar) + libs = archive_paths(args.idf_path, spec) + obj, old, new = spec["object"], spec["old"], spec["new"] if args.restore: - if not os.path.isfile(backup): - sys.exit(f"no backup to restore: {backup}") - shutil.copy2(backup, lib) - print(f"restored {lib} from backup") + n = 0 + for lib in libs: + backup = lib + ".original" + if os.path.isfile(backup): + shutil.copy2(backup, lib) + print(f"restored {lib}") + n += 1 + if n == 0: + sys.exit("no .original backups found to restore") return 0 - data = read_object(ar, lib) - n_old, n_new = data.count(OLD), data.count(NEW) - if args.verify_only: - print(f"{OBJECT} (via {ar}): unpatched-pattern={n_old} patched-pattern={n_new}") - return 0 - if n_new > 0 and n_old == 0: - print("already patched; nothing to do") - return 0 - if n_old == 0: - sys.exit("expected byte pattern not found — IDF version may differ; not patching") - - if not os.path.isfile(backup): - shutil.copy2(lib, backup) - print(f"backed up -> {backup}") - write_object(ar, lib, data.replace(OLD, NEW)) - print(f"patched {n_old} occurrence(s); {lib} now accepts a 5 ms connection interval") + 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) + if args.verify_only: + state = "PATCHED (5 ms)" if (n_new and not n_old) else \ + "unpatched (7.5 ms)" if (n_old and not n_new) else "UNKNOWN" + print(f"{tag}: {obj} unpatched-pattern={n_old} patched-pattern={n_new} -> {state}") + continue + if n_new > 0 and n_old == 0: + print(f"{tag}: already patched; nothing to do") + continue + if n_old == 0: + sys.exit(f"{tag}: expected byte pattern not found in {obj} — IDF version may " + f"differ; not patching") + if n_old > 1: + sys.exit(f"{tag}: pattern appears {n_old}x in {obj} (expected 1) — refusing to " + f"patch ambiguously") + backup = lib + ".original" + if not os.path.isfile(backup): + shutil.copy2(lib, backup) + print(f"{tag}: backed up -> {os.path.basename(backup)}") + write_object(ar, lib, obj, data.replace(old, new)) + print(f"{tag}: patched {n_old} occurrence(s) — now accepts a 5 ms connection interval") + any_patched_now = True + + if not args.verify_only and any_patched_now: + print(f"done ({args.target}). Run tools/smoke_test_5ms.py --target {args.target} to verify.") return 0 diff --git a/components/switch2_pro/tools/smoke_test_5ms.py b/components/switch2_pro/tools/smoke_test_5ms.py new file mode 100644 index 0000000000..2c7edf4ff1 --- /dev/null +++ b/components/switch2_pro/tools/smoke_test_5ms.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Smoke-test the 5 ms BLE connection-interval patch — no hardware required. + +For the given target it locates the controller archive(s) in $IDF_PATH, extracts +the object that holds the min-interval floor, disassembles the relevant function +with the target's GNU objdump, and reports the *actual instruction* that enforces +the floor: + + unpatched -> the 7.5 ms floor instruction (e.g. `bltui a4, 6`) => 5 ms REJECTED + patched -> the 5 ms floor instruction (e.g. `bltui a4, 4`) => 5 ms ACCEPTED + +This proves the patch does what it claims at the disassembly level, independent +of the byte-pattern match the patcher uses. Exit code 0 = patched (5 ms capable), +1 = unpatched, 2 = indeterminate / error. + +It reuses the per-target spec from patch_nimble_5ms.py (same directory), so the +two tools can never drift. Run it before and after the patcher to see the floor +change from 6 (7.5 ms) to 4 (5 ms). + +On-hardware confirmation (the second half of "does the console accept it"): +flash a BLE peripheral built with the patched IDF, then on the GAP connect / +connection-update event log the negotiated interval. With esp-nimble-cpp: + + void onConnect(NimBLEConnInfo& info) { + ESP_LOGI("smoke", "conn interval = %u units (%.2f ms)", + info.getConnInterval(), info.getConnInterval() * 1.25f); + } + +Point a central that drives a fast interval at it (the Switch 2, or a BlueZ host +with its own min-interval floor lowered). A patched controller logs 4 units +(5.00 ms); a stock one never goes below 6 (7.50 ms) or drops the link. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from patch_nimble_5ms import TARGETS, archive_paths, resolve_tool, spec_for # noqa: E402 + +# Function that contains the floor check, per stack. +FLOOR_FUNC = { + "nimble": None, # NimBLE object has no single obvious symbol; scan the whole object + "btdm": "r_llc_con_upd_param_in_range", +} + + +def stack_of(spec: dict) -> str: + return "btdm" if spec["object"] == "llc_con_upd.o" else "nimble" + + +def disassemble(objdump: str, obj_path: str, func: str | None) -> str: + out = subprocess.run([objdump, "-d", obj_path], capture_output=True, text=True).stdout + if not func: + return out + # keep only the named function body (up to the next symbol header) + lines, keep, buf = out.splitlines(), False, [] + for ln in lines: + if re.search(rf"<{re.escape(func)}>:", ln): + keep = True + elif keep and re.match(r"^[0-9a-f]{8} <", ln): + break + if keep: + buf.append(ln) + return "\n".join(buf) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--idf-path", default=os.environ.get("IDF_PATH"), help="ESP-IDF root") + ap.add_argument("--target", required=True, help="/ ".join(TARGETS)) + ap.add_argument("--objdump", default=None, help="override the objdump binary") + args = ap.parse_args() + if not args.idf_path: + sys.exit("set --idf-path or the IDF_PATH environment variable") + + spec = spec_for(args.target) + ar = resolve_tool("ar", spec["arch"], None) + objdump = resolve_tool("objdump", spec["arch"], args.objdump) + libs = archive_paths(args.idf_path, spec) + obj = spec["object"] + func = FLOOR_FUNC[stack_of(spec)] + re_old, re_new = re.compile(spec["disasm_old"]), re.compile(spec["disasm_new"]) + + print(f"target {args.target} ({spec['arch']}, {stack_of(spec)} controller)") + print(f"objdump: {objdump} object: {obj}" + + (f" function: {func}" if func else "")) + print("-" * 68) + + verdicts = [] + for lib in libs: + tag = os.path.basename(lib) + with tempfile.TemporaryDirectory() as tmp: + subprocess.run([ar, "x", lib, obj], cwd=tmp, check=True) + disasm = disassemble(objdump, os.path.join(tmp, obj), func) + old_lines = [l.strip() for l in disasm.splitlines() if re_old.search(l)] + new_lines = [l.strip() for l in disasm.splitlines() if re_new.search(l)] + if new_lines and not old_lines: + verdict, floor = "PATCHED (5 ms ACCEPTED)", new_lines[0] + elif old_lines and not new_lines: + verdict, floor = "unpatched (5 ms REJECTED)", old_lines[0] + else: + verdict, floor = "INDETERMINATE", (old_lines + new_lines or [""])[0] + verdicts.append(verdict) + print(f" {tag}") + print(f" floor instruction : {floor}") + print(f" verdict : {verdict}") + + print("-" * 68) + if all(v.startswith("PATCHED") for v in verdicts): + print(f"PASS — {args.target} controller accepts a 5 ms connection interval.") + return 0 + if all(v.startswith("unpatched") for v in verdicts): + print(f"unpatched — run tools/patch_nimble_5ms.py --target {args.target} to enable 5 ms.") + return 1 + print("INDETERMINATE — archives disagree or the floor instruction moved (IDF version?).") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/doc/Doxyfile b/doc/Doxyfile index 5ac85abd7d..b455c36324 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -185,6 +185,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/state_machine/example/main/hfsm_example.cpp \ $(PROJECT_PATH)/components/stream_frame/example/main/stream_frame_example.cpp \ $(PROJECT_PATH)/components/sx126x/example/main/sx126x_example.cpp \ + $(PROJECT_PATH)/components/switch2_pro/example/main/switch2_pro_example.cpp \ $(PROJECT_PATH)/components/tabulate/example/main/tabulate_example.cpp \ $(PROJECT_PATH)/components/t-deck/example/main/t_deck_example.cpp \ $(PROJECT_PATH)/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp \ @@ -446,6 +447,9 @@ INPUT = \ $(PROJECT_PATH)/components/state_machine/include/state_machine.hpp \ $(PROJECT_PATH)/components/stream_frame/include/stream_frame.hpp \ $(PROJECT_PATH)/components/sx126x/include/sx126x.hpp \ + $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro.hpp \ + $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro_report.hpp \ + $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro_protocol.hpp \ $(PROJECT_PATH)/components/t-deck/include/t-deck.hpp \ $(PROJECT_PATH)/components/t-dongle-s3/include/t-dongle-s3.hpp \ $(PROJECT_PATH)/components/t_keyboard/include/t_keyboard.hpp \ diff --git a/doc/en/ble/index.rst b/doc/en/ble/index.rst index a2afaab0b4..ebb6039538 100644 --- a/doc/en/ble/index.rst +++ b/doc/en/ble/index.rst @@ -13,6 +13,8 @@ BLE APIs gfps_service_example hid_service hid_service_example + switch2_pro + switch2_pro_example These components provide some interfaces for implementing a BLE peripheral - namely a BLE GATT Server hosting various services. diff --git a/doc/en/ble/switch2_pro.rst b/doc/en/ble/switch2_pro.rst new file mode 100644 index 0000000000..c67bf93a9c --- /dev/null +++ b/doc/en/ble/switch2_pro.rst @@ -0,0 +1,62 @@ +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 :cpp:class:`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-C6** (and the other open-NimBLE-controller chips: + C61/C2/H2), where pairing, input streaming, reconnect, and wake-from-sleep + all work against a real console. The **ESP32-S3 builds and pairs but does not + yet stream input reliably** — its closed BTDM BLE controller degrades the + link under sustained encrypted notifications. See the component README's + "Known issues" for details; S3/C3 support is a work in progress. + +.. 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. + +.. code-block:: cpp + + #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 +---------------------------- + +A real controller *reconnects* at a 5 ms connection interval, below the 7.5 ms +Bluetooth spec minimum, chosen by the console in its ``CONNECT_IND``. Accepting +it (required for reconnect and wake-from-sleep) needs the opt-in Kconfig option +``SWITCH2_PRO_PATCH_NIMBLE_5MS``, which binary-patches the prebuilt BLE +controller library in your global ``$IDF_PATH`` install. It is **off by +default** (it mutates your ESP-IDF install); fresh pairing and first-session +input work without it. See the component README and ``tools/patch_nimble_5ms.py``. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + switch2_pro_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/switch2_pro.inc +.. include-build-file:: inc/switch2_pro_report.inc +.. include-build-file:: inc/switch2_pro_protocol.inc diff --git a/doc/en/ble/switch2_pro_example.md b/doc/en/ble/switch2_pro_example.md new file mode 100644 index 0000000000..3d95c09c5b --- /dev/null +++ b/doc/en/ble/switch2_pro_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/switch2_pro/example/README.md +``` From 8e31b14299e0a43804ffd867651486c0662daec1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 12:09:12 -0500 Subject: [PATCH 14/18] fix(switch2_pro): address PR review (Copilot + cppcheck) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- components/switch2_pro/idf_component.yml | 6 ++++++ .../switch2_pro/include/switch2_pro.hpp | 16 ++++++++------ .../switch2_pro/include/switch2_pro_flash.hpp | 14 ++++++------- components/switch2_pro/src/switch2_pro.cpp | 9 ++++++-- .../switch2_pro/src/switch2_pro_pairing.cpp | 21 ++++++++++++++++--- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/components/switch2_pro/idf_component.yml b/components/switch2_pro/idf_component.yml index 6158567214..06e6580fff 100644 --- a/components/switch2_pro/idf_component.yml +++ b/components/switch2_pro/idf_component.yml @@ -20,6 +20,12 @@ tags: dependencies: idf: version: '>=5.5' + # NOTE: this component needs NimBLEServer::registerServicesFirst() (to place the + # Nintendo services at the low attribute handles the console addresses by fixed + # handle). That API is not in a released esp-nimble-cpp yet — it is upstream at + # h2zero/esp-nimble-cpp#443. Until it ships in a tagged release, build against + # the pinned esp-cpp/esp-nimble-cpp submodule; bump the version floor below to + # the release that includes it once available. h2zero/esp-nimble-cpp: version: '>=2.3.0' espp/ble_gatt_server: '>=1.0' diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index 0353bab38f..6ce1649368 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -31,11 +31,14 @@ namespace espp { /// data, and answers the console's command channel — including the reverse- /// engineered pairing handshake so a real console will bond with it. /// -/// Milestone status: GATT + pairing skeleton. Advertising, the custom service -/// tree, and the 0x15 pairing handshake are wired; the full init/calibration -/// sequence and input-report streaming are staged in follow-up work (see -/// DESIGN.md). Emulating the console's 5 ms connection interval additionally -/// requires the opt-in NimBLE patch (tools/patch_nimble_5ms.py). +/// Status: works on ESP32-C6 (and the other open-NimBLE-controller chips) — +/// 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 ESP32-S3 builds and pairs but does not yet +/// stream input reliably (see the component README). Reconnect and wake-from-sleep +/// use the console's sub-spec 5 ms interval, which requires the opt-in NimBLE patch +/// (tools/patch_nimble_5ms.py). /// /// \section switch2_pro_ex1 Example /// \snippet switch2_pro_example.cpp switch2_pro example @@ -168,7 +171,8 @@ class Switch2Pro : public BaseComponent { /// the response characteristic matching the request source. void send_response(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub, uint8_t byte4, uint8_t byte5, const uint8_t *payload, size_t payload_len); - /// Header-only ACK (byte4=0x00, byte5=0xf8, payload = {0x01,0,0,0}). + /// Header-only BLE ACK (byte4=0x10, byte5=0x78, no payload) — matches a real + /// Pro Controller 2's init-sequence ACKs. void send_ack(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub); /// Inject the current LTK (ltk_) into NimBLE's security store for `peer` so the diff --git a/components/switch2_pro/include/switch2_pro_flash.hpp b/components/switch2_pro/include/switch2_pro_flash.hpp index 4e280357ad..88880874aa 100644 --- a/components/switch2_pro/include/switch2_pro_flash.hpp +++ b/components/switch2_pro/include/switch2_pro_flash.hpp @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include #include +#include /// @file switch2_pro_flash.hpp /// @brief Simulated controller flash the console reads during init (command @@ -61,14 +63,10 @@ inline size_t simulated_flash_read(uint32_t addr, size_t len, uint8_t *out) { }; for (size_t i = 0; i < len; ++i) { const uint32_t a = addr + static_cast(i); - uint8_t value = 0xff; // erased flash default - for (const auto &blk : kBlocks) { - if (a >= blk.addr && a < blk.addr + blk.len) { - value = blk.data[a - blk.addr]; - break; - } - } - out[i] = value; + const auto *blk = std::find_if(std::begin(kBlocks), std::end(kBlocks), [a](const Block &b) { + return a >= b.addr && a < b.addr + b.len; + }); + out[i] = (blk != std::end(kBlocks)) ? blk->data[a - blk->addr] : 0xff; // 0xff = erased flash } return len; } diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index e04c71dfe6..b629e3e00d 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -158,7 +158,8 @@ bool Switch2Pro::init() { cfg.prio = 5; cfg.pin_to_core = 0; cfg.thread_name = "s2p_stream"; - esp_pthread_set_cfg(&cfg); + if (esp_pthread_set_cfg(&cfg) != ESP_OK) + logger_.warn("esp_pthread_set_cfg failed; streaming thread will use default stack/prio/core"); input_stream_thread_ = std::thread(&Switch2Pro::input_stream_loop, this); return true; } @@ -227,7 +228,11 @@ void Switch2Pro::configure_callbacks() { (now - stream_start_us_) / 1e6f, tx_completions_.load(), enomem_count_, wedge_reported_, (now - last_tx_complete_us_.load()) / 1000.0f, pool_stats()); } - paired_ = false; + // NOTE: paired_ is intentionally NOT cleared here. is_paired() reports whether + // the pairing handshake has completed / a bond exists — which survives a + // disconnect (the bond is persisted in NVS and a bonded reconnect does not + // re-run the 0x15 handshake). Use is_connected()/is_input_streaming() for + // live-session state. input_subscribed_ = false; active_conn_handle_ = 0xffff; // so the wake timer knows we're disconnected advertise(); diff --git a/components/switch2_pro/src/switch2_pro_pairing.cpp b/components/switch2_pro/src/switch2_pro_pairing.cpp index 287b081bfa..a5d386d638 100644 --- a/components/switch2_pro/src/switch2_pro_pairing.cpp +++ b/components/switch2_pro/src/switch2_pro_pairing.cpp @@ -2,6 +2,12 @@ #include +#include "esp_log.h" + +namespace { +constexpr const char *kPairingTag = "switch2::pairing"; +} // namespace + namespace espp::switch2 { namespace { @@ -21,7 +27,10 @@ std::array PairingCrypto::confirm(const std::array < // AES-128-ECB single-block encrypt via the PSA Crypto API (the supported // interface in mbedTLS 4.x / IDF 6; the classic mbedtls_aes_* API is private). - psa_crypto_init(); + if (psa_crypto_init() != PSA_SUCCESS) { + ESP_LOGE(kPairingTag, "psa_crypto_init failed"); + return out; // zeros — self_test() flags it, live pairing fails cleanly + } psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); @@ -34,10 +43,16 @@ std::array PairingCrypto::confirm(const std::array < return out; // zeros on failure; self_test() will flag it } size_t out_len = 0; - psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, block.data(), block.size(), out.data(), - out.size(), &out_len); + psa_status_t st = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, block.data(), block.size(), + out.data(), out.size(), &out_len); psa_destroy_key(key_id); psa_reset_key_attributes(&attr); + if (st != PSA_SUCCESS || out_len != out.size()) { + ESP_LOGE(kPairingTag, "psa_cipher_encrypt failed (status=%d, out_len=%u)", static_cast(st), + static_cast(out_len)); + out.fill(0); // don't return a partially-written block + return out; + } return out; } From b1c63ee7f0cc9cb7fe7410e20ea20778d2629734 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 14:34:00 -0500 Subject: [PATCH 15/18] fix(switch2_pro): address 2nd PR review round (Copilot) - 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 --- .github/workflows/build.yml | 4 +- .github/workflows/upload_components.yml | 1 + .../switch2_pro/include/switch2_pro.hpp | 19 ++++- components/switch2_pro/src/switch2_pro.cpp | 79 +++++++++++++++---- doc/Doxyfile | 6 +- 5 files changed, 83 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a3058394c..9a79af0ed6 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -300,12 +300,12 @@ jobs: target: esp32 - path: 'components/stream_frame/example' target: esp32 - - path: 'components/sx126x/example' - target: esp32s3 - path: 'components/switch2_pro/example' target: esp32c6 - path: 'components/switch2_pro/example' target: esp32s3 + - path: 'components/sx126x/example' + target: esp32s3 - path: 'components/t-deck/example' target: esp32s3 - path: 'components/t-dongle-s3/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index 68aabe86ca..080979032b 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -164,6 +164,7 @@ jobs: components/st25dv components/st7123touch components/state_machine + components/switch2_pro components/sx126x components/t_keyboard components/t-deck diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index 6ce1649368..985ff7ea94 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -248,10 +248,17 @@ class Switch2Pro : public BaseComponent { // Pairing state. bool paired_{false}; + /// Highest completed 0x15 pairing step this connection: 0=none, 1=exchange + /// addresses, 2=exchange keys, 3=confirm LTK. FINALISE (step 4) is only accepted + /// when this is 3, so an out-of-order/malformed peer cannot persist a bad bond. + /// Reset to 0 on each new connection. + uint8_t pairing_stage_{0}; bool reconnect_mode_{false}; ///< booted with a stored bond (reconnect, not fresh pair) bool wake_pending_{false}; ///< wake_console() latched: keep the WAKE adv variant until connected - bool input_subscribed_{false}; ///< console has enabled input-report notifications (0x000e) - uint8_t report_counter_{0}; ///< input-report sequence (byte 0); +1 per delivered report + /// Console has enabled input-report notifications (0x000e). Written from the + /// NimBLE callback thread, read by the streaming thread — atomic to avoid a race. + std::atomic input_subscribed_{false}; + uint8_t report_counter_{0}; ///< input-report sequence (byte 0); +1 per delivered report std::atomic notify_in_flight_{0}; ///< queued-but-not-yet-transmitted input notifications std::atomic tx_completions_{ 0}; ///< count of NOTIFY_TX completions (flow-control signal) @@ -280,7 +287,9 @@ class Switch2Pro : public BaseComponent { uint16_t last_latency_{0xffff}; uint8_t last_tx_phy_{0}; uint8_t last_rx_phy_{0}; - uint16_t active_conn_handle_{0xffff}; ///< current connection (BLE_HS_CONN_HANDLE_NONE) + /// Current connection handle (0xffff = BLE_HS_CONN_HANDLE_NONE). Written from the + /// NimBLE connect/disconnect callbacks, read by the streaming/timer threads — atomic. + std::atomic active_conn_handle_{0xffff}; std::array ltk_{}; ///< derived during key exchange (A1 ^ B1) std::array host_addr_{}; ///< console BD_ADDR (from exchange-addresses) uint8_t bond_peer_type_{0}; ///< persisted console address type @@ -290,7 +299,9 @@ class Switch2Pro : public BaseComponent { /// input report must reflect these: rumble (bit 5) sets report byte 0x0B to /// 0x38, and IMU (bit 2) makes us stream the 40-byte motion block — the /// console enables both (mask 0x2f) and discards reports that omit them. - uint8_t enabled_features_{0}; + /// Written from the FEATURE_SELECT command handler (callback thread), read by the + /// streaming thread when building each report — atomic. + std::atomic enabled_features_{0}; switch2::Pro2InputReport input_report_{}; }; diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index b629e3e00d..8e484b691c 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -20,6 +20,16 @@ #include "switch2_pro_flash.hpp" #include "switch2_pro_motion.hpp" +// The Switch 2 console filters controllers on a 31-byte LEGACY advertisement +// carrying Nintendo manufacturer data, and this component builds that via the +// legacy BleGattServer::AdvertisingParameters path (which only exists when NimBLE +// extended advertising is disabled). Fail fast with a clear message instead of a +// confusing template error if a consumer enables extended advertising. +#if defined(CONFIG_BT_NIMBLE_EXT_ADV) && CONFIG_BT_NIMBLE_EXT_ADV +#error \ + "switch2_pro requires legacy advertising; disable CONFIG_BT_NIMBLE_EXT_ADV (NimBLE extended advertising)." +#endif + namespace espp { using namespace switch2; @@ -165,9 +175,20 @@ bool Switch2Pro::init() { } Switch2Pro::~Switch2Pro() { + // Tear down everything that can call back into `this` BEFORE the members those + // callbacks touch are destroyed. The streaming thread, the wake timer, and the + // NimBLE GAP/GATT callbacks all capture `this`; members declared after + // ble_gatt_server_/wake_timer_ are destroyed first, so a late callback would + // otherwise access already-destroyed state. stream_stop_.store(true); if (input_stream_thread_.joinable()) input_stream_thread_.join(); + if (wake_timer_) { + wake_timer_->cancel(); // stop + join the wake-advertisement timer task + wake_timer_.reset(); + } + ble_gatt_server_.stop_advertising(); + ble_gatt_server_.set_callbacks({}); // detach the this-capturing GAP/GATT callbacks } void Switch2Pro::configure_security() { @@ -194,6 +215,7 @@ void Switch2Pro::configure_callbacks() { // over-the-air address the console connected to (see local_bt_address()). active_conn_handle_ = info.getConnHandle(); wake_pending_ = false; // wake accomplished — subsequent advertising can be passive + pairing_stage_ = 0; // a fresh connection restarts the 0x15 handshake sequence // The connection interval right after connect is the key diagnostic: the // Switch 2 drives 5 ms (interval == 4 units). If a controller can't hold // that, the console typically disconnects with a supervision timeout. @@ -601,20 +623,12 @@ void Switch2Pro::on_subscribe(NimBLECharacteristic *characteristic, uint16_t sub // The console enables input-report notifications on the Pro Controller 2 input // characteristic (0x000e) near the end of init; only then do we stream. if (characteristic == pro2_input_) { - input_subscribed_ = (sub_value != 0); - if (input_subscribed_) { - report_counter_ = 0; // fresh sequence for the console to track - motion_idx_ = 0; - enomem_count_ = 0; - notify_in_flight_.store(0); - tx_completions_.store(0); - backpressure_skips_ = 0; - last_itvl_ = 0; // force a fresh LINK baseline log - last_latency_ = 0xffff; - last_tx_phy_ = 0; - last_rx_phy_ = 0; - } - logger_.info("input-report streaming {}", input_subscribed_ ? "ENABLED (0x000e)" : "disabled"); + // Just flip the flag (atomic). The per-session counters/link-baseline are + // reset by the streaming thread itself at the start of each streaming run + // (see input_stream_loop) so they stay single-writer — no cross-thread race. + const bool subscribed = (sub_value != 0); + input_subscribed_.store(subscribed); + logger_.info("input-report streaming {}", subscribed ? "ENABLED (0x000e)" : "disabled"); } } @@ -713,6 +727,18 @@ void Switch2Pro::input_stream_loop() { // --- tx-wedge telemetry --- const int64_t now_us = esp_timer_get_time(); if (stream_start_us_ == 0) { // first live tick of this streaming run + // Reset the per-session state here (in the streaming thread) rather than in + // the on_subscribe callback, so these stay single-writer. + report_counter_ = 0; // fresh byte-0 sequence for the console to track + motion_idx_ = 0; + enomem_count_ = 0; + backpressure_skips_ = 0; + notify_in_flight_.store(0); + tx_completions_.store(0); + last_itvl_ = 0; // force a fresh LINK baseline log from poll_conn_state + last_latency_ = 0xffff; + last_tx_phy_ = 0; + last_rx_phy_ = 0; stream_start_us_ = hb_last_us_ = now_us; last_tx_complete_us_.store(now_us); hb_last_completions_ = tx_completions_.load(); @@ -831,7 +857,7 @@ bool Switch2Pro::send_input_report() { logger_.debug("input stream: inflight={} txdone={} enomem={} ctr=0x{:02x} btn=[{:02x} {:02x} " "{:02x}] 0x0b={:02x} feat={:02x}", notify_in_flight_.load(), tx_completions_.load(), enomem_count_, buf[0], buf[2], - buf[3], buf[4], buf[0x0b], enabled_features_); + buf[3], buf[4], buf[0x0b], enabled_features_.load()); return sent; } @@ -918,9 +944,12 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P // the 0x00/count prefix as the address — garbage the console never recognises. if (len >= 8) { std::copy(payload + 2, payload + 8, host_addr_.begin()); + pairing_stage_ = 1; logger_.info( "pairing: stored console identity addr {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", host_addr_[5], host_addr_[4], host_addr_[3], host_addr_[2], host_addr_[1], host_addr_[0]); + } else { + logger_.warn("pairing: exchange-addresses payload too short ({} bytes)", len); } // Reply: {0x01, 0x04, 0x01} + our BT address. The 0x04/0x01 prefix bytes // are as observed in captures; address byte order to be confirmed on HW. @@ -936,10 +965,14 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P } case PairingSub::EXCHANGE_KEYS: { // Request data is [0x00][A1 (16 bytes)] — skip the leading 0x00. - if (len >= 17) { + if (pairing_stage_ >= 1 && len >= 17) { std::array a1{}; std::copy(payload + 1, payload + 17, a1.begin()); ltk_ = PairingCrypto::derive_ltk(a1); + pairing_stage_ = 2; + } else { + logger_.warn("pairing: exchange-keys out of order or short (stage={}, len={})", + pairing_stage_, len); } // Reply: {0x01} + fixed controller key B1. std::array reply{0x01}; @@ -952,10 +985,14 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P case PairingSub::CONFIRM_LTK: { // Request data is [0x00][A2 challenge (16 bytes)] — skip the leading 0x00. std::array b2{}; - if (len >= 17) { + if (pairing_stage_ >= 2 && len >= 17) { std::array a2{}; std::copy(payload + 1, payload + 17, a2.begin()); b2 = PairingCrypto::confirm(ltk_, a2); + pairing_stage_ = 3; + } else { + logger_.warn("pairing: confirm out of order or short (stage={}, len={})", pairing_stage_, + len); } // Reply: {0x01} + B2 = AES-128-ECB(rev(LTK), rev(A2)). std::array reply{0x01}; @@ -966,6 +1003,14 @@ void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, P break; } case PairingSub::FINALISE: { + // Only finalise if address exchange, key derivation, and LTK confirmation all + // completed in order (stage 3). Otherwise an out-of-order/malformed peer could + // mark us paired and persist an all-zero/partial bond, sending future boots + // into reconnect mode with a useless bond. Reject without replying/persisting. + if (pairing_stage_ < 3) { + logger_.warn("pairing: FINALISE rejected — handshake incomplete (stage={})", pairing_stage_); + break; + } static constexpr std::array reply{0x01}; send_response(via_vibration_command, 0x15, transport, 0x03, 0x10, 0x78, reply.data(), reply.size()); diff --git a/doc/Doxyfile b/doc/Doxyfile index b455c36324..314182fd9f 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -184,8 +184,8 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/st7123touch/example/main/st7123touch_example.cpp \ $(PROJECT_PATH)/components/state_machine/example/main/hfsm_example.cpp \ $(PROJECT_PATH)/components/stream_frame/example/main/stream_frame_example.cpp \ - $(PROJECT_PATH)/components/sx126x/example/main/sx126x_example.cpp \ $(PROJECT_PATH)/components/switch2_pro/example/main/switch2_pro_example.cpp \ + $(PROJECT_PATH)/components/sx126x/example/main/sx126x_example.cpp \ $(PROJECT_PATH)/components/tabulate/example/main/tabulate_example.cpp \ $(PROJECT_PATH)/components/t-deck/example/main/t_deck_example.cpp \ $(PROJECT_PATH)/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp \ @@ -446,10 +446,10 @@ INPUT = \ $(PROJECT_PATH)/components/state_machine/include/state_base.hpp \ $(PROJECT_PATH)/components/state_machine/include/state_machine.hpp \ $(PROJECT_PATH)/components/stream_frame/include/stream_frame.hpp \ - $(PROJECT_PATH)/components/sx126x/include/sx126x.hpp \ $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro.hpp \ - $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro_report.hpp \ $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro_protocol.hpp \ + $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro_report.hpp \ + $(PROJECT_PATH)/components/sx126x/include/sx126x.hpp \ $(PROJECT_PATH)/components/t-deck/include/t-deck.hpp \ $(PROJECT_PATH)/components/t-dongle-s3/include/t-dongle-s3.hpp \ $(PROJECT_PATH)/components/t_keyboard/include/t_keyboard.hpp \ From e362ede81b91af6732a2e0501714d30160211569 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 19:46:58 -0500 Subject: [PATCH 16/18] fix(switch2_pro): address 3rd PR review round + self-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/switch2_pro/idf_component.yml | 1 + .../switch2_pro/include/switch2_pro.hpp | 24 ++++-- components/switch2_pro/src/switch2_pro.cpp | 81 ++++++++++++------- .../switch2_pro/tools/patch_nimble_5ms.py | 3 +- .../switch2_pro/tools/smoke_test_5ms.py | 3 +- 5 files changed, 74 insertions(+), 38 deletions(-) diff --git a/components/switch2_pro/idf_component.yml b/components/switch2_pro/idf_component.yml index 06e6580fff..8c67101bc2 100644 --- a/components/switch2_pro/idf_component.yml +++ b/components/switch2_pro/idf_component.yml @@ -30,3 +30,4 @@ dependencies: version: '>=2.3.0' espp/ble_gatt_server: '>=1.0' espp/base_component: '>=1.0' + espp/timer: '>=1.0' diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index 985ff7ea94..68dd9f5b44 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -195,10 +195,12 @@ class Switch2Pro : public BaseComponent { /// report every connection interval like a real controller. Started in init(), /// stopped in the destructor. void input_stream_loop(); - /// Send one input report now (latest stored state + counter + motion), honoring - /// the in-flight flow-control cap. Returns true iff a notification was actually - /// queued (rc==0); false on a flow-control skip or ENOMEM. Called by input_stream_loop(). - bool send_input_report(); + /// Send the given input-report snapshot now (the caller passes the exact bytes it + /// snapshotted under input_mutex_; this adds the counter/rumble/motion fields it + /// manages), honoring the mbuf backpressure cap. Returns true iff a notification + /// was actually queued (rc==0); false on a backpressure skip or ENOMEM. Called by + /// input_stream_loop(). + bool send_input_report(const std::array &report_data); /// On-change keepalive: send a report at least this often (in connection /// intervals) even when the app state is unchanged, so the console keeps seeing /// the controller as active. ~10 intervals ≈ 150 ms at 15 ms. @@ -254,7 +256,16 @@ class Switch2Pro : public BaseComponent { /// Reset to 0 on each new connection. uint8_t pairing_stage_{0}; bool reconnect_mode_{false}; ///< booted with a stored bond (reconnect, not fresh pair) - bool wake_pending_{false}; ///< wake_console() latched: keep the WAKE adv variant until connected + /// wake_console() latched: keep the WAKE adv variant on the air until connected. + /// Written from the app task (wake_console) and the connect callback, read by + /// advertise() — atomic. + std::atomic wake_pending_{false}; + /// One-shot wake-on-boot state: true from boot (when wake_console_on_boot_ and + /// bonded) until the FIRST successful connection, then cleared so we do NOT keep + /// waking a console the user later puts to sleep. Read by the wake-timer task and + /// advertise(), written by the connect callback — atomic. (User-requested wake is + /// separate: wake_pending_.) + std::atomic boot_wake_pending_{false}; /// Console has enabled input-report notifications (0x000e). Written from the /// NimBLE callback thread, read by the streaming thread — atomic to avoid a race. std::atomic input_subscribed_{false}; @@ -264,7 +275,8 @@ class Switch2Pro : public BaseComponent { 0}; ///< count of NOTIFY_TX completions (flow-control signal) uint32_t enomem_count_{0}; ///< diagnostic: notifies deferred because the tx pool was full uint32_t motion_idx_{0}; ///< index into kMotionSequence for the replayed IMU block - switch2::Pro2InputReport last_streamed_{}; ///< last app-state we notified (on-change dedup) + std::array + last_streamed_{}; ///< exact snapshot we last notified (on-change dedup) bool have_streamed_{false}; ///< false until the first report goes out (forces initial send) uint32_t idle_intervals_{0}; ///< connection intervals since last send (on-change keepalive) uint32_t interval_tick_{0}; ///< continuous-mode interval counter (for the rate divisor) diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index 8e484b691c..dc7f11e5a3 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -153,6 +153,7 @@ bool Switch2Pro::init() { logger_.info( "wake-on-boot: broadcasting the wake advertisement every {:.0f}s until connected", wake_interval_.count()); + boot_wake_pending_ = true; // one-shot: cleared on the first successful connect start_wake_timer(); } } @@ -187,8 +188,11 @@ Switch2Pro::~Switch2Pro() { wake_timer_->cancel(); // stop + join the wake-advertisement timer task wake_timer_.reset(); } - ble_gatt_server_.stop_advertising(); - ble_gatt_server_.set_callbacks({}); // detach the this-capturing GAP/GATT callbacks + // Fully deinitialize NimBLE here, while this object is still alive. That destroys + // the GATT server, its characteristics, and the per-characteristic ChannelCallbacks + // (each holds owner_ == this) as well as the GAP/GATT server callbacks — so none of + // them can fire against members that are about to be torn down. + ble_gatt_server_.deinit(); } void Switch2Pro::configure_security() { @@ -216,6 +220,12 @@ void Switch2Pro::configure_callbacks() { active_conn_handle_ = info.getConnHandle(); wake_pending_ = false; // wake accomplished — subsequent advertising can be passive pairing_stage_ = 0; // a fresh connection restarts the 0x15 handshake sequence + // Wake-on-boot is one-shot: clear it on the first connection so we never wake a + // console the user intentionally sleeps later. The wake timer cancels ITSELF on + // its next tick (see start_wake_timer) — do NOT cancel/join it from here, since + // this callback may run under the NimBLE host lock the timer task also needs. + // User-requested wake stays available via wake_console(). + boot_wake_pending_ = false; // The connection interval right after connect is the key diagnostic: the // Switch 2 drives 5 ms (interval == 4 units). If a controller can't hold // that, the console typically disconnects with a supervision timeout. @@ -463,7 +473,7 @@ void Switch2Pro::advertise() { // variant from its idle screens, and a waking console may transiently // connect/drop (which re-enters here) — the latch keeps the wake variant on // the air until a connection actually completes. - if (reconnect_mode_ && (wake_console_on_boot_ || wake_pending_)) + if (reconnect_mode_ && (boot_wake_pending_ || wake_pending_)) start_advertising(AdvMode::Wake, host_addr_); else if (reconnect_mode_) start_advertising(AdvMode::Reconnect, host_addr_); @@ -491,13 +501,19 @@ void Switch2Pro::start_wake_timer() { .name = "switch2 wake", .period = wake_interval_, .callback = [this]() -> bool { - // While disconnected, keep re-issuing the wake advertisement so a - // sleeping console is repeatedly nudged; do nothing once connected. + // Wake-on-boot is one-shot: once we've connected once (boot_wake_pending_ + // cleared), cancel this timer from its OWN task (returning true) so we + // never keep nudging a console the user later sleeps. Cancelling here (not + // from the connect callback) avoids joining this task under the host lock. + if (!boot_wake_pending_) + return true; // stop the timer + // While disconnected, keep re-issuing the wake advertisement so a sleeping + // console is repeatedly nudged; do nothing once connected. if (active_conn_handle_ == 0xffff) { logger_.info("wake: re-broadcasting wake advertisement (waiting for console)"); advertise(); } - return false; // never cancel — resume nudging after any disconnect + return false; // keep nudging until the first connection }, .auto_start = true, .stack_size_bytes = 8192, // advertise() → NimBLE is a deep call; 4096 can overflow @@ -760,34 +776,38 @@ void Switch2Pro::input_stream_loop() { hb_last_enomem_ = e; } + // Take ONE snapshot of the app state under the lock and use it for the + // change-check, the send, and the on-change baseline — so an app update + // between those steps can't cause a real change to be skipped. + std::array snap; + { + std::lock_guard lk(input_mutex_); + snap = input_report_.data(); + } + bool should_send = true; if (continuous_streaming_) { // Rate-halving probe: send only every Nth interval (N=1 → every interval). should_send = (interval_tick_++ % continuous_stream_divisor_) == 0; } else { - bool changed; - { - std::lock_guard lk(input_mutex_); - changed = !have_streamed_ || input_report_.data() != last_streamed_.data(); - } - if (changed || ++idle_intervals_ >= kKeepaliveIntervals) - should_send = true, idle_intervals_ = 0; - else - should_send = false; + const bool changed = !have_streamed_ || snap != last_streamed_; + should_send = changed || (++idle_intervals_ >= kKeepaliveIntervals); + if (should_send) + idle_intervals_ = 0; } - if (should_send && send_input_report() && !continuous_streaming_) { - // Only advance the on-change baseline on an ACTUAL send, so a flow-control - // skip retries the change next interval instead of dropping the input. - std::lock_guard lk(input_mutex_); - last_streamed_ = input_report_; + if (should_send && send_input_report(snap) && !continuous_streaming_) { + // Advance the baseline to EXACTLY what we sent, and only on an actual send + // (a backpressure skip retries the change next interval). + last_streamed_ = snap; have_streamed_ = true; } std::this_thread::sleep_for(std::chrono::microseconds(itvl_us)); } } -bool Switch2Pro::send_input_report() { +bool Switch2Pro::send_input_report( + const std::array &report_data) { ++send_attempts_; // telemetry: every call the loop wanted to send // Real backpressure. The old notify_in_flight_/NOTIFY_TX cap is INERT here: // NOTIFY_TX fires at host->controller handoff, not over-air completion, so the @@ -801,16 +821,17 @@ bool Switch2Pro::send_input_report() { return false; } - // Latest stored app state + the protocol fields the app doesn't manage: byte 0 - // counter, byte 0x0B rumble flag, and the 40-byte IMU motion block (replayed - // from a captured monotonic sequence when the console has enabled IMU). - std::array buf; - { - std::lock_guard lk(input_mutex_); - buf = input_report_.data(); - } + // The caller-provided snapshot + the protocol fields the app doesn't manage: + // byte 0 counter, byte 0x0B rumble flag, and the 40-byte IMU motion block + // (replayed from a captured sequence when the console has enabled IMU). + std::array buf = report_data; buf[0] = report_counter_; - buf[0x0b] = 0x38; // constant on a real controller (and the known-working emulator) + // Byte 0x0B reflects the console-negotiated rumble feature: 0x38 when rumble is + // enabled, 0x30 otherwise. The console enables rumble (mask 0x2f) before it + // subscribes/streams, so this is 0x38 during actual streaming — matching a real + // controller — but it now tracks FEATURE_SELECT (incl. a 0x05 disable) instead + // of being hardcoded, so the flags always match the negotiated mask. + buf[0x0b] = (enabled_features_ & switch2::FEATURE_RUMBLE) ? 0x38 : 0x30; if (enabled_features_ & switch2::FEATURE_IMU) { buf[0x0e] = 0x28; // motion data length (40) — always present once IMU is enabled if (stream_imu_motion_) { diff --git a/components/switch2_pro/tools/patch_nimble_5ms.py b/components/switch2_pro/tools/patch_nimble_5ms.py index c2e50bc096..a9813610ec 100644 --- a/components/switch2_pro/tools/patch_nimble_5ms.py +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -42,6 +42,7 @@ import subprocess import sys import tempfile +from typing import Optional # Per-target patch spec. `arch` selects the toolchain archiver/objdump (the # archives are GNU-format; macOS BSD `ar` cannot read them). `archives` is a @@ -115,7 +116,7 @@ } -def resolve_tool(kind: str, arch: str, explicit: str | None) -> str: +def resolve_tool(kind: str, arch: str, explicit: Optional[str]) -> str: """Resolve the GNU `ar`/`objdump` for the target arch. The controller archives are GNU-format (long-name symbol/string tables); macOS's BSD `ar` cannot extract them, so prefer the ESP toolchain's GNU tools (on PATH after diff --git a/components/switch2_pro/tools/smoke_test_5ms.py b/components/switch2_pro/tools/smoke_test_5ms.py index 2c7edf4ff1..b068dad1fc 100644 --- a/components/switch2_pro/tools/smoke_test_5ms.py +++ b/components/switch2_pro/tools/smoke_test_5ms.py @@ -37,6 +37,7 @@ import subprocess import sys import tempfile +from typing import Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from patch_nimble_5ms import TARGETS, archive_paths, resolve_tool, spec_for # noqa: E402 @@ -52,7 +53,7 @@ def stack_of(spec: dict) -> str: return "btdm" if spec["object"] == "llc_con_upd.o" else "nimble" -def disassemble(objdump: str, obj_path: str, func: str | None) -> str: +def disassemble(objdump: str, obj_path: str, func: Optional[str]) -> str: out = subprocess.run([objdump, "-d", obj_path], capture_output=True, text=True).stdout if not func: return out From 0279fca5fb67e397f2d258168b9b3330d06daf4b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 3 Sep 2026 21:54:57 -0500 Subject: [PATCH 17/18] fix(switch2_pro): address follow-up PR review (wake guard + patcher atomicity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- components/switch2_pro/src/switch2_pro.cpp | 8 ++- .../switch2_pro/tools/patch_nimble_5ms.py | 55 +++++++++++++------ 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index dc7f11e5a3..52ae772bcc 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -484,9 +484,13 @@ void Switch2Pro::advertise() { bool Switch2Pro::wake_console() { if (active_conn_handle_ != 0xffff) return false; // already connected — nothing to wake + // Require a real persisted bond. reconnect_mode_ is set only after a completed + // pairing (FINALISE) or a bond loaded from NVS. host_addr_ alone is not enough: + // it becomes nonzero mid-pairing (EXCHANGE_ADDRESSES), before any bond exists, so + // a failed pairing would otherwise let this emit a wake advertisement. static constexpr std::array kZeroAddr{}; - if (host_addr_ == kZeroAddr) - return false; // no bonded console identity to address the wake to + if (!reconnect_mode_ || host_addr_ == kZeroAddr) + return false; // no bonded console to wake logger_.info("wake: broadcasting wake advertisement (user-requested)"); wake_pending_ = true; // keep the wake variant on the air (across any transient // connect/drop while the console boots) until connected diff --git a/components/switch2_pro/tools/patch_nimble_5ms.py b/components/switch2_pro/tools/patch_nimble_5ms.py index a9813610ec..93cbbf0d68 100644 --- a/components/switch2_pro/tools/patch_nimble_5ms.py +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -200,16 +200,24 @@ def main() -> int: sys.exit("no .original backups found to restore") return 0 - any_patched_now = False + # verify-only: just report each archive's state; never touch anything. + if args.verify_only: + 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) + state = "PATCHED (5 ms)" if (n_new and not n_old) else \ + "unpatched (7.5 ms)" if (n_old and not n_new) else "UNKNOWN" + print(f"{tag}: {obj} unpatched-pattern={n_old} patched-pattern={n_new} -> {state}") + return 0 + + # PREFLIGHT every archive before writing any of them, so a bad/ambiguous second + # archive can't leave the first one patched (a partially-patched IDF install). + to_patch = [] # (lib, patched_bytes) 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) - if args.verify_only: - state = "PATCHED (5 ms)" if (n_new and not n_old) else \ - "unpatched (7.5 ms)" if (n_old and not n_new) else "UNKNOWN" - print(f"{tag}: {obj} unpatched-pattern={n_old} patched-pattern={n_new} -> {state}") - continue if n_new > 0 and n_old == 0: print(f"{tag}: already patched; nothing to do") continue @@ -219,16 +227,31 @@ def main() -> int: if n_old > 1: sys.exit(f"{tag}: pattern appears {n_old}x in {obj} (expected 1) — refusing to " f"patch ambiguously") - backup = lib + ".original" - if not os.path.isfile(backup): - shutil.copy2(lib, backup) - print(f"{tag}: backed up -> {os.path.basename(backup)}") - write_object(ar, lib, obj, data.replace(old, new)) - print(f"{tag}: patched {n_old} occurrence(s) — now accepts a 5 ms connection interval") - any_patched_now = True - - if not args.verify_only and any_patched_now: - print(f"done ({args.target}). Run tools/smoke_test_5ms.py --target {args.target} to verify.") + to_patch.append((lib, data.replace(old, new))) + + if not to_patch: + return 0 # every archive was already patched + + # WRITE pass. Refresh each archive's .original backup from the CURRENT archive + # first — the preflight just confirmed it is unpatched, so this avoids a stale + # backup from a previous IDF version (which --restore would otherwise put back). + # Roll back everything if any write fails, so IDF is never left partially patched. + backed_up = [] # (lib, backup) — refreshed, safe to restore from + try: + for lib, patched in to_patch: + tag = os.path.basename(lib) + backup = lib + ".original" + shutil.copy2(lib, backup) # refresh backup from the confirmed-unpatched archive + backed_up.append((lib, backup)) + write_object(ar, lib, obj, patched) + print(f"{tag}: backed up + patched — now accepts a 5 ms connection interval") + except Exception as exc: # noqa: BLE001 — any failure must roll back + for lib, backup in reversed(backed_up): + shutil.copy2(backup, lib) + print(f"rolled back {os.path.basename(lib)}") + sys.exit(f"patch failed ({exc}); rolled back {len(backed_up)} archive(s) — IDF left unpatched") + + print(f"done ({args.target}). Run tools/smoke_test_5ms.py --target {args.target} to verify.") return 0 From 7fa5f6f2ead2918464fb5cd1b33d288222ea55ce Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 09:02:11 -0500 Subject: [PATCH 18/18] fix(switch2_pro): make paired_/reconnect_mode_ atomic (cross-thread reads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paired_ is written by the FINALISE callback (NimBLE host task) and read by the public is_paired() getter (app task); reconnect_mode_ is written by FINALISE/init and read by advertise() and wake_console() (app task). Both were plain bools — make them std::atomic like the other cross-thread session flags to remove the C++ data race. (Disconnect log uses paired_.load().) Co-Authored-By: Claude Opus 4.8 --- components/switch2_pro/include/switch2_pro.hpp | 9 +++++++-- components/switch2_pro/src/switch2_pro.cpp | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/components/switch2_pro/include/switch2_pro.hpp b/components/switch2_pro/include/switch2_pro.hpp index 68dd9f5b44..02cfdd134e 100644 --- a/components/switch2_pro/include/switch2_pro.hpp +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -249,13 +249,18 @@ class Switch2Pro : public BaseComponent { NimBLECharacteristic *command_response2_{nullptr}; // Pairing state. - bool paired_{false}; + /// Whether the pairing handshake has completed / a bond exists. Written by the + /// FINALISE callback (host task), read by the public is_paired() getter (app + /// task) — atomic. + std::atomic paired_{false}; /// Highest completed 0x15 pairing step this connection: 0=none, 1=exchange /// addresses, 2=exchange keys, 3=confirm LTK. FINALISE (step 4) is only accepted /// when this is 3, so an out-of-order/malformed peer cannot persist a bad bond. /// Reset to 0 on each new connection. uint8_t pairing_stage_{0}; - bool reconnect_mode_{false}; ///< booted with a stored bond (reconnect, not fresh pair) + /// Booted with a stored bond (reconnect, not fresh pair). Written by FINALISE + /// (host task) / init, read by advertise() and wake_console() (app task) — atomic. + std::atomic reconnect_mode_{false}; /// wake_console() latched: keep the WAKE adv variant on the air until connected. /// Written from the app task (wake_console) and the connect callback, read by /// advertise() — atomic. diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp index 52ae772bcc..4880f70ff3 100644 --- a/components/switch2_pro/src/switch2_pro.cpp +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -248,7 +248,7 @@ void Switch2Pro::configure_callbacks() { }; callbacks.disconnect_callback = [this](NimBLEConnInfo &info, BleGattServer::DisconnectReason r) { logger_.warn("disconnected: peer={} reason={} (paired={})", info.getAddress().toString(), r, - paired_); + paired_.load()); // Tie the disconnect to the tx-wedge timeline: how long we streamed, whether // we had wedged, and how stale the last completion was. A disconnect ~1 SVN // timeout after the wedge with a large since_last_tx = over-air exchange