diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f39e533dcb..9a79af0ed6 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -300,6 +300,10 @@ jobs: target: esp32 - path: 'components/stream_frame/example' target: esp32 + - 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' 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/.gitignore b/.gitignore index 176a1dac14..c72607f636 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ # build folder for ESP-IDF build/ +# alternate/per-target ESP-IDF build dirs (e.g. build_s3, build_c6) +build_*/ # we only version sdkconfig.defaults diff --git a/components/ble_gatt_server/include/ble_gatt_server.hpp b/components/ble_gatt_server/include/ble_gatt_server.hpp index 44462f1423..d16bf92229 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,35 @@ 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(). Calling it after init() has no effect + /// (the built-in services are created/skipped during init) and is ignored + /// with a warning, since honoring it would leave services created but never + /// started or torn down. + void set_builtin_info_services_enabled(bool enabled) { + if (server_) { + logger_.warn("set_builtin_info_services_enabled() ignored: must be called before init()"); + return; + } + 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 +312,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 +327,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 +839,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(); 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 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..9363ad6f33 --- /dev/null +++ b/components/switch2_pro/CMakeLists.txt @@ -0,0 +1,65 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component ble_gatt_server esp-nimble-cpp timer + PRIV_REQUIRES mbedtls) + +# Opt-in: patch the prebuilt BLE controller library to accept the console's +# sub-spec 5 ms connection interval. Off by default. Covers ONLY the open RISC-V +# NimBLE controller (C6/C61/C2/H2, libble_app.a), which has no config option for a +# sub-spec interval. S3/C3 use ESP-IDF >= v6.1's official +# CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE instead (see below) — they are NOT +# patch targets. 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") + message(WARNING + "[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} + ${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] 5 ms controller patch failed (${_switch2_patch_result})") + endif() + elseif(IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3") + message(FATAL_ERROR + "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS does not support ${IDF_TARGET}. Use " + "ESP-IDF >= v6.1's official CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (default " + "on) for S3/C3 5 ms support instead — no binary patch needed. A patch of the " + "pre-fix BTDM controller was never confirmed to work (espressif/esp-idf#18467). " + "Disable this option for ${IDF_TARGET}.") + else() + message(WARNING + "[switch2_pro] SWITCH2_PRO_PATCH_NIMBLE_5MS has no effect on ${IDF_TARGET}: " + "no known controller patch for this target (supported: C6/C61/C2/H2 NimBLE).") + endif() +endif() + +# ESP32-S3 / C3: reconnect and wake-from-sleep need the console's sub-spec (5 ms) +# connection interval to be accepted by the closed BTDM controller. The official, +# default-on way is CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE, which requires +# ESP-IDF >= v6.1 (or the v6.0.2 / v5.5.x / v5.4.x / v5.3.x backports; +# espressif/esp-idf#18467). Warn at configure time if neither that option nor the +# legacy binary patch is enabled — reconnect/wake will otherwise silently fail +# (fresh pairing and first-session input still work). +if(IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3") + if(NOT CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE + AND NOT CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS) + idf_build_get_property(_switch2_idf_ver IDF_VERSION) + message(WARNING + "[switch2_pro] ${IDF_TARGET}: neither CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE " + "(official ESP-IDF sub-spec-interval support) nor SWITCH2_PRO_PATCH_NIMBLE_5MS is " + "enabled. The console drives the link at 5 ms for SUSTAINED input (it drops even the " + "fresh session to 5 ms ~1.5 s after subscription) as well as reconnect/wake, so only " + "the initial pairing handshake (~15 ms) will work here — sustained input, reconnect, " + "and wake will fail. Update to ESP-IDF >= v6.1 (you have ${_switch2_idf_ver}) for the " + "default-on option. See espressif/esp-idf#18467.") + endif() +endif() diff --git a/components/switch2_pro/DESIGN.md b/components/switch2_pro/DESIGN.md new file mode 100644 index 0000000000..67178bb90b --- /dev/null +++ b/components/switch2_pro/DESIGN.md @@ -0,0 +1,138 @@ +# 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 reconnect/wake won't form. + +Two routes by chip family: + +**ESP32-S3 / C3 — official ESP-IDF option (the verified path).** ESP-IDF added +`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default `y`), which lets the BTDM +controller and the BLE host accept sub-spec intervals (down to 3.75 ms) with no +binary patching — in ESP-IDF ≥ v6.0 (`142aea3`) / v5.5 (`cf13345`) / v5.4 +(`aefcf1c`) / v5.3 (`9831261`), and confirmed on **v6.1**; see +espressif/esp-idf#18467. The S3 is verified end-to-end on real hardware this way, +and v6.1's updated controller lib also fixes the sustained-tx stall seen on v6.0.1. +There is **no binary-patch fallback for S3/C3**: a patch of the pre-fix BTDM +library (`libbtdm_app.a`, `r_llc_con_upd_param_in_range` — the peripheral-side +connection-parameter validator) was reverse-engineered but never confirmed to +enable 5 ms on hardware (patching that min-interval compare alone is reported +insufficient on the pre-fix S3, esp-idf#18467, matching our own testing where it +had no effect), so it is not shipped. Those RE notes live in git history. + +**ESP32-C6 / C61 / C2 / H2 — binary patch.** The open RISC-V NimBLE controller has +no equivalent config option, so `tools/patch_nimble_5ms.py` lowers its 7.5 ms floor +with a single-instruction edit: 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). It is the single +unique occurrence in its object (asserted by the patcher); `tools/smoke_test_5ms.py` +proves the edit at the disassembly level with no hardware. + +**Build integration (decision: opt-in, never silent).** The patch mutates the user's +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) + +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_NR 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 (all implemented; verified end-to-end on ESP32-C6 and ESP32-S3) + +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 with real backpressure. The `CONNECT_IND` and + pairing run at 15 ms, but ~1.5 s after subscription the console issues an + `LL_CONNECTION_UPDATE` dropping the link to **5 ms** for the rest of the session + (observed on real S3 hardware) — so sustained input needs sub-spec-interval + support (see milestone 4), not just reconnect/wake. +4. **Reconnect + wake-from-sleep** (bonded reconnect with the 0x81 wake flag). + The console connects a bonded controller at 5 ms from the first packet, and + drops even the fresh session to 5 ms mid-stream, so the link runs at 5 ms in + every mode. On S3/C3 use ESP-IDF ≥ v6.1's official + `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default on); on C6/C61/C2/H2 use + the opt-in `SWITCH2_PRO_PATCH_NIMBLE_5MS` controller patch. + +On ESP-IDF ≥ v6.1 the ESP32-S3 is also fully verified (pairing, continuous input, +reconnect, wake) — its updated BTDM controller lib both accepts the console's 5 ms +CONNECT_IND (via `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`) and sustains the +encrypted input stream, fixing the ~3 s tx-stall seen on v6.0.1 (see the README +"Known issues"). On pre-v6.1 IDF, C6-class chips (open NimBLE controller) are the +supported target. + +## 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 interval patcher (C6/C61/C2/H2 NimBLE; S3/C3 use the official IDF option) + 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 new file mode 100644 index 0000000000..79604422e3 --- /dev/null +++ b/components/switch2_pro/Kconfig @@ -0,0 +1,38 @@ +menu "Switch 2 Pro Controller" + + config SWITCH2_PRO_PATCH_NIMBLE_5MS + bool "Patch the BLE controller to allow the console's 5 ms connection interval" + default n + help + The console drives the link at a 5 ms connection interval, below the + 7.5 ms Bluetooth spec minimum. It reaches 5 ms in every mode: on + RECONNECT and WAKE-FROM-SLEEP it connects a recognised (bonded) + controller at 5 ms from its CONNECT_IND, and on a FRESH session it + renegotiates down to 5 ms (LL_CONNECTION_UPDATE) about 1.5 s after the + console subscribes to input. The controller stack must accept that + sub-spec interval or the connection/stream fails. + + THIS OPTION IS ONLY FOR THE OPEN NimBLE CONTROLLER CHIPS + (ESP32-C6/C61/C2/H2), which have no config option for a sub-spec + interval. On ESP32-S3 / C3 use the OFFICIAL ESP-IDF option instead: + CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (default y) lets the BTDM + controller and the BLE host accept sub-spec intervals with no binary + patching, in ESP-IDF >= v6.1 (or the v6.0/v5.5/v5.4/v5.3 backports; see + espressif/esp-idf#18467). Enabling THIS option on S3/C3 is a build + error — there is no verified S3/C3 binary-patch fallback. + + When enabled on a supported chip, 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). + THIS MODIFIES YOUR ESP-IDF INSTALLATION. Undo with + tools/patch_nimble_5ms.py --target --restore, and verify with + tools/smoke_test_5ms.py --target . + + Required for SUSTAINED input (fresh or reconnected) as well as + reconnect and wake-from-sleep. Only the initial pairing handshake + (~15 ms, the first ~1.5 s) works without sub-spec support. Leave OFF + only if you accept that the console will drop the session once it + switches to 5 ms. + +endmenu diff --git a/components/switch2_pro/README.md b/components/switch2_pro/README.md new file mode 100644 index 0000000000..fc604b0ccf --- /dev/null +++ b/components/switch2_pro/README.md @@ -0,0 +1,228 @@ +# 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: 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 live connection interval, +> matching a real controller — ~62 Hz during the initial 15 ms window, then ~200 Hz +> once the console moves the link to 5 ms; see "The 5 ms connection interval"), +> 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: also fully working on ESP-IDF ≥ v6.1 (verified on real hardware).** +> The S3's sub-spec-interval rejection (which blocked reconnect/wake) is fixed by +> ESP-IDF's `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default on; +> espressif/esp-idf#18467), backported to v6.0/v5.5/v5.4/v5.3 — so on a current +> IDF the S3 needs **no binary patch**. Verified against a real Switch 2 on +> **v6.1**: pairing, continuous lag-free input, reconnect, and wake-from-sleep all +> work. The earlier finding that the *closed BTDM controller stalls sustained tx +> ~3 s into a stream* was on the **pre-fix (v6.0.1) controller lib**; v6.1 ships an +> updated controller lib and the stall is gone. See "The 5 ms connection interval" +> and "Known issues". + +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 (required for sustained input, reconnect & wake) + +The console chooses the connection interval **in its `CONNECT_IND`, based on +whether it recognises the controller**: + +- **Fresh pairing:** the `CONNECT_IND` is 15 ms — spec-legal, works on a stock + controller — and the 0x15 pairing handshake completes at 15 ms. But **~1.5 s + after input subscription the console sends an `LL_CONNECTION_UPDATE` dropping + the link to 5 ms** for the rest of the session (observed on real S3 hardware: + `CONN PARAMS UPDATE: itvl=5.00ms` right after streaming begins). So pairing + itself works unpatched, but *sustained* first-session input needs 5 ms support + too — on a stock controller the stream dies seconds in, when the console makes + that switch. (This is the real cause of the old "~3 s tx-stall"; see "Known + issues".) +- **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, and on real hardware). 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. + +Either way the console ends up driving the link at 5 ms, so accepting the +sub-spec interval is required for anything past the initial pairing handshake. + +Sustained input in every mode — plus reconnect and wake-from-sleep — therefore +requires the controller (and the BLE host) to accept that sub-spec interval. Only +the initial pairing handshake (the first ~1.5 s, before the console's switch to +5 ms) works without it. There are two ways to enable it, by chip: + +### ESP32-S3 / C3 — official ESP-IDF option (recommended) + +ESP-IDF now ships an **official** controller option that lets the S3/C3 BTDM +controller — and the BLE host — accept connection intervals below the 7.5 ms +spec minimum (down to 3.75 ms, which covers the console's 5 ms), no binary +patching required: + +``` +CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE=y # default y +``` + +It `select`s `BT_BLE_HOST_ALLOW_SUB_SPEC_MIN_CONN_INT`, so the NimBLE host +accepts the sub-spec interval too. It is **on by default**, so on a recent IDF +the S3 accepts the console's 5 ms reconnect/wake out of the box. + +Requires an ESP-IDF with the fix (espressif/esp-idf#18467), backported to +**v6.0 (≥ `142aea3`), v5.5 (≥ `cf13345`), v5.4 (≥ `aefcf1c`), v5.3 +(≥ `9831261`)**, and confirmed on **v6.1**. On an older IDF the option does not +exist — **update to a fixed IDF** (there is no verified binary-patch fallback for +S3/C3; see the note below). + +> Verified on real hardware with **ESP-IDF v6.1**: with this option default-on, +> the S3 pairs, streams input lag-free, reconnects, and wakes the console with no +> binary patch. +> +> **No S3/C3 binary-patch fallback.** A patch of the pre-fix BTDM controller +> (`libbtdm_app.a`, `r_llc_con_upd_param_in_range`) was reverse-engineered but never +> confirmed to enable 5 ms on hardware — patching that min-interval compare alone is +> reported insufficient on the pre-fix S3 (esp-idf#18467), matching our own testing +> where it had no effect. So `SWITCH2_PRO_PATCH_NIMBLE_5MS` covers only the +> C6-family chips below; on S3/C3 use the official option above (update your IDF). + +### ESP32-C6 / C61 / C2 / H2 — binary patch + +The open NimBLE controller (`libble_app.a`) has no equivalent config option yet +(Espressif support is planned), so these chips use the opt-in Kconfig option +**`SWITCH2_PRO_PATCH_NIMBLE_5MS`** (off by default — it mutates the prebuilt +controller lib in your global `$IDF_PATH`, which is too invasive to do silently). +When enabled, the build runs `tools/patch_nimble_5ms.py`, which binary-patches 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`. + +(The patcher only supports these targets; it refuses S3/C3 — use the official +option above there.) + +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 stalled sustained notification tx — fixed in ESP-IDF v6.1.** + +> **Resolved.** The analysis below was done on ESP-IDF **v6.0.1**, whose S3 +> controller lib predates the official sub-spec-interval fix +> (`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`, espressif/esp-idf#18467). **v6.1 +> ships the updated controller lib and the stall is gone** — verified on real +> hardware: the S3 now streams input to a real Switch 2 continuously and lag-free +> and holds reconnect/wake, matching the C6. The description below is retained as +> the pre-fix (v6.0.1) state for anyone stuck on an older IDF. + +The 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 was **independent of the +send rate** (62 Hz and 31 Hz stalled at the same wall-clock, ruling out +per-packet resource exhaustion). The identical firmware on an **ESP32-C6** +(open-source NimBLE controller) streamed indefinitely with zero distress. + +**The v6.1 log revealed the mechanism.** The stall was not a random controller +fault — it lined up exactly with the console's behaviour now visible on a working +S3: ~1.5 s after input subscription the console sends an `LL_CONNECTION_UPDATE` +dropping the link from 15 ms to **5 ms** (`CONN PARAMS UPDATE: itvl=5.00ms`). The +pre-fix S3 BTDM controller could not apply that sub-spec interval, so tx servicing +collapsed a few seconds into every session — which is why the "stall" always hit +at the same wall-clock regardless of send rate (it was the console's timed switch, +not resource exhaustion), and why the C6 (which accepts 5 ms via the binary patch) +never showed it. It is the **same** sub-spec-interval limitation as reconnect/wake, +just arriving mid-session. Espressif's controller-lib update in v6.1 (with +`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`) makes the S3 accept the 5 ms update, +and the stream now holds — verified on real hardware. On a pre-fix IDF, on-change +streaming (`Config::continuous_streaming = false`) reduces traffic enough to +partially mask it (input works in bursts); the real fix is updating to +ESP-IDF ≥ v6.1 (or 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. + +**Set `CONFIG_FREERTOS_HZ=1000` (required for accurate stream cadence).** The +streaming task paces itself with sub-15 ms sleeps (down to ~5 ms once the console +moves the link there), and `std::this_thread::sleep_for` is quantised to the +FreeRTOS tick. At the 100 Hz default a 5 ms sleep rounds to ~10 ms and 15 ms to +~20 ms, desyncing from the connection interval. The example sets this; a consuming +project must set it too (the component logs a warning at init if it is lower). + +**What the captures established** (ndeadly's `nrf52840` captures, decrypted, +via `tshark`): a real controller's fresh pairing's `CONNECT_IND` is **15 ms** +and its reconnect **and wake** connect at **5 ms**. Active-use motion captures +run at ~7.5 ms / 133 Hz for minutes. **On real hardware the console does not stay +at the fresh-pair 15 ms:** ~1.5 s after input subscription it sends an +`LL_CONNECTION_UPDATE` dropping the link to 5 ms and drives the session there +(the only other post-connect LL change is the PHY switch to 2M). So the console +ends up at 5 ms in *every* mode — fresh session (after a brief 15 ms window), +reconnect, and wake — which is why sub-spec-interval support is required for +sustained input, not just 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 + +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) — default target ESP32-C6 (also fully working on the +ESP32-S3 with ESP-IDF ≥ v6.1). 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/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/README.md b/components/switch2_pro/example/README.md new file mode 100644 index 0000000000..6813300636 --- /dev/null +++ b/components/switch2_pro/example/README.md @@ -0,0 +1,151 @@ +# Switch 2 Pro Controller Example + +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 targets: ESP32-C6** (default) **and ESP32-S3**. Pairing, input, +> reconnect, and wake-from-sleep all work against a real console. RISC-V siblings +> (C61/C2/H2) use the same open NimBLE controller as the C6. +> +> **ESP32-S3** works with **ESP-IDF ≥ v6.1**, where the official +> `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default on) gives it the console's +> 5 ms reconnect/wake with no binary patch — verified on real hardware. + +## Build, flash, monitor + +Default target is **esp32c6**: + +```bash +idf.py build flash monitor +``` + +The console drives the link at a sub-spec 5 ms connection interval for +**sustained input** (it drops even the fresh session to 5 ms ~1.5 s after +subscribing) as well as reconnect and wake-from-sleep — only the initial pairing +handshake (~15 ms, the first ~1.5 s) works without it. On the C6-family chips +that needs the opt-in controller patch (off by default because it modifies your +global `$IDF_PATH` install). To enable it: + +```bash +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 +``` + +The ESP32-S3 works out of the box on ESP-IDF ≥ v6.1 (`idf.py set-target esp32s3`); +no patch is needed there — the official `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` +is default-on and covers the console's 5 ms reconnect/wake. + +## Testing with a real Switch 2 + +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` — 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 + +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. (On the S3 this needs +ESP-IDF ≥ v6.1; on C6-family chips it needs the 5 ms controller patch.) + +### Verified on ESP32-S3 (ESP-IDF v6.1) + +A full session on a real Switch 2 — fresh pair, input testing, then powering the +console off and back on with reconnect + input still working — with the official +`CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` (default on, **no binary patch**). +The key moments: + +- `connected: … interval=15.00ms` — fresh pairing connects at the spec-legal + 15 ms, and the 0x15 handshake completes there. +- `CONN PARAMS UPDATE: itvl=5.00ms` — **~1.5 s after streaming starts the console + drops the link to 5 ms** and streams there for the rest of the session. On the + pre-v6.1 controller lib this is exactly where the old "~3 s tx-stall" hit; v6.1 + applies the update cleanly and input keeps flowing. +- After the console is powered off (`REMOTE_USER_TERMINATED`) and a `wake` + broadcast, it reconnects **straight at `interval=5.00ms`** and input resumes. + +
+Full monitor log (repetitive heartbeat lines elided) + +``` +[Switch2Pro/I][0.223]: pairing crypto self-test passed +I (224) BLE_INIT: BT controller compile version [51d9dfd] +I (226) BLE_INIT: Bluetooth MAC: f4:12:fa:5a:85:92 +[Switch2Pro/I][0.289]: BLE address: using PUBLIC +[Switch2Pro/I][0.290]: common_input = 0x000a (0x000a) +[Switch2Pro/I][0.291]: pro2_input = 0x000e (0x000e) +[Switch2Pro/I][0.291]: command = 0x0014 (0x0014) +[Switch2Pro/I][0.291]: vib_command = 0x0016 (0x0016) +[Switch2Pro/I][0.292]: resp1 = 0x001a (0x001a) +[Switch2Pro/I][0.292]: resp2 = 0x001e (0x001e) +[Switch2Pro/I][0.296]: Switch2Pro advertising as 'Pro Controller' + +# --- fresh pairing, connects at 15 ms --- +[Switch2Pro/I][5.331]: connected: peer=C8:48:05:64:A0:BB interval=15.00ms supervision=2000ms latency=0 +[Switch2Pro/I][6.291]: SUBSCRIBE resp1(0x001a) value=0x0001 (on) +[Switch2Pro/I][6.321]: SUBSCRIBE resp2(0x001e) value=0x0001 (on) +[Switch2Pro/I][6.501]: pairing: stored console identity addr c8:48:05:64:a0:bb +[Switch2Pro/I][6.503]: pairing: exchange addresses -> replied with our address 92 85 5a fa 12 f4 (little-endian) +[Switch2Pro/I][6.532]: pairing: exchange keys -> LTK derived, replied B1 +[Switch2Pro/I][6.578]: pairing: confirm -> replied B2 +[Switch2Pro/I][6.607]: pairing: finalised — bonded +[Switch2Pro/I][6.611]: injected LTK into NimBLE store (rc=0) — ready for LL encryption +[Switch2Pro/I][6.613]: saved bond to NVS (console addr + LTK) +[Switch2Pro/I][6.710]: AUTH complete: encrypted=true bonded=true authenticated=true +[Switch2Pro/I][7.176]: SUBSCRIBE pro2_input(0x000e) value=0x0001 (on) +[Switch2Pro/I][7.176]: input-report streaming ENABLED (0x000e) + +# --- console drops the FRESH session to 5 ms ~1.5 s after streaming begins --- +[Switch2Pro/I][7.699]: LINK CHANGE: itvl=15.00ms latency=0 timeout=2000ms tx_phy=2 rx_phy=2 +[Switch2Pro/I][8.666]: CONN PARAMS UPDATE: itvl=5.00ms latency=0 timeout=2000ms +[Switch2Pro/I][8.717]: LINK CHANGE: itvl=5.00ms latency=0 timeout=2000ms tx_phy=2 rx_phy=2 + +# --- streams fine at 5 ms; BOOT press registers as A --- +[switch2_pro example/I][9.749]: connected=true streaming=true A(boot)=false ... + ... (streaming continuously at 5 ms) ... +[switch2_pro example/I][32.944]: connected=true streaming=true A(boot)=true L+R(auto)=false + ... + +# --- console powered off --- +[Switch2Pro/I][40.616]: SUBSCRIBE pro2_input(0x000e) value=0x0000 (off) +[Switch2Pro/W][40.620]: disconnected: peer=C8:48:05:64:A0:BB reason=REMOTE_USER_TERMINATED (paired=true) +[Switch2Pro/I][40.623]: advertising (reconnect): flags+mfr(26 B) in adv, name in scan response + ... (advertising for reconnect) ... + +# --- BOOT press broadcasts wake; console powers back on and reconnects at 5 ms --- +[Switch2Pro/I][54.114]: wake: broadcasting wake advertisement (user-requested) +[switch2_pro example/I][54.118]: BOOT pressed while disconnected -> sent wake advertisement +[Switch2Pro/I][56.801]: connected: peer=C8:48:05:64:A0:BB interval=5.00ms supervision=2000ms latency=0 +[Switch2Pro/I][57.225]: AUTH complete: encrypted=true bonded=true authenticated=true +[Switch2Pro/I][57.226]: SUBSCRIBE pro2_input(0x000e) value=0x0001 (on) +[Switch2Pro/I][57.227]: input-report streaming ENABLED (0x000e) +[Switch2Pro/I][57.731]: LINK CHANGE: itvl=5.00ms latency=0 timeout=2000ms tx_phy=2 rx_phy=2 + +# --- input works again after wake/reconnect; BOOT press registers as A --- +[switch2_pro example/I][63.454]: connected=true streaming=true A(boot)=true L+R(auto)=false + ... +``` + +
+ +## 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/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..00bda21af7 --- /dev/null +++ b/components/switch2_pro/example/main/switch2_pro_example.cpp @@ -0,0 +1,127 @@ +#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) { + ESP_ERROR_CHECK(nvs_flash_erase()); + nvs_err = nvs_flash_init(); + } + // Fail fast: the reconnect/wake contract depends on NVS, so a controller that + // came up with NVS broken would pair but silently lose its bond on reboot. + ESP_ERROR_CHECK(nvs_err); + + //! [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. + // + // 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::INFO, + .wake_console_on_boot = false, + }); + + if (!controller.init()) { + logger.error("failed to initialize Switch2Pro"); + return; + } + logger.info("advertising — on the Switch 2, open Controllers > Pair, and watch " + "the log for the connect interval, the 0x15 pairing exchange, and " + "either 'pairing finalised' or a disconnect reason"); + + // The BOOT button (GPIO0) is wired as the A button for easy testing. + gpio_config_t btn_cfg = {}; + btn_cfg.pin_bit_mask = 1ULL << kBootButtonGpio; + btn_cfg.mode = GPIO_MODE_INPUT; + btn_cfg.pull_up_en = GPIO_PULLUP_ENABLE; + btn_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; + btn_cfg.intr_type = GPIO_INTR_DISABLE; + gpio_config(&btn_cfg); + + // Feed input state to the driver. set_input_report() just stores the latest + // report; the driver's streaming task paces the actual BLE notifications (one + // per connection interval, like a real controller) and only streams once the + // console has subscribed. So it is safe to call every loop — keep one report + // and mutate it. Replace the BOOT read below with your real button/stick source. + // + // Press BOOT and watch A register on the Switch 2's "Test Input Devices" screen. + espp::switch2::Pro2InputReport report; + report.set_power(/*battery_level=*/9, /*charging=*/false, /*external_power=*/false); // full + int tick = 0; + // The Switch 2 shows "press L + R on the controller you want to use" while + // selecting; auto-hold L+R for ~1 s each time streaming (re)starts to satisfy + // that selection prompt so the console activates this controller. + constexpr int kLrHoldTicks = 66; // ~1 s at the 15 ms cadence below + bool prev_streaming = false; + bool prev_pressed = false; + int lr_ticks = 0; + while (true) { + const bool pressed = gpio_get_level(kBootButtonGpio) == 0; // BOOT reads low when pressed + const bool press_edge = pressed && !prev_pressed; + prev_pressed = pressed; + + // BOOT while bonded-but-disconnected = wake the console (a real controller + // wakes the console on a button press). wake_console() no-ops unless there + // is a stored bond and no active connection, so the edge check is enough. + if (press_edge && !controller.is_connected()) { + if (controller.wake_console()) + logger.info("BOOT pressed while disconnected -> sent wake advertisement"); + } + + // Rising edge of streaming: (re)arm the L+R auto-press. + const bool streaming = controller.is_input_streaming(); + if (streaming && !prev_streaming) + lr_ticks = kLrHoldTicks; + prev_streaming = streaming; + const bool lr_auto = lr_ticks > 0; + if (lr_ticks > 0) + --lr_ticks; + + report.set_a(pressed); // BOOT doubles as A while connected + report.set_l(lr_auto); + report.set_r(lr_auto); + report.set_left_stick(0.f, 0.f); // centered + report.set_right_stick(0.f, 0.f); // centered + controller.set_input_report(report); + + if (++tick % 66 == 0) // ~1 s at the 15 ms cadence below + logger.info("connected={} streaming={} A(boot)={} L+R(auto)={}", controller.is_connected(), + streaming, pressed, lr_auto); + std::this_thread::sleep_for(15ms); // ~66 Hz, matching the real controller + } + //! [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..eb14f0663c --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -0,0 +1,86 @@ +# Default target is esp32c6 (open-source NimBLE BLE controller): pairing, +# reconnect, wake, and sustained lag-free input all verified against a real +# Switch 2. The ESP32-S3 is also fully verified on ESP-IDF >= v6.1, where the +# official CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (default on) lets its BTDM +# controller accept the console's 5 ms interval with no binary patch (the old +# ~3 s tx-stall was on the pre-v6.1 controller lib — see README "Known issues"). +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. +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 / 5 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). CPU frequency is set per-target (sdkconfig.defaults.esp32c6 +# = 160 MHz, the C6 maximum; sdkconfig.defaults.esp32s3 = 240 MHz) — a 240 MHz +# choice here would be invalid for the default C6 target. +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 +# 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 — 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 console's sub-spec 5 ms interval is required for sustained input in EVERY +# mode (it drops even the fresh session to 5 ms ~1.5 s after subscription via +# LL_CONNECTION_UPDATE) plus reconnect/wake (bonded CONNECT_IND is 5 ms from the +# first packet). Only the initial pairing handshake runs at 15 ms. +# * ESP32-S3 / C3: use ESP-IDF >= v6.1's official, default-on +# CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE — no patch needed. +# * ESP32-C6 / C61 / C2 / H2: the open NimBLE controller has no such option, so +# enable the patch below. It is OFF by default because it mutates the prebuilt +# BLE controller lib in your global $IDF_PATH at configure time (too invasive +# to do silently). Uncomment (or run `tools/patch_nimble_5ms.py --target +# `), 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..34e3750baf --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults.esp32c6 @@ -0,0 +1,18 @@ +# 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 CPU tops out at 160 MHz (240 MHz is an S3-only choice). +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160 + +# 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/example/sdkconfig.defaults.esp32s3 b/components/switch2_pro/example/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000000..3daeb20fe7 --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults.esp32s3 @@ -0,0 +1,8 @@ +# ESP32-S3-specific defaults (applied on top of sdkconfig.defaults when the +# target is esp32s3). The S3 is fully verified against a real Switch 2 on +# ESP-IDF >= v6.1, where CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (default on) +# lets the BTDM controller accept the console's 5 ms interval with no patch. + +# The S3 CPU runs at 240 MHz for headroom in the BLE host + input path. +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 diff --git a/components/switch2_pro/idf_component.yml b/components/switch2_pro/idf_component.yml new file mode 100644 index 0000000000..b16746183e --- /dev/null +++ b/components/switch2_pro/idf_component.yml @@ -0,0 +1,38 @@ +## 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: "https://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: + # Floor for the component itself. NOTE: the ESP32-S3/C3 targets additionally + # need the official sub-spec-interval fix (CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE) + # for reconnect/wake — ESP-IDF >= v6.1 (or the v6.0.2 / v5.5.x / v5.4.x / v5.3.x + # backports; espressif/esp-idf#18467). CMakeLists warns at configure time if a + # S3/C3 build lacks it (the version floor here can't be target-conditional). + 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' + 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 new file mode 100644 index 0000000000..da9adc24ca --- /dev/null +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -0,0 +1,350 @@ +#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" +#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. +/// +/// Status: works on ESP32-C6 (and the other open-NimBLE-controller chips) and on +/// ESP32-S3 (ESP-IDF >= v6.1) — advertising, the custom GATT tree, the 0x15 +/// pairing handshake, the full init/calibration command sequence, LL encryption, +/// bond persistence, continuous input-report streaming, reconnect, and +/// wake-from-sleep are all implemented and verified against a real console. The +/// console drives the link at a sub-spec 5 ms interval for sustained input, +/// reconnect, and wake; the S3/C3 get that from ESP-IDF's default-on +/// CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE (>= v6.1), the open-NimBLE chips +/// from the opt-in tools/patch_nimble_5ms.py (see the component README). +/// +/// \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}; + /// 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) — fine for games that do not use motion. On: replay + /// a captured 128-frame resting sequence — but it loops (~2 s at 62 Hz) so its + /// embedded timestamps jump backwards at the wrap. The driver owns the motion + /// block, so there is no per-report motion input today; a live-IMU path (a + /// motion setter on the report) is future work. + bool 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 and + /// on the ESP32-S3 (ESP-IDF >= v6.1). This is the recommended mode on every + /// supported target. **Off = on-change:** notify only when the app's + /// button/stick state changes, plus a low-rate keepalive — an optional + /// reduced-traffic mode (it was also a workaround for the pre-v6.1 S3 BTDM + /// tx-servicing stall, now fixed; see README "Known issues"). + bool continuous_streaming{true}; + /// Continuous-mode send divisor: send one report every Nth connection interval + /// (1 = every interval, 2 = every other, ...). The resulting rate depends on the + /// live interval the console chose: at the initial 15 ms, N=1 is ~62 Hz and N=2 + /// ~31 Hz; once the console moves the link to 5 ms, N=1 is ~200 Hz and N=2 + /// ~100 Hz. Diagnostic knob to separate a time-based stall (console + /// deprioritisation — stalls at the same wall-clock regardless of N) from a + /// packet-count-based one (our-side tx-credit accumulation — survives ~N× + /// longer). Ignored in on-change mode. + 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. + bool init(); + + /// Whether the pairing handshake has completed with a console. + bool is_paired() const { return paired_; } + + /// 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(); + /// `host_addr_le` is the paired console's BD_ADDR in wire (little-endian) order, + /// embedded verbatim for Reconnect/Wake; ignored for Discovery. + /// @return true if advertising actually started. + bool 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. + /// @return true if advertising actually started. + bool 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); + /// 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 + /// 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 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 + /// controller can satisfy the console's link-layer encryption request (the + /// Switch 2 uses standard LL encryption with the app-derived LTK, not SMP). + /// @return true if the LTK was written to the store; false on failure (LL + /// encryption will then fail and the console will drop the link). + bool inject_ltk(uint8_t peer_type, const uint8_t *peer_val_le); + /// Inject ltk_ for the currently-connected peer (used right after finalise). + /// @return true on success; false if there is no active connection or the + /// store write failed. + bool 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 continuous mode (default, Config::continuous_streaming + /// = true) it sends one report every connection interval like a real controller; + /// in on-change mode it sends only when the app state changes plus a low-rate + /// keepalive. Started in init(), stopped in the destructor. + void input_stream_loop(); + /// 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. + 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. + /// @param status NimBLE NOTIFY_TX outcome (0 = transmitted; nonzero = failed + /// attempt, e.g. ENOMEM). Completion telemetry only advances on 0. + void on_notify_tx(NimBLECharacteristic *characteristic, int status); + + 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_; + + // Per-characteristic callback objects. NimBLECharacteristic::setCallbacks() only + // stores the raw pointer and never deletes it, so we own them here (deleted via + // the virtual base after ble_gatt_server_.deinit() has torn down the + // characteristics in the destructor). + std::vector> channel_callbacks_; + + // 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. + /// 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}; + /// 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. + 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}; + 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) + std::atomic enomem_count_{0}; ///< diagnostic: notifies deferred because the tx pool was + ///< full (read cross-thread at disconnect) + uint32_t motion_idx_{0}; ///< index into kMotionSequence for the replayed IMU block + 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) + // --- 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 + std::atomic stream_start_us_{0}; ///< when the current streaming run began (0 = not + ///< started; read cross-thread at disconnect) + 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 + std::atomic wedge_reported_{ + false}; ///< one-shot guard for the wedge-onset log (read cross-thread at disconnect) + 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}; + /// 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 + 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. + /// 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_{}; +}; + +} // namespace espp 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..88880874aa --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_flash.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +/// @file switch2_pro_flash.hpp +/// @brief Simulated controller flash the console reads during init (command +/// 0x02 memory reads): device info, serial, colors and stick/IMU +/// 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 { + +// 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}; + +/// 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); + 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; +} + +} // namespace espp::switch2 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..3016968632 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_motion.hpp @@ -0,0 +1,404 @@ +#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. This sequence is replayed (looping) ONLY when +/// Config::stream_imu_motion is enabled; by default (false) send_input_report() +/// leaves the motion block all-zero, which the console also accepts. + +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_pairing.hpp b/components/switch2_pro/include/switch2_pro_pairing.hpp new file mode 100644 index 0000000000..b033bc3989 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_pairing.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#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..3f09d93347 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_protocol.hpp @@ -0,0 +1,187 @@ +#pragma once + +#include +#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_NO_RSP (matches a real controller). +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"; +/// 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 +// --------------------------------------------------------------------------- + +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. 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; + +// --------------------------------------------------------------------------- +// 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 + +/// 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; 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). +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..f5a8308cee --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_report.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include +#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). The three button bytes +/// are at report offsets 2, 3, 4 — data()[0] is the counter and data()[1] the +/// power byte — which is what the setters below write (set_bit(2/3/4, ...)): +/// report offset 2: 0x80 RStick 0x40 Plus 0x20 ZR 0x10 R 0x08 X 0x04 Y 0x02 A 0x01 B +/// report offset 3: 0x80 LStick 0x40 Minus 0x20 ZL 0x10 L 0x08 Up 0x04 Left 0x02 Right 0x01 Down +/// report offset 4: 0x10 C 0x08 GL 0x04 GR 0x02 Capture 0x01 Home +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); + 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); + } + + 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; + // Round (not truncate) so a neutral axis (v==0) maps to STICK_CENTER (2048), + // not 2047, while ±1 still land exactly on 0 / STICK_MAX (4095). + return static_cast((v * 0.5f + 0.5f) * STICK_MAX + 0.5f); + } + 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..ac53a0c87a --- /dev/null +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -0,0 +1,1307 @@ +#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" + +// 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; + +/// 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: + ChannelCallbacks(Switch2Pro *owner, const char *name, int role) + : owner_(owner) + , name_(name) + , role_(role) {} + + void onWrite(NimBLECharacteristic *characteristic, NimBLEConnInfo & /*conn*/) override { + auto value = characteristic->getValue(); + // 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) + 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"); + 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, code); + } + +private: + Switch2Pro *owner_; + const char *name_; + int role_; +}; + +bool Switch2Pro::init() { + // init() is one-shot. A second call would reach the input_stream_thread_ + // assignment below while the first streaming thread is still joinable, and + // assigning to a joinable std::thread calls std::terminate. Treat an already- + // initialized instance as a no-op success. + if (input_stream_thread_.joinable()) { + logger_.warn("init() called again while already initialized — ignoring"); + return true; + } + // 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. + 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; + } + + 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; + // Fail loudly if the stable address can't be installed: proceeding with an + // unknown/unstable address would make the 0x15 exchange advertise an address + // that doesn't match the persisted identity, silently breaking reconnect/wake. + if (!NimBLEDevice::setOwnAddr(rnd.data()) || + !NimBLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM)) { + logger_.error("BLE address: failed to install a stable static-random address; aborting " + "init (an unstable/mismatched address breaks pairing and reconnect/wake)"); + return false; + } + 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"); + return false; + } + ble_gatt_server_.start_services(); + ble_gatt_server_.start(); + log_handle_map(); // after start(), so handles are assigned + // 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_) { + if (!inject_ltk(bond_peer_type_, bond_peer_val_.data())) + logger_.error("reconnect: pre-loading the stored LTK failed — a bonded reconnect/wake " + "will not encrypt; delete the bond and re-pair to recover"); + if (wake_console_on_boot_) { + logger_.info( + "wake-on-boot: broadcasting the wake advertisement every {:.0f}s until connected", + wake_interval_.count()); + // Latch the wake state so the FIRST advertisement below is the wake variant. + // The periodic re-advertiser (start_wake_timer) is started only AFTER that + // advertisement succeeds — see below — so a failed init never leaves a + // background advertiser running on an object the caller thinks failed. + boot_wake_pending_ = true; // one-shot: cleared on the first successful connect + } + } + if (!advertise()) { + logger_.error("init failed: could not start advertising"); + return false; + } + // Now that the initial advertisement is on the air, start the periodic wake + // re-advertiser. boot_wake_pending_ is set only when reconnect_mode_ && + // wake_console_on_boot_, so this covers the wake-on-boot case; starting it here + // (after advertising succeeds) means a failed init never leaves a timer running. + if (boot_wake_pending_) + start_wake_timer(); + logger_.info("Switch2Pro advertising as '{}'", device_name_); + + // The input stream paces itself with sub-15 ms sleeps (down to ~5 ms once the + // console moves the link there). std::this_thread::sleep_for is tick-quantised, + // so a low FreeRTOS tick rate coarsens the cadence: at the 100 Hz default a 5 ms + // sleep rounds to ~10 ms and 15 ms to ~20 ms, desyncing from the connection + // interval. The example sets CONFIG_FREERTOS_HZ=1000; warn a consumer whose build + // did not. +#if CONFIG_FREERTOS_HZ < 1000 + logger_.warn("CONFIG_FREERTOS_HZ is {} (< 1000): input-stream pacing is tick-quantised, so the " + "per-connection-interval cadence (especially the console's 5 ms) will be coarse. " + "Set CONFIG_FREERTOS_HZ=1000 for an accurate stream rate.", + static_cast(CONFIG_FREERTOS_HZ)); +#endif + + // 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"; + 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; +} + +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(); + } + // 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() { + // 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); + // 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(); + pairing_stage_ = 0; // a fresh connection restarts the 0x15 handshake sequence + // NOTE: the wake latches (wake_pending_ / boot_wake_pending_) are deliberately + // NOT cleared here. A console we just woke can connect and then drop again before + // encryption/init completes; clearing on the raw connect event would make the + // ensuing disconnect fall back to the passive reconnect advertisement, when we + // still need the wake variant to keep nudging. They are cleared only once a + // usable (encrypted) session is confirmed — in the authentication_complete + // callback below — after which the wake timer self-cancels on its next tick. + // 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. + // NimBLE floors ble_gap_update_params at the 7.5 ms spec minimum, so we could + // not request the console's 5 ms even if we wanted to — and we don't need to: + // the console drives the interval itself. It connects at 15 ms, then on a FRESH + // session sends its own LL_CONNECTION_UPDATE down to 5 ms ~1.5 s after + // subscribing (verified on hardware), and on a bonded reconnect/wake it uses + // 5 ms straight from the CONNECT_IND. Accepting those sub-spec intervals is a + // controller-side capability (the official CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ + // ENABLE on S3/C3, or the NimBLE patch on C6-family), not something the host + // initiates. Kicking off our own update procedure right after connect also + // 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_.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 + // stopped at the wedge; staying up long after = the link outlived our tx stall. + // Snapshot stream_start_us_ ONCE: the streaming thread can reset it to 0 + // between a condition check and a later read (unsubscribe often precedes + // disconnect), which would otherwise yield an uptime-sized "streamed" value. + const int64_t started = stream_start_us_.load(); + if (started != 0) { + const int64_t now = esp_timer_get_time(); + logger_.warn(" @disconnect: streamed {:.1f}s, {} completions, {} enomem, wedged={}, " + "since_last_tx={:.0f}ms | {}", + (now - started) / 1e6f, tx_completions_.load(), enomem_count_.load(), + wedge_reported_.load(), (now - last_tx_complete_us_.load()) / 1000.0f, + pool_stats()); + } + // 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(); + }; + callbacks.conn_params_update_callback = [this](const 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) { + // Fires when the link's security state settles. This is EXPECTED in the normal + // (non-SMP) flow: after the 0x15 handshake we inject the LTK and the console + // starts standard LL encryption, which surfaces here with encrypted=true and no + // SMP exchange (see the fresh-pair log). encrypted/bonded/authenticated report + // the state reached — an encrypted=false here would mean the LTK was not + // accepted. + logger_.info("AUTH complete: encrypted={} bonded={} authenticated={}", info.isEncrypted(), + info.isBonded(), info.isAuthenticated()); + // A usable (encrypted) session is now established, so any pending wake has + // succeeded: drop the wake latches. Doing it here (not on the raw connect event) + // means a transient connect/drop before encryption keeps the wake variant on the + // air. boot_wake_pending_=false lets the wake timer self-cancel on its next tick + // (from its own task — never cancel/join it from a host-callback context). + if (info.isEncrypted()) { + wake_pending_ = false; + boot_wake_pending_ = false; + } + }; + ble_gatt_server_.set_callbacks(callbacks); +} + +void Switch2Pro::log_hex(const char *prefix, const uint8_t *data, size_t len) { + 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; + } + // 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() { + auto *server = ble_gatt_server_.server(); + 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. + auto attach = [this](NimBLECharacteristic *c, const char *name, int role) { + // We own the callback object (NimBLE only stores the raw pointer, never frees + // it) so it is not leaked on teardown and outlives the characteristic. + auto cb = std::make_unique(this, name, role); + c->setCallbacks(cb.get()); + channel_callbacks_.push_back(std::move(cb)); + }; + + // 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)); + 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. 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); + attach(command_, "command(0x0014)", 1); + vibration_command_ = + svc2->createCharacteristic(NimBLEUUID(VIBRATION_COMMAND_UUID), NIMBLE_PROPERTY::WRITE_NR); + attach(vibration_command_, "vib_command(0x0016)", 2); + // 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(); + return true; +} + +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(" 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()); +} + +bool Switch2Pro::start_advertising(AdvMode mode, const std::array &host_addr_le) { + auto mfr = MANUFACTURER_DATA_DISCOVERY; + 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 (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())) + 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; + const char *mode_name = mode == AdvMode::Discovery ? "discovery" + : mode == AdvMode::Reconnect ? "reconnect" + : "wake"; + if (!ble_gatt_server_.start_advertising(params)) { + logger_.error("failed to start advertising ({})", mode_name); + return false; + } + logger_.info("advertising ({}): flags+mfr({} B) in adv, name in scan response", mode_name, + mfr.size()); + return true; +} + +bool 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_ && (boot_wake_pending_ || wake_pending_)) + return start_advertising(AdvMode::Wake, host_addr_); + if (reconnect_mode_) + return start_advertising(AdvMode::Reconnect, host_addr_); + return start_advertising(AdvMode::Discovery); +} + +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 (!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 + if (!start_advertising(AdvMode::Wake, host_addr_)) { + wake_pending_ = false; // nothing is on the air — don't leave the latch set + logger_.error("wake: failed to start the wake advertisement"); + return false; + } + 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 { + // 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; // keep nudging until the first connection + }, + .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; +} + +// --------------------------------------------------------------------------- +// 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(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) + 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(); +} + +void Switch2Pro::send_ack(bool via_vibration_command, uint8_t cmd, uint8_t transport, uint8_t sub) { + // 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); +} + +// --------------------------------------------------------------------------- +// Command dispatch +// --------------------------------------------------------------------------- + +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; + } + 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; + + // 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); + } else { + handle_command(via_vibration_command, cmd, transport, sub, payload, payload_len); + } +} + +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_) { + // 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"); + } +} + +// 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, int status) { + if (characteristic != pro2_input_) + return; + // NimBLE fires this for every notification attempt, including immediate failures + // (e.g. ENOMEM), passing the outcome in `status`. Free the flow-control slot for + // the attempt either way (the host is done with it), but only count a real + // over-air completion when status == 0 — otherwise the wedge telemetry would log + // failed sends as successful completions and never show a stalled tx. + if (notify_in_flight_.load() > 0) + notify_in_flight_.fetch_sub(1); + if (status == 0) { + tx_completions_.fetch_add(1); + last_tx_complete_us_.store(esp_timer_get_time()); + } +} + +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 + // 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; + interval_tick_ = 0; // so the first tick of a new run always sends (divisor phase) + 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(); + 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; + } + + // 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 { + 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(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( + 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 + // 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; + } + + // 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_; + // 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_) { + // 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_.load(), buf[0], + buf[2], buf[3], buf[4], buf[0x0b], enabled_features_.load()); + return sent; +} + +bool 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); + if (rc != 0) { + // The LTK is not installed, so the console's imminent encryption request will + // fail and it will drop the link. Surface it loudly rather than logging "ready". + logger_.error("inject_ltk: ble_store_write_our_sec failed (rc={}) — LL encryption " + "cannot be established; the console will drop the link", + rc); + return false; + } + logger_.info("injected LTK into NimBLE store — ready for LL encryption"); + return true; +} + +bool 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 false; + } + 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; + } + esp_err_t set_err = nvs_set_blob(h, kNvsBondKey, &b, sizeof(b)); + esp_err_t commit_err = (set_err == ESP_OK) ? nvs_commit(h) : set_err; + nvs_close(h); + // Keep the bond in RAM regardless so this session can still reconnect/wake; only + // persistence across a reboot is lost if the write failed. + bond_peer_type_ = b.peer_type; + std::copy(std::begin(b.peer_val), std::end(b.peer_val), bond_peer_val_.begin()); + if (set_err != ESP_OK || commit_err != ESP_OK) + logger_.error("save_bond: NVS write failed (set={}, commit={}) — bond kept in RAM for " + "this boot, but reconnect/wake after a reboot will not work", + esp_err_to_name(set_err), esp_err_to_name(commit_err)); + else + 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: { + // 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()); + 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. + 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 {: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: { + // Request data is [0x00][A1 (16 bytes)] — skip the leading 0x00. + 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}; + 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: { + // Request data is [0x00][A2 challenge (16 bytes)] — skip the leading 0x00. + std::array b2{}; + 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}; + 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: { + // 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()); + paired_ = true; + logger_.info("pairing: finalised — bonded"); + // 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. + if (!inject_pairing_ltk()) + logger_.error("pairing: LTK injection failed — the console's encryption request will " + "fail and it will drop the link; re-pair to retry"); + // 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: + logger_.debug("pairing: unhandled subcommand 0x{:02x}", static_cast(sub)); + break; + } +} + +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); + // 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]; + 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, RSP_BYTE4_BT, + RSP_BYTE5_BT, reply.data(), reply.size()); + logger_.debug("flash read {} bytes @ 0x{:06x}", read_len, addr); + break; + } + 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: { + // 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; + } + 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::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: + 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 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..d9bcd2102d --- /dev/null +++ b/components/switch2_pro/src/switch2_pro_pairing.cpp @@ -0,0 +1,74 @@ +#include "switch2_pro_pairing.hpp" + +#include + +#include + +#include "esp_log.h" + +namespace { +constexpr const char *kPairingTag = "switch2::pairing"; +} // namespace + +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() is idempotent but non-trivial, so run it exactly once + // (self_test() at init and every live pairing share the same process init). + static std::once_flag psa_once; + static psa_status_t psa_init_status = PSA_ERROR_BAD_STATE; + std::call_once(psa_once, [] { psa_init_status = psa_crypto_init(); }); + if (psa_init_status != 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); + 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_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; +} + +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..983d7f7b40 --- /dev/null +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""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. + +This tool patches ONLY the open 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. That controller has no config +option for a sub-spec interval, so the binary patch is the only route there. + +ESP32-S3 / C3 are NOT patched by this tool. Use ESP-IDF >= v6.1's official, +default-on CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE instead (espressif/esp-idf +#18467; also backported to v6.0/v5.5/v5.4/v5.3) — that is the verified S3/C3 path, +needs no binary patching, and works on real hardware. A binary patch of the pre-fix +BTDM library (libbtdm_app.a, r_llc_con_upd_param_in_range) was explored but never +confirmed to enable 5 ms: patching that min-interval compare alone is reported +insufficient on the pre-fix S3 controller (esp-idf#18467), matching our own +testing where it had no effect, so it is not offered as a fallback. The +reverse-engineering notes remain in git history. + +WARNING: this modifies files inside your global $IDF_PATH install, affecting +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 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 +import os +import shutil +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 +# 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", + }, + # NOTE: the ESP32-S3 / C3 (BTDM / RivieraWaves controller) are deliberately NOT + # patch targets. Use ESP-IDF >= v6.1's official, default-on + # CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE instead (espressif/esp-idf#18467) — + # that is the verified S3/C3 path. A binary patch of the pre-fix libbtdm_app.a was + # explored but never confirmed to enable 5 ms on hardware: patching the + # min-interval compare in r_llc_con_upd_param_in_range alone is reported + # insufficient on the pre-fix S3 controller (see esp-idf#18467), matching our own + # testing where it had no effect. The reverse-engineering notes live in git + # history if a validated S3/C3 patch is pursued later. +} + + +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 + the ESP-IDF export script), then llvm-*, then the bare tool.""" + if explicit: + return explicit + 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 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 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, obj: str) -> bytes: + with tempfile.TemporaryDirectory() as tmp: + 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, obj: str, data: bytes) -> None: + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, obj), "wb") as f: + f.write(data) + # Run from tmp and pass ONLY the basename (`obj`), so the member name stored + # in the archive stays exactly `obj`. Passing an absolute/relative path can, + # on some ar variants, store the full path as the member name, which then + # breaks `ar x ` and any build expecting that object name. + # `lib` is absolute, so the changed cwd does not affect it. + subprocess.run([ar, "r", lib, obj], cwd=tmp, check=True) + + +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("--verify-only", action="store_true", help="report state, change nothing") + ap.add_argument("--restore", action="store_true", help="restore the .original backups") + ap.add_argument("--ar", default=None, + 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") + + 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: + 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 + + # 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) + # Already-patched: require EXACTLY one patched pattern and zero unpatched. + # A count > 1 (or a leftover old pattern) is ambiguous — refuse rather than + # assume it is safely patched. Matters most for the C3's short 2-byte + # signature, which is far likelier to occur incidentally. + if n_old == 0 and n_new > 0: + if n_new != 1: + sys.exit(f"{tag}: patched pattern appears {n_new}x in {obj} (expected 1) — " + f"ambiguous, refusing to touch") + 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") + # To patch: require EXACTLY one unpatched pattern and zero patched ones, so a + # mixed (partially-patched or coincidental) object is never patched. + if n_old != 1 or n_new != 0: + sys.exit(f"{tag}: expected exactly one unpatched pattern and none patched in {obj} " + f"(found unpatched={n_old}, patched={n_new}) — refusing to patch ambiguously") + 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 + + +if __name__ == "__main__": + sys.exit(main()) 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..574394e848 --- /dev/null +++ b/components/switch2_pro/tools/smoke_test_5ms.py @@ -0,0 +1,148 @@ +#!/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 (`addi a5, a4, -6`) => 5 ms REJECTED + patched -> the 5 ms floor instruction (`addi a5, a4, -4`) => 5 ms ACCEPTED + +Only the open RISC-V NimBLE controller (esp32c6/c61/c2/h2) is supported, matching +patch_nimble_5ms.py (S3/C3 use ESP-IDF >= v6.1's official option, not a patch). + +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 +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 + +# 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: Optional[str]) -> str: + r = subprocess.run([objdump, "-d", obj_path], capture_output=True, text=True) + if r.returncode != 0: + # Surface the real cause (wrong arch, corrupt object, missing tool) instead of + # treating empty stdout as an indeterminate verdict; the entry point maps this + # to exit code 2 (error), distinct from a valid unpatched (1) result. + raise RuntimeError(f"{objdump} -d {obj_path} failed (rc={r.returncode}): {r.stderr.strip()}") + out = r.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__": + # Honour the documented contract: 0 = patched, 1 = unpatched, 2 = error. A bare + # sys.exit("message") (here or in a reused helper) and any uncaught subprocess + # error would otherwise exit 1, making a missing archive/tool indistinguishable + # from a valid "unpatched" result. Map both to 2. + try: + sys.exit(main()) + except SystemExit as e: + if isinstance(e.code, int) or e.code is None: + raise # a real int return code (0/1/2) — preserve it + print(e.code, file=sys.stderr) # sys.exit("message") -> error + sys.exit(2) + except Exception as exc: # noqa: BLE001 — any unexpected failure is an error (2) + print(f"error: {exc}", file=sys.stderr) + sys.exit(2) diff --git a/doc/Doxyfile b/doc/Doxyfile index 5ac85abd7d..a71a7c08d9 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -184,6 +184,7 @@ 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/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 \ @@ -445,6 +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/switch2_pro/include/switch2_pro.hpp \ + $(PROJECT_PATH)/components/switch2_pro/include/switch2_pro_pairing.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 \ 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..b046a36903 --- /dev/null +++ b/doc/en/ble/switch2_pro.rst @@ -0,0 +1,76 @@ +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 targets: ESP32-C6** (and the other open-NimBLE-controller chips: + C61/C2/H2) **and ESP32-S3** — pairing, input streaming, reconnect, and + wake-from-sleep all verified against a real console. The S3 needs the console's + sub-spec 5 ms interval, which is now official in ESP-IDF via + ``CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`` (default on) — use **ESP-IDF + ≥ v6.1** (or the v6.0/v5.5/v5.4/v5.3 backports), where the S3 works with no + binary patch. See the component README's "The 5 ms connection interval" and + "Known issues". + +.. note:: + + Interoperability only. This component contains no Nintendo or Espressif + binaries; the pairing "authentication" relies on a published fixed key and is + a possession check, not per-device attestation. + +.. 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 +---------------------------- + +The console drives the link at a 5 ms connection interval, below the 7.5 ms +Bluetooth spec minimum. It reaches 5 ms in every mode: a bonded *reconnect* or +*wake* connects at 5 ms from the ``CONNECT_IND``, and a *fresh* session is +renegotiated down to 5 ms (``LL_CONNECTION_UPDATE``) about 1.5 s after the +console subscribes to input. Only the initial pairing handshake (~15 ms, the +first ~1.5 s) works without sub-spec support; **sustained input, reconnect, and +wake all need it**. How to enable it depends on the chip: + +- **ESP32-S3 / C3:** use **ESP-IDF ≥ v6.1** (or the v6.0/v5.5/v5.4/v5.3 + backports), where the official ``CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE`` + (default on; espressif/esp-idf#18467) makes the BTDM controller accept the 5 ms + interval with **no binary patch**. This is the verified S3/C3 path. +- **ESP32-C6 / C61 / C2 / H2:** the open NimBLE controller has no such option, so + enable the opt-in Kconfig option ``SWITCH2_PRO_PATCH_NIMBLE_5MS``, which + binary-patches the prebuilt controller library in your global ``$IDF_PATH`` + install (off by default — it mutates your ESP-IDF install). + +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_pairing.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 +```