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/components/ble_gatt_server/include/ble_gatt_server.hpp b/components/ble_gatt_server/include/ble_gatt_server.hpp index 44462f1423..c7694e2d17 100644 --- a/components/ble_gatt_server/include/ble_gatt_server.hpp +++ b/components/ble_gatt_server/include/ble_gatt_server.hpp @@ -69,6 +69,13 @@ class BleGattServer : public BaseComponent { /// @param conn_info The connection information for the device. typedef std::function authentication_complete_callback_t; + /// @brief Callback for when the connection parameters are updated (fires on + /// completion of any connection-parameter-update procedure — whether + /// peer- or self-initiated, accepted or rejected; read the live + /// parameters from conn_info to see the outcome). + /// @param conn_info The connection information for the device. + typedef std::function conn_params_update_callback_t; + /// @brief Callback to retrieve the passkey for the device. /// @return The passkey for the device. typedef std::function get_passkey_callback_t; @@ -131,6 +138,8 @@ class BleGattServer : public BaseComponent { nullptr; ///< Callback for when a device disconnects from the GATT server. authentication_complete_callback_t authentication_complete_callback = nullptr; ///< Callback for when a device completes authentication. + conn_params_update_callback_t conn_params_update_callback = + nullptr; ///< Callback for when the connection parameters are updated. get_passkey_callback_t get_passkey_callback = nullptr; ///< Callback for getting the passkey. /// @note If not provided, will simply return @@ -259,15 +268,26 @@ class BleGattServer : public BaseComponent { // set the server callbacks server_->setCallbacks(new BleGattServerCallbacks(this)); - // create the device info service - device_info_service_.init(server_); + if (builtin_info_services_) { + // create the device info service + device_info_service_.init(server_); - // create the battery service - battery_service_.init(server_); + // create the battery service + battery_service_.init(server_); + } return true; } + /// Enable or disable the built-in Device Information and Battery services. + /// @param enabled Whether init()/start_services() create and start the + /// built-in Device Information (0x180A) and Battery (0x180F) services. + /// Defaults to true. Set to false BEFORE init() for peripherals that + /// must expose only their own services (e.g. emulating a device whose + /// GATT layout must match a specific attribute table). + /// @note Must be called before init(). + void set_builtin_info_services_enabled(bool enabled) { builtin_info_services_ = enabled; } + /// Deinitialize the GATT server /// This method deletes the server and all associated objects. /// It also invalidates any references/pointers to the server. @@ -283,8 +303,10 @@ class BleGattServer : public BaseComponent { } // deinitialize the services - device_info_service_.deinit(); - battery_service_.deinit(); + if (builtin_info_services_) { + device_info_service_.deinit(); + battery_service_.deinit(); + } // if true, deletes all server/advertising/scan/client objects which // invalidates any references/pointers to them bool clear_all = true; @@ -296,8 +318,10 @@ class BleGattServer : public BaseComponent { /// Start the services /// This method starts the device info and battery services. void start_services() { - device_info_service_.start(); - battery_service_.start(); + if (builtin_info_services_) { + device_info_service_.start(); + battery_service_.start(); + } } /// Start the server @@ -806,6 +830,8 @@ class BleGattServer : public BaseComponent { NimBLEServer *server_{nullptr}; ///< The GATT server. DeviceInfoService device_info_service_; ///< The device info service. BatteryService battery_service_; ///< The battery service. + bool builtin_info_services_{ + true}; ///< Whether to create/start the built-in DIS + battery services. }; } // namespace espp diff --git a/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp b/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp index e2b52016e8..398f6bc911 100644 --- a/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp +++ b/components/ble_gatt_server/include/ble_gatt_server_callbacks.hpp @@ -16,6 +16,7 @@ class BleGattServerCallbacks : public NimBLEServerCallbacks { virtual void onConnect(NimBLEServer *server, NimBLEConnInfo &conn_info) override; virtual void onDisconnect(NimBLEServer *server, NimBLEConnInfo &conn_info, int reason) override; virtual void onAuthenticationComplete(NimBLEConnInfo &conn_info) override; + virtual void onConnParamsUpdate(NimBLEConnInfo &conn_info) override; virtual uint32_t onPassKeyDisplay() override; virtual void onConfirmPassKey(NimBLEConnInfo &conn_info, uint32_t pass_key) override; diff --git a/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp b/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp index ff5eed31df..82c27ade8e 100644 --- a/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp +++ b/components/ble_gatt_server/src/ble_gatt_server_callbacks.cpp @@ -62,6 +62,13 @@ void BleGattServerCallbacks::onAuthenticationComplete(NimBLEConnInfo &conn_info) } } } +void BleGattServerCallbacks::onConnParamsUpdate(NimBLEConnInfo &conn_info) { + if (server_) { + if (server_->callbacks_.conn_params_update_callback) { + server_->callbacks_.conn_params_update_callback(conn_info); + } + } +} uint32_t BleGattServerCallbacks::onPassKeyDisplay() { if (server_ && server_->callbacks_.get_passkey_callback) { return server_->callbacks_.get_passkey_callback(); 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..bd5e7e1522 --- /dev/null +++ b/components/switch2_pro/CMakeLists.txt @@ -0,0 +1,37 @@ +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 the RISC-V NimBLE +# controller (C6/C61/C2/H2, libble_app.a) and the BTDM/RivieraWaves controller +# (S3/C3, libbtdm_app.a) — the patcher picks the right object + byte pattern per +# target. Mutates the global $IDF_PATH install, so it is deliberately explicit +# and never silent. +if(CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS) + if(IDF_TARGET STREQUAL "esp32c6" OR IDF_TARGET STREQUAL "esp32c61" + OR IDF_TARGET STREQUAL "esp32c2" OR IDF_TARGET STREQUAL "esp32h2" + OR IDF_TARGET STREQUAL "esp32s3" OR IDF_TARGET STREQUAL "esp32c3") + 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() + 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, " + "S3/C3 BTDM).") + endif() +endif() diff --git a/components/switch2_pro/DESIGN.md b/components/switch2_pro/DESIGN.md new file mode 100644 index 0000000000..685513ddbe --- /dev/null +++ b/components/switch2_pro/DESIGN.md @@ -0,0 +1,132 @@ +# switch2_pro — design notes + +## Goal + +Emulate a **Nintendo Switch 2 Pro Controller over BLE** so a real Switch 2 console +accepts it as a native controller — including waking the console from sleep over BLE. + +This is NOT the Switch 1 protocol. The Switch 2 moved controllers from Bluetooth +Classic HID to **BLE with a proprietary GATT layer** (not HID-over-GATT), a custom +pairing scheme (not BLE SMP), and a custom command channel. So espp's existing +`hid_service` / `hid-rp` (standard HOGP + report descriptors) do **not** apply here; +this component builds custom GATT services directly on `espp::BleGattServer`. + +## Sources / prior art + +- **Protocol facts**: `ndeadly/switch2_controller_research` (byte-level GATT map, + pairing handshake, command set, report formats; decrypted sniffer captures). +- **Working ESP32 reference** (MIT): `zhantss/ESP32-BLE5-NSController-Emulator` — + raw-NimBLE C emulator that a real Switch 2 accepts. We adapt its *approach and + structure* (with attribution) and reimplement on esp-nimble-cpp / `BleGattServer`. + We do **not** copy ndeadly's prose/tables wholesale, and we do **not** vendor + Espressif's `libble_app.a`. + +## Feasibility (verified) + +Not blocked by cryptographic attestation. The pairing "authentication" is weak and +reproducible: a **fixed controller key** `B1 = 5CF6EE792CDF05E1BA2B6325C41A5F10`, an +XOR-derived link key `LTK = A1 ⊕ B1`, and a single AES-128-ECB possession proof +`B2 = AES_ECB(reverse(LTK), reverse(A2))`. Golden vector (host-verified with openssl): + + A1 = 3503e92982877124bea80c664615834b (host public key, from console) + B1 = 5cf6ee792cdf05e1ba2b6325c41a5f10 (fixed controller key) + A2 = 6fc6df8ad8fedf15bb8c15e91f320544 (host challenge) + LTK = 69f50750ae5874c504836f43820fdc5b (= A1 ⊕ B1) + B2 = 134c97f511b9b6dd4d86fd40f536e9ed (= AES-128-ECB(rev(LTK), rev(A2))) + +`switch2_pro_pairing.*` implements this and self-tests against the golden vector at +init (logged pass/fail) — verifiable on-device with no console. + +## The 5 ms connection-interval problem + +The console drives the link at a **5 ms** connection interval — below the 7.5 ms BLE +spec minimum. The controller stack must accept it or the console won't stream input. + +Both chip families keep the 7.5 ms floor as a hard compare inside a closed controller +library, and both are patchable with a single-instruction edit that lowers the floor +to 4 units (5 ms). `tools/patch_nimble_5ms.py` picks the right archive, object and byte +pattern per `--target`; `tools/smoke_test_5ms.py` proves the edit at the disassembly +level with no hardware. + +- **C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): patch + `$IDF_PATH/.../libble_app.a`, object `ble_ll_conn.c.o`. The floor is + `addi a5, a4, -6`; flip the immediate to `-4` (`93 07 a7 ff` → `93 07 c7 ff`). + Adapted from zhantss (MIT). +- **S3 / C3** (BTDM / RivieraWaves controller, `lib_esp32c3_family/*/libbtdm_app.a` + and the `libbtdm_app_flash.a` variant): patch object `llc_con_upd.o`, function + `r_llc_con_upd_param_in_range` — the peripheral-side connection-parameter validator + the console's `LL_CONNECTION_PARAM_REQ` / `LL_CONNECTION_UPDATE_IND` path runs + through (confirmed: its only caller is the RivieraWaves `ip_funcs` jump table; its + siblings are `ll_connection_param_req_handler` / `ll_connection_update_ind_handler`). + The floor is a compare of the requested min-interval against 6: + - **S3** (Xtensa): `bltui a4, 6` → `bltui a4, 4` (`b6 64 01` → `b6 44 01`). + - **C3** (RISC-V): `li a6,5; bgeu a6,a2` → `li a6,3` (`15 48` → `0d 48`). + + Both reverse-engineered here from the same reject-below-6 semantics as the C6 patch; + each is the single unique occurrence in its object (asserted by the patcher). The + latency bound sitting right beside the floor (`499` = `0x1f3`, the BLE max latency) + confirms the surrounding code is the connection-parameter range check. This replaces + the earlier note about `CONFIG_BT_CTRL_BLE_MIN_CONN_INTERVAL_ENABLE` / + esp-idf#18467, which does **not** exist on IDF 6.0.1. + +**Build integration (decision: opt-in, never silent).** The patch mutates the user's +global IDF install and is version-fragile (the 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 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) + +1. **GATT + pairing skeleton**: custom GATT tree stands up, advertises with + Nintendo manufacturer data, completes the 0x15 pairing handshake (crypto + known-answer verified) and the console accepts pairing. +2. **Command dispatch + init sequence** (flash/calibration reads, feature-select, + LEDs, firmware-update-prompt suppression) so the console finishes bring-up. +3. **Input report streaming** (report 0x09: buttons incl. C/GL/GR, 12-bit sticks, + IMU block) streamed continuously at the console's 15 ms / ~62 Hz cadence with + real backpressure. Runs on the spec-legal 15 ms interval — no patch needed. +4. **Reconnect + wake-from-sleep** (bonded reconnect with the 0x81 wake flag). + The console connects a bonded controller at 5 ms, so these need the opt-in + `SWITCH2_PRO_PATCH_NIMBLE_5MS` controller patch. + +On the ESP32-S3 the BTDM controller does not yet sustain the encrypted input +stream (see the README "Known issues"); C6-class chips (open NimBLE controller) +are the supported target. + +## Component layout + + 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 BTDM) + tools/smoke_test_5ms.py hardware-free verifier (disassembles the controller floor) + Kconfig SWITCH2_PRO_PATCH_NIMBLE_5MS opt-in + example/ C6-primary, S3-buildable diff --git a/components/switch2_pro/Kconfig b/components/switch2_pro/Kconfig new file mode 100644 index 0000000000..060b055af6 --- /dev/null +++ b/components/switch2_pro/Kconfig @@ -0,0 +1,26 @@ +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 + On RECONNECT and WAKE-FROM-SLEEP the console connects a recognised + (bonded) controller at a 5 ms connection interval, below the 7.5 ms + Bluetooth spec minimum, chosen in its CONNECT_IND. The controller + stack must accept that sub-spec interval or the connection never + forms. + + When enabled, the component build patches the prebuilt closed + controller library in your global $IDF_PATH install to lower the + minimum-interval floor from 6 units (7.5 ms) to 4 (5 ms): + * ESP32-C6/C61/C2/H2 — NimBLE controller (libble_app.a) + * ESP32-S3/C3 — BTDM/RivieraWaves controller (libbtdm_app.a) + THIS MODIFIES YOUR ESP-IDF INSTALLATION. Undo with + tools/patch_nimble_5ms.py --target --restore, and verify with + tools/smoke_test_5ms.py --target . + + Required for reconnect and wake-from-sleep. NOT required for fresh + pairing or first-session input streaming (those use the spec-legal + 15 ms interval). Leave OFF unless you understand the consequences. + +endmenu diff --git a/components/switch2_pro/README.md b/components/switch2_pro/README.md new file mode 100644 index 0000000000..186e45592a --- /dev/null +++ b/components/switch2_pro/README.md @@ -0,0 +1,149 @@ +# 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 15 ms connection +> interval, ~62 Hz — matching a real controller's cadence), buttons and sticks +> register on the console's "Test Input Devices" screen, and **reconnect** +> (including the console reconnecting on its own after a reboot) and +> **wake-from-sleep** both work without re-pairing. What's implemented: +> advertising with Nintendo manufacturer data, the exact GATT handle layout, +> the reverse-engineered pairing crypto (known-answer verified) + LTK injection +> for standard LL encryption, the full console init/command sequence, bond +> persistence (NVS), and continuous input-report streaming with real +> backpressure. +> +> **ESP32-S3: pairs, but input only works in short bursts.** The S3's *closed* +> BTDM BLE controller stops servicing notification tx ~3 s into any sustained +> encrypted stream (rate-independent; host and buffers healthy) and the link +> then supervision-times-out — see "Known issues" below. Use a chip from the +> open-NimBLE-controller family (C6/C61/C2/H2) instead. + +Unlike the original Switch (Bluetooth Classic HID), the Switch 2 uses a +**proprietary BLE GATT interface — not HID-over-GATT — and a custom pairing +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 reconnect & wake) + +The console chooses the connection interval **in its `CONNECT_IND`, based on +whether it recognises the controller**: + +- **Fresh pairing:** 15 ms — spec-legal, works on a stock controller. The + console then services continuous 62 Hz input at 15 ms indefinitely (it never + renegotiates mid-session), which is why pairing and first-session input work + unpatched. +- **Reconnect / wake (bonded controller):** `CONNECT_IND interval=4` — **5 ms + from the very first packet**, below the 7.5 ms Bluetooth spec minimum + (verified in both the reconnect and wake captures). A stock controller + cannot accept that connection, so reconnect/wake silently fail: the console + wakes on our advertisement, attempts the 5 ms connection, and gives up. + +So the controller patch is **required for reconnect and wake-from-sleep** — but +it is **off by default**, because enabling it mutates the prebuilt controller +library in your global `$IDF_PATH`, which is too invasive to do silently. Fresh +pairing and first-session input work without it; enable it (uncomment +`CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS` in the example's `sdkconfig.defaults`, or +run the tool directly) to get reconnect/wake. The Kconfig option +**`SWITCH2_PRO_PATCH_NIMBLE_5MS`** makes the component build run +`tools/patch_nimble_5ms.py`, which **binary-patches the prebuilt controller +library** to lower the minimum-interval floor from 6 units (7.5 ms) to 4 (5 ms): + +- **ESP32-C6 / C61 / C2 / H2** (RISC-V, open NimBLE controller): `libble_app.a`, + `addi a5,a4,-6` → `-4`. +- **ESP32-S3 / C3** (BTDM / RivieraWaves controller): `libbtdm_app.a` (+ the + `_flash` variant), `r_llc_con_upd_param_in_range` — S3 `bltui a4,6` → `bltui + a4,4`, C3 `li a6,5` → `li a6,3`. + +This modifies your ESP-IDF installation; undo with `python +tools/patch_nimble_5ms.py --target --restore`, and check the current state +(no hardware needed) with `python tools/smoke_test_5ms.py --target `, which +disassembles the controller and reports whether 5 ms is accepted or rejected. + +## Stability notes & known issues + +**ESP32-S3: the closed BTDM BLE controller stalls sustained notification tx.** +The one unresolved issue, isolated by elimination on real hardware: ~3 s into +any sustained encrypted notification stream, the S3's controller stops +servicing tx — completions become sporadic (hundreds of ms apart), input dies, +and the link eventually supervision-times-out. Everything host-side is +demonstrably healthy at that moment (mbuf pools near-full, controller ACL +buffers free, host task responsive), and the stall time is **independent of the +send rate** (62 Hz and 31 Hz stall at the same wall-clock, ruling out +per-packet resource exhaustion). The identical firmware on an **ESP32-C6** +(open-source NimBLE controller) streams indefinitely with zero distress — and +reconnect-after-console-reboot, which never worked on the S3, works on the C6. +Conclusion: an S3 BTDM controller bug/incompatibility on this link +configuration (2M PHY + LL encryption + sustained peripheral notifications), +unreachable from the host. On the S3, on-change streaming +(`Config::continuous_streaming = false`) reduces traffic enough to partially +mask it (input works in bursts); the real fix is using a C6-class chip. + +Diagnostics that survive in the driver (useful if this ever regresses): a +per-500 ms stream-health debug log (drain rate, backpressure skips, ENOMEM +count, per-pool free/low-water), a one-shot `TX WEDGE` warning pinpointing the +first ENOMEM's origin (host mbuf vs downstream), and an `@disconnect` summary +tying the disconnect to the tx timeline. + +**Streaming model.** `set_input_report()` only stores the latest state; a +driver-owned task streams it — by default **continuously**, one report per live +connection interval with the byte-0 counter incrementing every report, matching +a real controller (verified in captures: a real device streams 62 Hz at the +15 ms pairing interval). The task applies **real backpressure** by capping +un-drained host mbufs (the `NOTIFY_TX` event fires at host→controller handoff, +*not* over-air completion, so counting those cannot detect a backlog — the mbuf +pool level can). The 40-byte IMU motion block is sent all-zero by default +(accepted by the console); `Config::stream_imu_motion` replays captured frames +instead. + +**Dedicate a core to BLE (dual-core chips).** With everything on core 0 (the +ESP-IDF default), a fast input-notify loop starves the NimBLE host and the link +supervision-times-out within seconds. The example's `sdkconfig.defaults` pins +the BLE controller and host to core 1 on dual-core targets and enlarges the +NimBLE mbuf/ACL pools. Also keep HCI commands (e.g. `ble_gap_read_le_phy`) out +of any per-interval loop — at 62 Hz they flood the HCI path and stall the +host's data tx. + +**What the captures established** (ndeadly's `nrf52840` captures, decrypted, +via `tshark`): a real controller's fresh pairing runs at **15 ms** and its +reconnect **and wake** at **5 ms**, fixed in the `CONNECT_IND` and never +renegotiated mid-session (zero `LL_CONNECTION_UPDATE_IND` / +`LL_CONNECTION_PARAM_REQ` in any capture; the only post-connect LL change is +the PHY switch to 2M). Active-use motion captures run at ~7.5 ms / 133 Hz for +minutes. The console services an emulated controller's *fresh-pair* session at +15 ms / 62 Hz indefinitely, but connects to a *bonded* controller only at 5 ms +— which is why the controller patch is required for reconnect/wake (see "The +5 ms connection interval" above). Advertising variants: the bonded +manufacturer-data payload embeds the console's identity address, with flag +byte `0x00` for passive reconnect presence and `0x81` ("user pressed a button +— connect to me") for wake; an idle console ignores the passive variant, so +user-initiated wake should broadcast `0x81` (see `wake_console()`). + +## Attribution + +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 (recommended; also builds +for S3, with the streaming caveat above). It brings up the controller, runs the +pairing-crypto self-test, advertises for a console to pair with, and maps the +BOOT button to A (GPIO0 on Xtensa boards, GPIO9 on the RISC-V devkits). diff --git a/components/switch2_pro/example/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..9ca73df67e --- /dev/null +++ b/components/switch2_pro/example/README.md @@ -0,0 +1,64 @@ +# 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 target: ESP32-C6** (default). Pairing, input, reconnect, and +> wake-from-sleep all work against a real console. The **ESP32-S3 builds and +> pairs but does not yet stream input reliably** (see the component README's +> "Known issues"). RISC-V siblings (C61/C2/H2) use the same open NimBLE +> controller as the C6. + +## Build, flash, monitor + +Default target is **esp32c6**: + +```bash +idf.py build flash monitor +``` + +Reconnect and wake-from-sleep require the console's sub-spec 5 ms connection +interval, which needs the opt-in controller patch (off by default because it +modifies your global `$IDF_PATH` install). Fresh pairing and input streaming +work **without** it. To enable it: + +```bash +idf.py 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 also builds (`idf.py set-target esp32s3`), but see the caveat above. + +## Testing with a real Switch 2 + +1. Flash and open the monitor. You should see `pairing crypto self-test passed`, + 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 (5 ms patch enabled) + +After the first pairing the bond is saved to NVS. On the next boot the controller +advertises for reconnection. With the console asleep, press **BOOT** while the +log shows `connected=false` to broadcast the wake advertisement — the console +should power on and reconnect without re-pairing. + +## Feeding real input + +`set_input_report()` just stores the latest state; a driver-owned task paces the +BLE notifications. Replace the BOOT-button read in `main` with your real +button/stick source and call `set_input_report()` whenever state changes. diff --git a/components/switch2_pro/example/main/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..f7e6d80703 --- /dev/null +++ b/components/switch2_pro/example/main/switch2_pro_example.cpp @@ -0,0 +1,124 @@ +#include +#include + +#include "driver/gpio.h" +#include "nvs_flash.h" + +#include "switch2_pro.hpp" + +#include "logger.hpp" + +using namespace std::chrono_literals; + +// The BOOT button doubles as the A button for boards without dedicated buttons. +// It reads low when pressed. GPIO0 on Xtensa (S3/S2/classic); GPIO9 on the +// RISC-V chips (C6/C61/C3/C2/H2 devkits). +#if CONFIG_IDF_TARGET_ESP32 || CONFIG_IDF_TARGET_ESP32S2 || CONFIG_IDF_TARGET_ESP32S3 +static constexpr gpio_num_t kBootButtonGpio = GPIO_NUM_0; +#else +static constexpr gpio_num_t kBootButtonGpio = GPIO_NUM_9; +#endif + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "switch2_pro example", .level = espp::Logger::Verbosity::INFO}); + + // Bond persistence (LTK + console address) is stored in NVS so the controller + // reconnects after a reboot without re-pairing. + esp_err_t nvs_err = nvs_flash_init(); + if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + nvs_flash_init(); + } + + //! [switch2_pro example] + // Bring up the emulated Switch 2 Pro Controller. init() verifies the pairing + // crypto against a known-answer vector, builds the custom Nintendo GATT + // services, configures security so the console (not BLE SMP) drives pairing, + // and starts advertising with Nintendo manufacturer data. + // + // 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..a55a794833 --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults @@ -0,0 +1,85 @@ +# Default target is esp32c6 — the RECOMMENDED chip (open-source NimBLE BLE +# controller): pairing, reconnect, wake, and sustained lag-free 62 Hz input +# streaming all verified against a real Switch 2. The ESP32-S3 builds and pairs, +# but its closed BTDM BLE controller stops servicing tx ~3 s into any sustained +# encrypted notification stream (host-side unfixable — see README "Known +# issues"), so S3 input only works in short bursts. +CONFIG_IDF_TARGET="esp32c6" + +# On the ESP32-S3 (native USB), route the console to USB-Serial-JTAG so the +# monitor shows the pairing trace. Harmless on boards with a UART bridge too. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Common ESP-related +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# 1000 Hz tick so the ~15 ms input-stream cadence is representable (at the 100 Hz +# default, sleep_for(15ms) rounds to a 20 ms tick, desyncing from the connection +# interval), and 240 MHz for headroom in the BLE host + input path. +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240 +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# Partition Table +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. Note this does NOT fix the +# S3 BTDM tx-servicing stall (see README "Known issues") — it only removes one +# allocation failure mode under sustained streaming. +CONFIG_BT_CTRL_BLE_STATIC_ACL_TX_BUF_NB=12 + +# NOTE: MAX_CCCDS should be 4 * MAX_BONDS +CONFIG_BT_NIMBLE_MAX_BONDS=3 +CONFIG_BT_NIMBLE_MAX_CCCDS=128 + +# OFF by default: enabling it mutates the prebuilt BLE controller lib in your +# global $IDF_PATH at configure time, which is too invasive to do silently in a +# released example. But it is REQUIRED for reconnect and wake-from-sleep: the +# capture shows the console connects to a RECOGNISED (bonded) controller with +# `CONNECT_IND interval=4` (5 ms, below the 7.5 ms spec minimum) from the very +# first packet, so a stock controller cannot complete a reconnect/wake connection. +# Fresh pairing (15 ms) and first-session input streaming work WITHOUT the patch. +# To enable reconnect/wake: uncomment below (or run +# `tools/patch_nimble_5ms.py --target `), then verify with +# `tools/smoke_test_5ms.py --target `; undo with `--restore`. +# CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS=y diff --git a/components/switch2_pro/example/sdkconfig.defaults.esp32c6 b/components/switch2_pro/example/sdkconfig.defaults.esp32c6 new file mode 100644 index 0000000000..b6ad185ff5 --- /dev/null +++ b/components/switch2_pro/example/sdkconfig.defaults.esp32c6 @@ -0,0 +1,14 @@ +# ESP32-C6-specific defaults (applied on top of sdkconfig.defaults when the +# target is esp32c6; S3-only options in the base file are ignored here). +# +# The C6 RISC-V GCC 15.2 toolchain (esp-15.2.0_20251204) fails to compile +# picolibc's hal/assert.h (__noreturn=[[noreturn]] -Werror=attributes), so use +# newlib. Xtensa (S3) compiles picolibc fine — this is C6-toolchain-specific. +CONFIG_LIBC_NEWLIB=y + +# The C6 is single-core: the core-pinning options from the base defaults don't +# exist here (silently ignored). The C6's BLE controller is the OPEN-SOURCE +# NimBLE controller (libble_app.a) — the same one the known-working zhantss +# emulator was verified on — patched for 5 ms by the same +# CONFIG_SWITCH2_PRO_PATCH_NIMBLE_5MS option (different lib/instruction than +# the S3's closed BTDM controller). diff --git a/components/switch2_pro/idf_component.yml b/components/switch2_pro/idf_component.yml new file mode 100644 index 0000000000..8c67101bc2 --- /dev/null +++ b/components/switch2_pro/idf_component.yml @@ -0,0 +1,33 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Emulate a Nintendo Switch 2 Pro Controller over BLE (custom GATT + reverse-engineered pairing) so a real Switch 2 accepts it as a native controller and can be woken from sleep." +url: "https://github.com/esp-cpp/espp/tree/main/components/switch2_pro" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/ble/switch2_pro.html" +examples: + - path: example +tags: + - cpp + - Component + - BLE + - NimBLE + - HID + - Gamepad + - Nintendo + - Switch2 +dependencies: + idf: + version: '>=5.5' + # 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..68dd9f5b44 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro.hpp @@ -0,0 +1,321 @@ +#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) — +/// advertising, the custom GATT tree, the 0x15 pairing handshake, the full +/// init/calibration command sequence, LL encryption, bond persistence, continuous +/// input-report streaming, reconnect, and wake-from-sleep are all implemented and +/// verified against a real console. The ESP32-S3 builds and pairs but does not yet +/// stream input reliably (see the component README). Reconnect and wake-from-sleep +/// use the console's sub-spec 5 ms interval, which requires the opt-in NimBLE patch +/// (tools/patch_nimble_5ms.py). +/// +/// \section switch2_pro_ex1 Example +/// \snippet switch2_pro_example.cpp switch2_pro example +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). On: replay a captured 128-frame resting sequence — + /// but it loops (~2 s at 62 Hz) so its embedded timestamps jump backwards at + /// the wrap; prefer feeding real IMU data via the report instead. + bool stream_imu_motion{false}; + /// Streaming model. **On (default) = continuous:** send one report every + /// connection interval with the counter incrementing each time, exactly like + /// a real controller — verified stable and lag-free on the C6-class chips + /// (open NimBLE controller) at the console's 15 ms / 62 Hz. **Off = + /// on-change:** notify only when the app's button/stick state changes, plus a + /// low-rate keepalive — a reduced-traffic fallback that partially masks the + /// ESP32-S3 BTDM controller's tx-servicing bug (see README "Known issues"). + bool continuous_streaming{true}; + /// Continuous-mode send divisor: send one report every Nth connection interval + /// (1 = every interval / 62 Hz, 2 = every other / 31 Hz, ...). Diagnostic knob + /// to separate a time-based stall (console deprioritisation — stalls at the + /// same wall-clock regardless of N) from a packet-count-based one (our-side + /// tx-credit accumulation — survives ~N× longer). Ignored in on-change mode. + uint32_t continuous_stream_divisor{1}; + }; + + explicit Switch2Pro(const Config &config) + : BaseComponent("Switch2Pro", config.log_level) + , device_name_(config.device_name) + , wake_console_on_boot_(config.wake_console_on_boot) + , wake_interval_(config.wake_interval) + , stream_imu_motion_(config.stream_imu_motion) + , continuous_streaming_(config.continuous_streaming) + , continuous_stream_divisor_( + config.continuous_stream_divisor ? config.continuous_stream_divisor : 1) + , ble_gatt_server_({.callbacks = {}, .log_level = Logger::Verbosity::WARN}) {} + + /// Stop the input-streaming task on teardown. + ~Switch2Pro(); + + /// Initialize NimBLE, build the custom GATT services, configure security so + /// the console (not standard SMP) drives pairing, and start advertising. + /// @return true on success. + 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. + void start_advertising(AdvMode mode, const std::array &host_addr_le = {}); + /// Advertise in the mode appropriate to the current state: Wake (with the stored + /// console address) if bonded and wake-on-boot is enabled, else Reconnect if + /// bonded, else Discovery. + void advertise(); + /// Start a periodic timer that re-issues the wake advertisement (via advertise()) + /// every wake_interval_ while disconnected, so a sleeping console keeps getting + /// nudged until it wakes and reconnects. No-op if already running. + void start_wake_timer(); + + /// Log a byte buffer as hex at debug level (command/response tracing). + void log_hex(const char *prefix, const uint8_t *data, size_t len); + /// 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). + void inject_ltk(uint8_t peer_type, const uint8_t *peer_val_le); + /// Inject ltk_ for the currently-connected peer (used right after finalise). + void inject_pairing_ltk(); + /// Persist the bond {console address, LTK} to NVS so it survives reboots and + /// the controller can reconnect/wake without re-pairing. + void save_bond(); + /// Load a persisted bond into bond_peer_* / ltk_. Returns true if one exists. + bool load_bond(); + /// Our own BT address (6 bytes) for the exchange-addresses reply. + std::array local_bt_address() const; + + /// Driver-owned streaming task: while the console is subscribed, notify the + /// input report on 0x000e. In on-change mode (default) it sends only when the + /// app state changes plus a low-rate keepalive; in continuous mode it sends one + /// report every connection interval like a real controller. Started in init(), + /// stopped in the destructor. + void input_stream_loop(); + /// Send 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. + void on_notify_tx(NimBLECharacteristic *characteristic); + + friend class ChannelCallbacks; + + std::string device_name_; + bool wake_console_on_boot_; + std::chrono::duration wake_interval_; + bool stream_imu_motion_; + bool continuous_streaming_; ///< see Config::continuous_streaming (on-change vs per-interval) + uint32_t + continuous_stream_divisor_; ///< see Config::continuous_stream_divisor (rate-halving probe) + std::shared_ptr wake_timer_; ///< re-issues the wake advertisement until connected + BleGattServer ble_gatt_server_; + + // Proprietary GATT characteristics (owned by NimBLE once created). + NimBLECharacteristic *common_input_{nullptr}; + NimBLECharacteristic *pro2_input_{nullptr}; + NimBLECharacteristic *command_{nullptr}; + NimBLECharacteristic *vibration_command_{nullptr}; + NimBLECharacteristic *command_response1_{nullptr}; + NimBLECharacteristic *command_response2_{nullptr}; + + // Pairing state. + bool paired_{false}; + /// Highest completed 0x15 pairing step this connection: 0=none, 1=exchange + /// addresses, 2=exchange keys, 3=confirm LTK. FINALISE (step 4) is only accepted + /// when this is 3, so an out-of-order/malformed peer cannot persist a bad bond. + /// Reset to 0 on each new connection. + uint8_t pairing_stage_{0}; + bool reconnect_mode_{false}; ///< booted with a stored bond (reconnect, not fresh pair) + /// 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) + uint32_t enomem_count_{0}; ///< diagnostic: notifies deferred because the tx pool was full + uint32_t motion_idx_{0}; ///< index into kMotionSequence for the replayed IMU block + 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 + int64_t stream_start_us_{0}; ///< when the current streaming run began (0 = not started) + int64_t hb_last_us_{0}; ///< last heartbeat timestamp + uint32_t hb_last_completions_{ + 0}; ///< tx_completions_ snapshot at last heartbeat (drain-rate delta) + uint32_t hb_last_enomem_{0}; ///< enomem_count_ snapshot at last heartbeat + uint32_t send_attempts_{0}; ///< send_input_report() calls this streaming run + uint32_t backpressure_skips_{0}; ///< sends deferred because msys_1 had no headroom + bool wedge_reported_{false}; ///< one-shot guard for the wedge-onset log + std::mutex input_mutex_; ///< guards input_report_ (set from app task, read by stream task) + std::thread input_stream_thread_; ///< streams input reports once per connection interval + std::atomic stream_stop_{false}; ///< signals input_stream_thread_ to exit + // Last-observed link state, logged whenever it changes so we can see exactly + // what the console renegotiates at the pairing->active transition. + uint16_t last_itvl_{0}; + uint16_t last_latency_{0xffff}; + uint8_t last_tx_phy_{0}; + uint8_t last_rx_phy_{0}; + /// 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..6083fa59c8 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_motion.hpp @@ -0,0 +1,403 @@ +#pragma once + +#include +#include + +/// @file switch2_pro_motion.hpp +/// @brief Real Pro Controller 2 IMU motion sequence (report 0x09 bytes 0x0F..0x36). +/// +/// A contiguous run captured in order from a real controller, so the block's +/// internal (packed, undocumented) per-sample timestamps advance monotonically +/// when replayed in sequence. The console enables IMU during init and every real +/// report carries this 40-byte block; we replay this sequence (looping) so our +/// stream matches the device rather than sending an empty or non-monotonic block. + +namespace espp::switch2 { +inline constexpr std::array, 128> kMotionSequence = {{ + {{0x17, 0x80, 0x01, 0x0f, 0x43, 0xfb, 0xff, 0x7d, 0xff, 0x3f, 0x10, 0x00, 0xe8, 0x00, + 0xe0, 0xf4, 0x20, 0xd0, 0xf9, 0x3f, 0xfb, 0xff, 0x06, 0x40, 0x01, 0xe0, 0xf4, 0x0e, + 0xc8, 0xfc, 0x5f, 0xfd, 0x5f, 0x03, 0x80, 0x01, 0xb8, 0xe9, 0x3b, 0x20}}, + {{0x22, 0xb0, 0x00, 0x0e, 0x23, 0xf0, 0xff, 0x55, 0xfe, 0x3f, 0x3c, 0x00, 0xd8, 0x00, + 0xdc, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x60, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x2f, 0xd0, 0x00, 0x0e, 0xe3, 0xe8, 0xff, 0xa9, 0xfd, 0x3f, 0x53, 0x00, 0xd8, 0x00, + 0xe0, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x80, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x3a, 0xb0, 0x00, 0x0e, 0xa3, 0xe1, 0xff, 0xf1, 0xfc, 0xbf, 0x6a, 0x00, 0xc8, 0x00, + 0xd8, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x04, 0x50, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x47, 0xd0, 0x00, 0x0e, 0x83, 0xda, 0xff, 0x3d, 0xfc, 0x3f, 0x82, 0x00, 0x28, 0x01, + 0xdc, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x04, 0x40, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x52, 0xb0, 0x00, 0x0e, 0x03, 0xd4, 0xff, 0x91, 0xfb, 0x3f, 0x9d, 0x00, 0x38, 0x01, + 0xd4, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x6c, 0xfa, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x04, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x5e, 0xc0, 0x00, 0x0e, 0x03, 0xce, 0xff, 0xe1, 0xfa, 0x3f, 0xb5, 0x00, 0x18, 0x01, + 0xe0, 0xf4, 0x1c, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x04, 0xc0, 0x02, 0xb0, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x6a, 0xc0, 0x00, 0x0e, 0xa3, 0xc6, 0xff, 0x2d, 0xfa, 0xbf, 0xcc, 0x00, 0xc8, 0x00, + 0xf0, 0xf4, 0x1f, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x78, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0x40, 0x03, 0x80, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x76, 0xc0, 0x00, 0x0e, 0xc3, 0xbf, 0xff, 0x7d, 0xf9, 0x3f, 0xe7, 0x00, 0xd8, 0x00, + 0xd8, 0xf4, 0x1f, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x50, 0xd3, 0x73, 0x40, 0x00, 0x02}}, + {{0x81, 0xb0, 0x00, 0x0e, 0x83, 0xb8, 0xff, 0xe5, 0xf8, 0xbf, 0x00, 0x01, 0xe8, 0x00, + 0xdc, 0xf4, 0x1d, 0x10, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x80, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x8e, 0xd0, 0x00, 0x0e, 0x83, 0xb0, 0xff, 0x49, 0xf8, 0x3f, 0x1c, 0x01, 0xf8, 0x00, + 0xd8, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x08, 0x80, 0x03, 0x70, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x99, 0xb0, 0x00, 0x0e, 0x03, 0xa9, 0xff, 0x99, 0xf7, 0xbf, 0x38, 0x01, 0xd8, 0x00, + 0xe0, 0xf4, 0x1f, 0x10, 0xff, 0xf7, 0xff, 0x04, 0xc0, 0x00, 0x70, 0x7a, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x04, 0xc0, 0x02, 0x80, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xa6, 0xd0, 0x00, 0x0e, 0xc3, 0xa1, 0xff, 0xed, 0xf6, 0x3f, 0x52, 0x01, 0x98, 0x00, + 0xe0, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x06, 0x80, 0x03, 0x50, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xb1, 0xb0, 0x00, 0x0e, 0x83, 0x9a, 0xff, 0x25, 0xf6, 0x3f, 0x6b, 0x01, 0xd8, 0x00, + 0xd4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x00, 0x04, 0x20, 0xd3, 0x7b, 0x40, 0x05, 0x02}}, + {{0xbe, 0xd0, 0x00, 0x0e, 0xe3, 0x92, 0xff, 0x61, 0xf5, 0x3f, 0x85, 0x01, 0xd8, 0x00, + 0xcc, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xf3, 0xff, 0x06, 0xc0, 0x03, 0x60, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0xc9, 0xb0, 0x00, 0x0e, 0xc3, 0x8b, 0xff, 0xb9, 0xf4, 0x3f, 0x9f, 0x01, 0x18, 0x01, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x03, 0x00, 0x04, 0xa0, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xc9, 0xb0, 0x00, 0x0e, 0xc3, 0x8b, 0xff, 0xb9, 0xf4, 0x3f, 0x9f, 0x01, 0x18, 0x01, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x03, 0x00, 0x04, 0xa0, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xd6, 0xd0, 0x00, 0x0e, 0x43, 0x85, 0xff, 0x11, 0xf4, 0xbf, 0xb3, 0x01, 0x18, 0x01, + 0xe0, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x08, 0x80, 0x03, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xed, 0x70, 0x01, 0x0f, 0xa3, 0x7b, 0xff, 0x35, 0xf3, 0x3f, 0xd8, 0x01, 0xe8, 0x00, + 0xe8, 0xf4, 0x23, 0x10, 0xf9, 0xbf, 0xfa, 0x7f, 0x07, 0xc0, 0x01, 0xe8, 0xf4, 0x11, + 0x68, 0xfc, 0x7f, 0xfd, 0x1f, 0x03, 0xc0, 0x01, 0xd0, 0xe9, 0x41, 0x20}}, + {{0xf8, 0xb0, 0x00, 0x0e, 0xe3, 0x6f, 0xff, 0x0d, 0xf2, 0xbf, 0x04, 0x02, 0xd8, 0x00, + 0xd8, 0xf4, 0x1d, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x50, 0xd3, 0x7f, 0x40, 0x05, 0x02}}, + {{0x05, 0xd1, 0x00, 0x0e, 0x83, 0x69, 0xff, 0x5d, 0xf1, 0xbf, 0x1c, 0x02, 0xc8, 0x00, + 0xd0, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x02, 0x40, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x10, 0xb1, 0x00, 0x0e, 0x83, 0x61, 0xff, 0xa9, 0xf0, 0x3f, 0x34, 0x02, 0xa8, 0x00, + 0xd4, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x74, + 0xff, 0xeb, 0xff, 0x07, 0x00, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x1d, 0xd1, 0x00, 0x0e, 0x83, 0x5a, 0xff, 0xdd, 0xef, 0x3f, 0x4b, 0x02, 0xc8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x40, 0x04, 0x70, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x28, 0xb1, 0x00, 0x0e, 0xc3, 0x53, 0xff, 0x2d, 0xef, 0xbf, 0x63, 0x02, 0x18, 0x01, + 0xd4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xeb, 0xff, 0x06, 0x00, 0x04, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x35, 0xd1, 0x00, 0x0e, 0xc3, 0x4c, 0xff, 0x69, 0xee, 0x3f, 0x7c, 0x02, 0xe8, 0x00, + 0xe0, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x70, 0xfa, 0x08, 0x84, + 0xff, 0xe7, 0xff, 0x06, 0x40, 0x03, 0x50, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x40, 0xb1, 0x00, 0x0e, 0x63, 0x45, 0xff, 0xb5, 0xed, 0x3f, 0x98, 0x02, 0xe8, 0x00, + 0xd8, 0xf4, 0x24, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x4d, 0xd1, 0x00, 0x0e, 0x43, 0x3e, 0xff, 0x0d, 0xed, 0x3f, 0xb3, 0x02, 0xd8, 0x00, + 0xe0, 0xf4, 0x20, 0x10, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x90, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x58, 0xb1, 0x00, 0x0e, 0x63, 0x37, 0xff, 0x55, 0xec, 0xbf, 0xce, 0x02, 0xf8, 0x00, + 0xe0, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x74, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x04, 0x00, 0x04, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x64, 0xc1, 0x00, 0x0e, 0xc3, 0x30, 0xff, 0xb1, 0xeb, 0xbf, 0xe2, 0x02, 0x18, 0x01, + 0xd8, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x68, 0x7a, 0x08, 0xa4, + 0xff, 0xf3, 0xff, 0x06, 0x40, 0x03, 0x40, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x70, 0xc1, 0x00, 0x0e, 0x63, 0x2a, 0xff, 0x09, 0xeb, 0x3f, 0xfc, 0x02, 0xb8, 0x00, + 0xd0, 0xf4, 0x20, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x30, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x7c, 0xc1, 0x00, 0x0e, 0x63, 0x23, 0xff, 0x51, 0xea, 0x3f, 0x16, 0x03, 0xc8, 0x00, + 0xcc, 0xf4, 0x22, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x64, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0xc0, 0x02, 0x30, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x87, 0xb1, 0x00, 0x0e, 0x63, 0x1c, 0xff, 0xa1, 0xe9, 0x3f, 0x2f, 0x03, 0xd8, 0x00, + 0xd4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x04, 0x40, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x94, 0xd1, 0x00, 0x0e, 0x63, 0x15, 0xff, 0xdd, 0xe8, 0x3f, 0x48, 0x03, 0xe8, 0x00, + 0xdc, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x06, 0xc0, 0x03, 0x50, 0xd3, 0x8f, 0x40, 0x04, 0x02}}, + {{0x9f, 0xb1, 0x00, 0x0e, 0x23, 0x0e, 0xff, 0x45, 0xe8, 0x3f, 0x64, 0x03, 0xf8, 0x00, + 0xd8, 0xf4, 0x23, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x04, 0x80, 0x03, 0x20, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xac, 0xd1, 0x00, 0x0e, 0xe3, 0x06, 0xff, 0x9d, 0xe7, 0x3f, 0x7c, 0x03, 0xd8, 0x00, + 0xd0, 0xf4, 0x22, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0xa4, + 0xff, 0xe7, 0xff, 0x04, 0x40, 0x03, 0x50, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xb7, 0xb1, 0x00, 0x0e, 0x83, 0x00, 0xff, 0xed, 0xe6, 0xbf, 0x95, 0x03, 0xf8, 0x00, + 0xdc, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0xa0, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xc4, 0xd1, 0x00, 0x0e, 0xc3, 0xf9, 0xfe, 0x2d, 0xe6, 0xbf, 0xad, 0x03, 0xc8, 0x00, + 0xe4, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x04, 0xa0, 0x00, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0x40, 0x02, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xcf, 0xb1, 0x00, 0x0e, 0x43, 0xf2, 0xfe, 0x75, 0xe5, 0x3f, 0xca, 0x03, 0xb8, 0x00, + 0xe0, 0xf4, 0x1c, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x70, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xdc, 0xd1, 0x00, 0x0e, 0x63, 0xeb, 0xfe, 0xb5, 0xe4, 0x3f, 0xe2, 0x03, 0xd8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x70, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0xe7, 0xb1, 0x00, 0x0e, 0x83, 0xe4, 0xfe, 0x09, 0xe4, 0x3f, 0xfc, 0x03, 0x18, 0x01, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x04, 0x60, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xf3, 0xc1, 0x00, 0x0e, 0x43, 0xde, 0xfe, 0x6d, 0xe3, 0x3f, 0x14, 0x04, 0xe8, 0x00, + 0xd8, 0xf4, 0x20, 0x10, 0xff, 0xf7, 0xff, 0x04, 0x00, 0x01, 0x68, 0xfa, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xfe, 0xb1, 0x00, 0x0e, 0xa3, 0xd6, 0xfe, 0xc5, 0xe2, 0x3f, 0x31, 0x04, 0xf8, 0x00, + 0xdc, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x08, 0x80, 0x04, 0x60, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x0b, 0xd2, 0x00, 0x0e, 0xe3, 0xcf, 0xfe, 0x09, 0xe2, 0x3f, 0x4e, 0x04, 0xf8, 0x00, + 0xe0, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x80, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x16, 0xb2, 0x00, 0x0e, 0x43, 0xc8, 0xfe, 0x45, 0xe1, 0xbf, 0x69, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x6c, 0x7a, 0x09, 0x84, + 0xff, 0xeb, 0xff, 0x08, 0x80, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x0b, 0x02}}, + {{0x23, 0xd2, 0x00, 0x0e, 0x43, 0xc1, 0xfe, 0x8d, 0xe0, 0xbf, 0x83, 0x04, 0xf8, 0x00, + 0xe4, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xf3, 0xff, 0x08, 0x00, 0x03, 0x80, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x2e, 0xb2, 0x00, 0x0e, 0x43, 0xba, 0xfe, 0xe5, 0xdf, 0xbf, 0xa0, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x1b, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x40, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x3b, 0xd2, 0x00, 0x0e, 0x83, 0xb3, 0xfe, 0x41, 0xdf, 0x3f, 0xb8, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x20, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xe7, 0xff, 0x06, 0x00, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x46, 0xb2, 0x00, 0x0e, 0xa3, 0xac, 0xfe, 0x95, 0xde, 0x3f, 0xd0, 0x04, 0xd8, 0x00, + 0xd8, 0xf4, 0x22, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xe7, 0xff, 0x04, 0x80, 0x03, 0xa0, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0x53, 0xd2, 0x00, 0x0e, 0x03, 0xa6, 0xfe, 0xe5, 0xdd, 0x3f, 0xe7, 0x04, 0xc8, 0x00, + 0xe4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x70, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x40, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x5e, 0xb2, 0x00, 0x0e, 0xa3, 0x9e, 0xfe, 0x2d, 0xdd, 0x3f, 0xff, 0x04, 0xe8, 0x00, + 0xdc, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xf7, 0xff, 0x05, 0x80, 0x03, 0x20, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x6b, 0xd2, 0x00, 0x0e, 0x63, 0x97, 0xfe, 0x8d, 0xdc, 0x3f, 0x18, 0x05, 0xc8, 0x00, + 0xcc, 0xf4, 0x20, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0xc0, 0x02, 0x50, 0xd3, 0x87, 0x40, 0x04, 0x02}}, + {{0x76, 0xb2, 0x00, 0x0e, 0x23, 0x90, 0xfe, 0xed, 0xdb, 0x3f, 0x31, 0x05, 0xa8, 0x00, + 0xd8, 0xf4, 0x23, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0xb4, + 0xff, 0xeb, 0xff, 0x05, 0x00, 0x03, 0x30, 0xd3, 0x83, 0x40, 0x05, 0x02}}, + {{0x82, 0xc2, 0x00, 0x0e, 0x03, 0x8a, 0xfe, 0x3d, 0xdb, 0x3f, 0x48, 0x05, 0xc8, 0x00, + 0xcc, 0xf4, 0x1d, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x05, 0xc0, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x8d, 0xb2, 0x00, 0x0e, 0x83, 0x83, 0xfe, 0x91, 0xda, 0x3f, 0x5f, 0x05, 0xc8, 0x00, + 0xdc, 0xf4, 0x21, 0x50, 0xff, 0xf7, 0xff, 0x02, 0xe0, 0x00, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x9a, 0xd2, 0x00, 0x0e, 0xc3, 0x7c, 0xfe, 0xed, 0xd9, 0xbf, 0x78, 0x05, 0xe8, 0x00, + 0xdc, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0x00, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x04, 0x02}}, + {{0xa5, 0xb2, 0x00, 0x0e, 0x83, 0x76, 0xfe, 0x2d, 0xd9, 0x3f, 0x92, 0x05, 0xb8, 0x00, + 0xdc, 0xf4, 0x1e, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x50, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xb2, 0xd2, 0x00, 0x0e, 0xc3, 0x6f, 0xfe, 0x95, 0xd8, 0xbf, 0xae, 0x05, 0xe8, 0x00, + 0xdc, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0x00, 0x01, 0x70, 0xfa, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xbd, 0xb2, 0x00, 0x0e, 0xe3, 0x68, 0xfe, 0xe5, 0xd7, 0xbf, 0xc8, 0x05, 0x18, 0x01, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x07, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x40, 0x03, 0xa0, 0xd3, 0x73, 0x40, 0x00, 0x02}}, + {{0xca, 0xd2, 0x00, 0x0e, 0xc3, 0x61, 0xfe, 0x45, 0xd7, 0xbf, 0xe0, 0x05, 0xb8, 0x00, + 0xe8, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x74, 0xfa, 0x08, 0x94, + 0xff, 0xf3, 0xff, 0x05, 0x40, 0x03, 0x90, 0xd3, 0x87, 0x40, 0x04, 0x02}}, + {{0xd5, 0xb2, 0x00, 0x0e, 0xa3, 0x5a, 0xfe, 0x9d, 0xd6, 0xbf, 0xf9, 0x05, 0xc8, 0x00, + 0xd8, 0xf4, 0x22, 0x90, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x70, 0x7a, 0x09, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x02, 0x60, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xe2, 0xd2, 0x00, 0x0e, 0xa3, 0x54, 0xfe, 0xe5, 0xd5, 0xbf, 0x12, 0x06, 0xb8, 0x00, + 0xd8, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xed, 0xb2, 0x00, 0x0e, 0x43, 0x4c, 0xfe, 0x25, 0xd5, 0x3f, 0x2e, 0x06, 0xd8, 0x00, + 0xd4, 0xf4, 0x20, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x07, 0xb4, + 0xff, 0xef, 0xff, 0x05, 0x00, 0x03, 0x60, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xf9, 0xc2, 0x00, 0x0e, 0xe3, 0x45, 0xfe, 0x7d, 0xd4, 0xbf, 0x44, 0x06, 0xe8, 0x00, + 0xe0, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x00, 0x03, 0x80, 0xd3, 0x8f, 0x40, 0x05, 0x02}}, + {{0x05, 0xc3, 0x00, 0x0e, 0xa3, 0x3e, 0xfe, 0xc5, 0xd3, 0x3f, 0x5e, 0x06, 0xf8, 0x00, + 0xd0, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x80, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x11, 0xc3, 0x00, 0x0e, 0xc3, 0x37, 0xfe, 0x15, 0xd3, 0xbf, 0x77, 0x06, 0xe8, 0x00, + 0xd4, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0x70, 0xd3, 0x8b, 0x40, 0x05, 0x02}}, + {{0x1c, 0xb3, 0x00, 0x0e, 0x83, 0x30, 0xfe, 0x59, 0xd2, 0x3f, 0x8f, 0x06, 0x18, 0x01, + 0xe4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x04, 0x80, 0x03, 0x80, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x29, 0xd3, 0x00, 0x0e, 0xe3, 0x29, 0xfe, 0x95, 0xd1, 0x3f, 0xa5, 0x06, 0xf8, 0x00, + 0xdc, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x04, 0x02}}, + {{0x34, 0xb3, 0x00, 0x0e, 0x83, 0x22, 0xfe, 0xe5, 0xd0, 0x3f, 0xbb, 0x06, 0xe8, 0x00, + 0xd8, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x01, 0x00, 0x01, 0x70, 0xfa, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x04, 0xc0, 0x03, 0x90, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x41, 0xd3, 0x00, 0x0e, 0x03, 0x1b, 0xfe, 0x21, 0xd0, 0xbf, 0xca, 0x06, 0xd8, 0x00, + 0xe4, 0xf4, 0x24, 0x50, 0xff, 0xef, 0xff, 0x02, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x40, 0x03, 0x60, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x4c, 0xb3, 0x00, 0x0e, 0xa3, 0x13, 0xfe, 0x65, 0xcf, 0x3f, 0xe1, 0x06, 0xd8, 0x00, + 0xd8, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0x40, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x05, 0x02}}, + {{0x59, 0xd3, 0x00, 0x0e, 0xe3, 0x0c, 0xfe, 0xa9, 0xce, 0xbf, 0x01, 0x07, 0xf8, 0x00, + 0xe0, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x04, 0x00, 0x01, 0x70, 0x7a, 0x07, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x80, 0x03, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x64, 0xb3, 0x00, 0x0e, 0x43, 0x05, 0xfe, 0xf9, 0xcd, 0xbf, 0x1f, 0x07, 0xe8, 0x00, + 0xe4, 0xf4, 0x1d, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x64, 0xb3, 0x00, 0x0e, 0x43, 0x05, 0xfe, 0xf9, 0xcd, 0xbf, 0x1f, 0x07, 0xe8, 0x00, + 0xe4, 0xf4, 0x1d, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x71, 0xd3, 0x00, 0x0e, 0x83, 0xfe, 0xfd, 0x49, 0xcd, 0x3f, 0x36, 0x07, 0xf8, 0x00, + 0xdc, 0xf4, 0x1e, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x80, 0xd3, 0x7f, 0x40, 0x04, 0x02}}, + {{0x88, 0x73, 0x01, 0x0f, 0xa3, 0xf5, 0xfd, 0x5d, 0xcc, 0xbf, 0x59, 0x07, 0xd8, 0x00, + 0xe0, 0xf4, 0x20, 0x10, 0xf9, 0xbf, 0xfa, 0xbf, 0x07, 0x00, 0x02, 0xd8, 0xf4, 0x0f, + 0xa8, 0xfc, 0x7f, 0xfd, 0x5f, 0x03, 0xa0, 0x01, 0xb8, 0xe9, 0x45, 0x20}}, + {{0x93, 0xb3, 0x00, 0x0e, 0x43, 0xea, 0xfd, 0x45, 0xcb, 0xbf, 0x86, 0x07, 0x08, 0x01, + 0xe0, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x07, 0xb4, + 0xff, 0xf3, 0xff, 0x06, 0x00, 0x04, 0xa0, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0xa0, 0xd3, 0x00, 0x0e, 0x23, 0xe3, 0xfd, 0x9d, 0xca, 0xbf, 0x9f, 0x07, 0xe8, 0x00, + 0xe8, 0xf4, 0x21, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x74, 0x7a, 0x08, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x80, 0x03, 0xa0, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0xab, 0xb3, 0x00, 0x0e, 0x43, 0xdc, 0xfd, 0x21, 0xca, 0x3f, 0xbb, 0x07, 0xb8, 0x00, + 0xe4, 0xf4, 0x21, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x80, 0x02, 0x90, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xb8, 0xd3, 0x00, 0x0e, 0x23, 0xd4, 0xfd, 0x79, 0xc9, 0xbf, 0xd6, 0x07, 0xd8, 0x00, + 0xdc, 0xf4, 0x23, 0x10, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x68, 0x7a, 0x09, 0x94, + 0xff, 0xef, 0xff, 0x08, 0x00, 0x04, 0x30, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xc3, 0xb3, 0x00, 0x0e, 0xa3, 0xcc, 0xfd, 0xd1, 0xc8, 0x3f, 0xf4, 0x07, 0xf8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x00, 0x04, 0x50, 0xd3, 0x7f, 0x40, 0x0b, 0x02}}, + {{0xd0, 0xd3, 0x00, 0x0e, 0xa3, 0xc5, 0xfd, 0x15, 0xc8, 0x3f, 0x0e, 0x08, 0xf8, 0x00, + 0xd4, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x06, 0x40, 0x04, 0x90, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xdb, 0xb3, 0x00, 0x0e, 0x63, 0xbe, 0xfd, 0x59, 0xc7, 0xbf, 0x26, 0x08, 0x18, 0x01, + 0xe8, 0xf4, 0x1e, 0x50, 0xff, 0xf7, 0xff, 0x04, 0x00, 0x01, 0x74, 0x7a, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x05, 0x00, 0x04, 0x90, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xe8, 0xd3, 0x00, 0x0e, 0x43, 0xb7, 0xfd, 0xb1, 0xc6, 0xbf, 0x43, 0x08, 0xb8, 0x00, + 0xe4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x04, 0xc0, 0x02, 0x80, 0xd3, 0x87, 0x40, 0x09, 0x02}}, + {{0xf3, 0xb3, 0x00, 0x0e, 0x83, 0xb0, 0xfd, 0xf9, 0xc5, 0x3f, 0x5a, 0x08, 0xc8, 0x00, + 0xe4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0xc0, 0x03, 0x90, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0xff, 0xc3, 0x00, 0x0e, 0x03, 0xaa, 0xfd, 0x49, 0xc5, 0x3f, 0x72, 0x08, 0xd8, 0x00, + 0xec, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x04, 0xe0, 0x00, 0x74, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x0a, 0x02}}, + {{0x0b, 0xc4, 0x00, 0x0e, 0x83, 0xa2, 0xfd, 0x91, 0xc4, 0x3f, 0x8d, 0x08, 0xe8, 0x00, + 0xd0, 0xf4, 0x25, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x09, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x60, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x17, 0xc4, 0x00, 0x0e, 0xc3, 0x9b, 0xfd, 0xcd, 0xc3, 0x3f, 0xa5, 0x08, 0xd8, 0x00, + 0xdc, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x03, 0x50, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x22, 0xb4, 0x00, 0x0e, 0x63, 0x94, 0xfd, 0x19, 0xc3, 0x3f, 0xbe, 0x08, 0xe8, 0x00, + 0xcc, 0xf4, 0x1f, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x80, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x2f, 0xd4, 0x00, 0x0e, 0x83, 0x8d, 0xfd, 0x75, 0xc2, 0x3f, 0xd5, 0x08, 0xe8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x08, 0xc0, 0x03, 0x70, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x3a, 0xb4, 0x00, 0x0e, 0xc3, 0x86, 0xfd, 0xbd, 0xc1, 0x3f, 0xf1, 0x08, 0xd8, 0x00, + 0xd4, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x08, 0xa4, + 0xff, 0xf3, 0xff, 0x04, 0x80, 0x03, 0x30, 0xd3, 0x7f, 0x40, 0x05, 0x02}}, + {{0x47, 0xd4, 0x00, 0x0e, 0x23, 0x80, 0xfd, 0x19, 0xc1, 0xbf, 0x0a, 0x09, 0xd8, 0x00, + 0xd4, 0xf4, 0x1f, 0x10, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x6c, 0x7a, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x02, 0x70, 0xd3, 0x7b, 0x40, 0x00, 0x02}}, + {{0x52, 0xb4, 0x00, 0x0e, 0x03, 0x79, 0xfd, 0x75, 0xc0, 0xbf, 0x26, 0x09, 0xc8, 0x00, + 0xe4, 0xf4, 0x1c, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0xa0, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x5f, 0xd4, 0x00, 0x0e, 0x23, 0x72, 0xfd, 0xbd, 0xbf, 0xbf, 0x3d, 0x09, 0xd8, 0x00, + 0xe4, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xf3, 0xff, 0x06, 0xc0, 0x02, 0x50, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x6a, 0xb4, 0x00, 0x0e, 0xe3, 0x6a, 0xfd, 0x19, 0xbf, 0x3f, 0x57, 0x09, 0xd8, 0x00, + 0xd4, 0xf4, 0x1f, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0xfa, 0x07, 0x84, + 0xff, 0xef, 0xff, 0x08, 0x40, 0x03, 0x80, 0xd3, 0x77, 0x40, 0x00, 0x02}}, + {{0x77, 0xd4, 0x00, 0x0e, 0xc3, 0x63, 0xfd, 0x59, 0xbe, 0xbf, 0x6f, 0x09, 0x08, 0x01, + 0xe0, 0xf4, 0x1d, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x05, 0x80, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x04, 0x02}}, + {{0x82, 0xb4, 0x00, 0x0e, 0x43, 0x5c, 0xfd, 0xb1, 0xbd, 0x3f, 0x86, 0x09, 0xe8, 0x00, + 0xd4, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x08, 0x84, + 0xff, 0xeb, 0xff, 0x07, 0x40, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x8e, 0xc4, 0x00, 0x0e, 0x63, 0x55, 0xfd, 0x01, 0xbd, 0x3f, 0x9f, 0x09, 0xb8, 0x00, + 0xd4, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x64, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x30, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x9a, 0xc4, 0x00, 0x0e, 0xc3, 0x4d, 0xfd, 0x59, 0xbc, 0x3f, 0xb9, 0x09, 0xd8, 0x00, + 0xd4, 0xf4, 0x24, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x05, 0x40, 0x03, 0x70, 0xd3, 0x9f, 0x40, 0x00, 0x02}}, + {{0xa6, 0xc4, 0x00, 0x0e, 0x03, 0x46, 0xfd, 0xad, 0xbb, 0xbf, 0xd3, 0x09, 0xb8, 0x00, + 0xe4, 0xf4, 0x23, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x04, 0x40, 0x03, 0x70, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xb1, 0xb4, 0x00, 0x0e, 0x23, 0x3f, 0xfd, 0xf9, 0xba, 0x3f, 0xe9, 0x09, 0xc8, 0x00, + 0xd8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x40, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0xbe, 0xd4, 0x00, 0x0e, 0x03, 0x38, 0xfd, 0x3d, 0xba, 0x3f, 0x05, 0x0a, 0xd8, 0x00, + 0xd4, 0xf4, 0x22, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xe0, 0x00, 0x6c, 0x7a, 0x09, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x04, 0x02}}, + {{0xc9, 0xb4, 0x00, 0x0e, 0xa3, 0x30, 0xfd, 0x9d, 0xb9, 0xbf, 0x1e, 0x0a, 0xf8, 0x00, + 0xd8, 0xf4, 0x24, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x09, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x40, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x05, 0x02}}, + {{0xd6, 0xd4, 0x00, 0x0e, 0xc3, 0x29, 0xfd, 0xe5, 0xb8, 0xbf, 0x37, 0x0a, 0xe8, 0x00, + 0xe0, 0xf4, 0x25, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x09, 0xa4, + 0xff, 0xf3, 0xff, 0x06, 0x00, 0x04, 0xa0, 0xd3, 0x8f, 0x40, 0x04, 0x02}}, + {{0xe1, 0xb4, 0x00, 0x0e, 0x23, 0x23, 0xfd, 0x49, 0xb8, 0xbf, 0x50, 0x0a, 0xe8, 0x00, + 0xe0, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x60, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0xee, 0xd4, 0x00, 0x0e, 0xc3, 0x1b, 0xfd, 0x91, 0xb7, 0x3f, 0x67, 0x0a, 0xc8, 0x00, + 0xe4, 0xf4, 0x24, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x68, 0xfa, 0x08, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x02, 0x40, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0xf9, 0xb4, 0x00, 0x0e, 0xc3, 0x14, 0xfd, 0xe9, 0xb6, 0xbf, 0x82, 0x0a, 0xc8, 0x00, + 0xcc, 0xf4, 0x21, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x09, 0x94, + 0xff, 0xeb, 0xff, 0x07, 0xc0, 0x03, 0x50, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0x06, 0xd5, 0x00, 0x0e, 0x23, 0x0e, 0xfd, 0x35, 0xb6, 0x3f, 0x9a, 0x0a, 0xf8, 0x00, + 0xd4, 0xf4, 0x24, 0x50, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0xfa, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x05, 0x00, 0x04, 0x80, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0x11, 0xb5, 0x00, 0x0e, 0x23, 0x07, 0xfd, 0x89, 0xb5, 0x3f, 0xb1, 0x0a, 0xc8, 0x00, + 0xe0, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x07, 0x80, 0x03, 0x80, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x1d, 0xc5, 0x00, 0x0e, 0x83, 0x00, 0xfd, 0xd9, 0xb4, 0x3f, 0xc8, 0x0a, 0xe8, 0x00, + 0xe0, 0xf4, 0x22, 0x50, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x80, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0x28, 0xb5, 0x00, 0x0e, 0x63, 0xf9, 0xfc, 0x21, 0xb4, 0xbf, 0xe2, 0x0a, 0xd8, 0x00, + 0xe8, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x04, 0xc0, 0x00, 0x74, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0xc0, 0x02, 0x70, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0x35, 0xd5, 0x00, 0x0e, 0x43, 0xf2, 0xfc, 0x75, 0xb3, 0xbf, 0xfe, 0x0a, 0xb8, 0x00, + 0xe4, 0xf4, 0x1e, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x70, 0x7a, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x02, 0xa0, 0xd3, 0x8f, 0x40, 0x09, 0x02}}, + {{0x40, 0xb5, 0x00, 0x0e, 0x83, 0xea, 0xfc, 0xbd, 0xb2, 0xbf, 0x16, 0x0b, 0xd8, 0x00, + 0xd8, 0xf4, 0x21, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0xa4, + 0xff, 0xeb, 0xff, 0x06, 0x00, 0x03, 0x70, 0xd3, 0x7b, 0x40, 0x05, 0x02}}, + {{0x4d, 0xd5, 0x00, 0x0e, 0xa3, 0xe2, 0xfc, 0xf9, 0xb1, 0xbf, 0x2e, 0x0b, 0xc8, 0x00, + 0xd4, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x68, 0xfa, 0x07, 0xa4, + 0xff, 0xf3, 0xff, 0x05, 0x40, 0x03, 0x20, 0xd3, 0x6f, 0x40, 0x00, 0x02}}, + {{0x58, 0xb5, 0x00, 0x0e, 0x43, 0xdb, 0xfc, 0x55, 0xb1, 0x3f, 0x48, 0x0b, 0xc8, 0x00, + 0xcc, 0xf4, 0x1c, 0x50, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x64, 0xfa, 0x06, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x80, 0x03, 0x30, 0xd3, 0x77, 0x40, 0x05, 0x02}}, + {{0x65, 0xd5, 0x00, 0x0e, 0x43, 0xd4, 0xfc, 0xb9, 0xb0, 0xbf, 0x5f, 0x0b, 0xe8, 0x00, + 0xc8, 0xf4, 0x1e, 0x50, 0xff, 0xf7, 0xff, 0x04, 0xe0, 0x00, 0x64, 0xfa, 0x07, 0x94, + 0xff, 0xf3, 0xff, 0x07, 0x00, 0x03, 0x60, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0x70, 0xb5, 0x00, 0x0e, 0x43, 0xcd, 0xfc, 0x1d, 0xb0, 0x3f, 0x7e, 0x0b, 0xb8, 0x00, + 0xcc, 0xf4, 0x21, 0x50, 0xff, 0xef, 0xff, 0x04, 0xc0, 0x00, 0x64, 0xfa, 0x08, 0x84, + 0xff, 0xef, 0xff, 0x06, 0x40, 0x02, 0x50, 0xd3, 0x8b, 0x40, 0x00, 0x02}}, + {{0x7d, 0xd5, 0x00, 0x0e, 0x23, 0xc6, 0xfc, 0x65, 0xaf, 0xbf, 0x9a, 0x0b, 0xa8, 0x00, + 0xd0, 0xf4, 0x24, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x07, 0x80, 0x02, 0x60, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x88, 0xb5, 0x00, 0x0e, 0x23, 0xbf, 0xfc, 0xd1, 0xae, 0xbf, 0xb5, 0x0b, 0xc8, 0x00, + 0xd8, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0x7a, 0x08, 0xb4, + 0xff, 0xef, 0xff, 0x05, 0x00, 0x03, 0x50, 0xd3, 0x83, 0x40, 0x00, 0x02}}, + {{0x94, 0xc5, 0x00, 0x0e, 0x43, 0xb8, 0xfc, 0x31, 0xae, 0xbf, 0xcc, 0x0b, 0xd8, 0x00, + 0xe0, 0xf4, 0x1f, 0x50, 0xff, 0xf7, 0xff, 0x03, 0x00, 0x01, 0x70, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x06, 0x00, 0x03, 0x80, 0xd3, 0x93, 0x40, 0x00, 0x02}}, + {{0xa0, 0xc5, 0x00, 0x0e, 0x43, 0xb1, 0xfc, 0x8d, 0xad, 0xbf, 0xe4, 0x0b, 0xe8, 0x00, + 0xe0, 0xf4, 0x24, 0x10, 0xff, 0xef, 0xff, 0x04, 0x20, 0x01, 0x70, 0x7a, 0x09, 0x84, + 0xff, 0xef, 0xff, 0x04, 0x00, 0x04, 0x50, 0xd3, 0x8f, 0x40, 0x00, 0x02}}, + {{0xac, 0xc5, 0x00, 0x0e, 0xc3, 0xa8, 0xfc, 0xcd, 0xac, 0xbf, 0xfe, 0x0b, 0x18, 0x01, + 0xd0, 0xf4, 0x22, 0x10, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x6c, 0xfa, 0x07, 0x94, + 0xff, 0xf3, 0xff, 0x06, 0x80, 0x03, 0x30, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xb7, 0xb5, 0x00, 0x0e, 0x43, 0xa1, 0xfc, 0x25, 0xac, 0x3f, 0x19, 0x0c, 0xc8, 0x00, + 0xd0, 0xf4, 0x1e, 0x50, 0xff, 0xe7, 0xff, 0x03, 0xc0, 0x00, 0x6c, 0xfa, 0x07, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x80, 0x02, 0x40, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xc4, 0xd5, 0x00, 0x0e, 0x43, 0x9a, 0xfc, 0x59, 0xab, 0x3f, 0x33, 0x0c, 0xc8, 0x00, + 0xe0, 0xf4, 0x23, 0x10, 0xff, 0xf7, 0xff, 0x03, 0xc0, 0x00, 0x70, 0xfa, 0x08, 0xa4, + 0xff, 0xeb, 0xff, 0x05, 0xc0, 0x02, 0x90, 0xd3, 0x7f, 0x40, 0x00, 0x02}}, + {{0xcf, 0xb5, 0x00, 0x0e, 0xc3, 0x92, 0xfc, 0xbd, 0xaa, 0x3f, 0x48, 0x0c, 0xc8, 0x00, + 0xe0, 0xf4, 0x1f, 0x10, 0xff, 0xef, 0xff, 0x03, 0xe0, 0x00, 0x70, 0x7a, 0x08, 0xa4, + 0xff, 0xef, 0xff, 0x03, 0x40, 0x03, 0x70, 0xd3, 0x87, 0x40, 0x00, 0x02}}, + {{0xdc, 0xd5, 0x00, 0x0e, 0x03, 0x8b, 0xfc, 0x0d, 0xaa, 0xbf, 0x5c, 0x0c, 0xf8, 0x00, + 0xe0, 0xf4, 0x23, 0x50, 0xff, 0xef, 0xff, 0x03, 0x20, 0x01, 0x70, 0x7a, 0x08, 0x84, + 0xff, 0xf3, 0xff, 0x07, 0x80, 0x04, 0x80, 0xd3, 0x83, 0x40, 0x04, 0x02}}, + {{0xe7, 0xb5, 0x00, 0x0e, 0xc3, 0x83, 0xfc, 0x65, 0xa9, 0x3f, 0x79, 0x0c, 0x28, 0x01, + 0xd4, 0xf4, 0x1d, 0x10, 0xff, 0xef, 0xff, 0x03, 0x00, 0x01, 0x6c, 0x7a, 0x07, 0xa4, + 0xff, 0xef, 0xff, 0x07, 0xc0, 0x03, 0x40, 0xd3, 0x7f, 0x40, 0x0b, 0x02}}, + {{0xf4, 0xd5, 0x00, 0x0e, 0x03, 0x7c, 0xfc, 0xad, 0xa8, 0x3f, 0x94, 0x0c, 0xd8, 0x00, + 0xc8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x60, 0xfa, 0x07, 0x94, + 0xff, 0xef, 0xff, 0x06, 0xc0, 0x02, 0xf0, 0xd2, 0x6f, 0x40, 0x04, 0x02}}, + {{0xff, 0xb5, 0x00, 0x0e, 0x23, 0x75, 0xfc, 0xf9, 0xa7, 0x3f, 0xaf, 0x0c, 0xd8, 0x00, + 0xb8, 0xf4, 0x20, 0x50, 0xff, 0xef, 0xff, 0x03, 0xa0, 0x00, 0x64, 0xfa, 0x08, 0x94, + 0xff, 0xef, 0xff, 0x07, 0x80, 0x02, 0x30, 0xd3, 0x83, 0x40, 0x0b, 0x02}}, +}}; + +} // namespace espp::switch2 diff --git a/components/switch2_pro/include/switch2_pro_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..63efbe3f53 --- /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. +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..6de81315d6 --- /dev/null +++ b/components/switch2_pro/include/switch2_pro_report.hpp @@ -0,0 +1,101 @@ +#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): +/// byte0: 0x80 RStick 0x40 Plus 0x20 ZR 0x10 R 0x08 X 0x04 Y 0x02 A 0x01 B +/// byte1: 0x80 LStick 0x40 Minus 0x20 ZL 0x10 L 0x08 Up 0x04 Left 0x02 Right 0x01 Down +/// byte2: 0x10 C 0x08 GL 0x04 GR 0x02 Capture 0x01 Home +class Pro2InputReport { +public: + static constexpr uint8_t REPORT_ID = 0x09; + static constexpr size_t SIZE = 63; + static constexpr uint16_t STICK_CENTER = 0x800; ///< 12-bit midpoint (2048) + static constexpr uint16_t STICK_MAX = 0xfff; + + Pro2InputReport() { reset(); } + + void reset() { + data_.fill(0); + 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; + return static_cast((v * 0.5f + 0.5f) * STICK_MAX); + } + void pack_stick(size_t offset, float x, float y) { + const uint16_t xv = axis_to_u12(x); + const uint16_t yv = axis_to_u12(y); + data_[offset] = static_cast(xv & 0xff); + data_[offset + 1] = static_cast(((yv & 0x0f) << 4) | ((xv >> 8) & 0x0f)); + data_[offset + 2] = static_cast((yv >> 4) & 0xff); + } + void set_bit(size_t byte, uint8_t mask, bool v) { + if (v) + data_[byte] |= mask; + else + data_[byte] &= ~mask; + } + + std::array data_{}; +}; + +} // namespace espp::switch2 diff --git a/components/switch2_pro/src/switch2_pro.cpp b/components/switch2_pro/src/switch2_pro.cpp new file mode 100644 index 0000000000..52ae772bcc --- /dev/null +++ b/components/switch2_pro/src/switch2_pro.cpp @@ -0,0 +1,1207 @@ +#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); + } + +private: + Switch2Pro *owner_; + const char *name_; + int role_; +}; + +bool Switch2Pro::init() { + // 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; + NimBLEDevice::setOwnAddr(rnd.data()); + NimBLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM); + logger_.warn("BLE address: PUBLIC unavailable; using STABLE static-random {:02x}:{:02x}:{:02x}:" + "{:02x}:{:02x}:{:02x}", + rnd[5], rnd[4], rnd[3], rnd[2], rnd[1], rnd[0]); + } + // Load any persisted bond BEFORE configure_security (which otherwise clears + // bonds): if present we reconnect instead of re-pairing. + reconnect_mode_ = load_bond(); + if (reconnect_mode_) { + paired_ = true; + logger_.info("loaded stored bond — reconnection mode (console address persisted)"); + } + configure_security(); + if (!build_gatt()) { + logger_.error("failed to build GATT services"); + 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_) { + inject_ltk(bond_peer_type_, bond_peer_val_.data()); + if (wake_console_on_boot_) { + logger_.info( + "wake-on-boot: broadcasting the wake advertisement every {:.0f}s until connected", + wake_interval_.count()); + boot_wake_pending_ = true; // one-shot: cleared on the first successful connect + start_wake_timer(); + } + } + advertise(); + logger_.info("Switch2Pro advertising as '{}'", device_name_); + + // Start the driver-owned input-streaming task. It notifies the latest report + // once per connection interval while the console is subscribed. Give it a + // generous stack (ble_gatts_notify_custom is a deep call) and pin it to core 0, + // away from the BLE controller/host on core 1. + esp_pthread_cfg_t cfg = esp_pthread_get_default_config(); + cfg.stack_size = 8192; + cfg.prio = 5; + cfg.pin_to_core = 0; + cfg.thread_name = "s2p_stream"; + 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(); + wake_pending_ = false; // wake accomplished — subsequent advertising can be passive + pairing_stage_ = 0; // a fresh connection restarts the 0x15 handshake sequence + // Wake-on-boot is one-shot: clear it on the first connection so we never wake a + // console the user intentionally sleeps later. The wake timer cancels ITSELF on + // its next tick (see start_wake_timer) — do NOT cancel/join it from here, since + // this callback may run under the NimBLE host lock the timer task also needs. + // User-requested wake stays available via wake_console(). + boot_wake_pending_ = false; + // The connection interval right after connect is the key diagnostic: the + // Switch 2 drives 5 ms (interval == 4 units). If a controller can't hold + // that, the console typically disconnects with a supervision timeout. + // Connection interval is logged for reference, but note: a real console + // pairs entirely at the initial 15 ms interval (verified against the + // procon2 pairing capture) — it does NOT move to 5 ms until after pairing. + // So this value is not a pairing gate; it matters only for post-pairing + // low-latency input streaming. + logger_.info("connected: peer={} interval={:.2f}ms supervision={}ms latency={}", + info.getAddress().toString(), info.getConnInterval() * 1.25f, + info.getConnTimeout() * 10, info.getConnLatency()); + // NOTE: we deliberately do NOT initiate a connection-parameter update here. + // It cannot lower us below 7.5 ms anyway (NimBLE floors ble_gap_update_params + // at the spec minimum), the console keeps 15 ms regardless, and the real 5 ms + // arrives in the console's CONNECT_IND on reconnect (needs the controller + // patch), not via an update. On the ESP32-S3 BTDM controller, kicking off an + // update procedure right after connect correlated with a degraded/limping tx + // link, so it is removed. See request_fast_interval() history in git if you + // want to re-test it. + }; + callbacks.disconnect_callback = [this](NimBLEConnInfo &info, BleGattServer::DisconnectReason r) { + logger_.warn("disconnected: peer={} reason={} (paired={})", info.getAddress().toString(), r, + paired_); + // Tie the disconnect to the tx-wedge timeline: how long we streamed, whether + // we had wedged, and how stale the last completion was. A disconnect ~1 SVN + // timeout after the wedge with a large since_last_tx = over-air exchange + // stopped at the wedge; staying up long after = the link outlived our tx stall. + if (stream_start_us_ != 0) { + const int64_t now = esp_timer_get_time(); + logger_.warn(" @disconnect: streamed {:.1f}s, {} completions, {} enomem, wedged={}, " + "since_last_tx={:.0f}ms | {}", + (now - stream_start_us_) / 1e6f, tx_completions_.load(), enomem_count_, + wedge_reported_, (now - last_tx_complete_us_.load()) / 1000.0f, pool_stats()); + } + // 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](NimBLEConnInfo &info) { + // Fires when ANY connection-parameter-update procedure completes — including + // the console's answer to our at-connect offer, and any console-initiated + // update. If the interval here is still 15 ms, the console REJECTED (or + // no-op'd) the procedure; if it moved (5/7.5 ms), it accepted. Before this + // callback existed we were blind to the difference between "rejected" and + // "console never responded". + logger_.info("CONN PARAMS UPDATE: itvl={:.2f}ms latency={} timeout={}ms", + info.getConnInterval() * 1.25f, info.getConnLatency(), info.getConnTimeout() * 10); + }; + callbacks.authentication_complete_callback = [this](const NimBLEConnInfo &info) { + // If this fires, the console ran BLE SMP (which the research says it should + // NOT do). Encrypted={}, bonded={} tells us what security state it reached. + logger_.info("AUTH complete: encrypted={} bonded={} authenticated={}", info.isEncrypted(), + info.isBonded(), info.isAuthenticated()); + }; + ble_gatt_server_.set_callbacks(callbacks); +} + +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) { + c->setCallbacks(new ChannelCallbacks(this, name, role)); + }; + + // Service 1 (purpose not fully understood; created so the handle map matches + // what the console observed from a real controller). + auto *svc1 = server->createService(NimBLEUUID(SERVICE1_UUID)); + 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()); +} + +void 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; + ble_gatt_server_.start_advertising(params); + logger_.info("advertising ({}): flags+mfr({} B) in adv, name in scan response", + mode == AdvMode::Discovery ? "discovery" + : mode == AdvMode::Reconnect ? "reconnect" + : "wake", + mfr.size()); +} + +void Switch2Pro::advertise() { + // Embed the console's STABLE IDENTITY address (from the 0x15 exchange, persisted + // in the bond) — that is what the real controller advertises on reconnect, and + // what the console matches to recognise us and grant the fast 5 ms interval. Do + // NOT use bond_peer_val_ (the NimBLE connection address), which can be a + // rotating private address we cannot resolve without an SMP bond. + // + // Use the WAKE variant (0x81 flag = "user pressed a button, connect to me") + // while a wake is pending: an awake console ignores the flag-less Reconnect + // variant from its idle screens, and a waking console may transiently + // connect/drop (which re-enters here) — the latch keeps the wake variant on + // the air until a connection actually completes. + if (reconnect_mode_ && (boot_wake_pending_ || wake_pending_)) + start_advertising(AdvMode::Wake, host_addr_); + else if (reconnect_mode_) + start_advertising(AdvMode::Reconnect, host_addr_); + else + start_advertising(AdvMode::Discovery); +} + +bool Switch2Pro::wake_console() { + if (active_conn_handle_ != 0xffff) + return false; // already connected — nothing to wake + // 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 + start_advertising(AdvMode::Wake, host_addr_); + return true; +} + +void Switch2Pro::start_wake_timer() { + if (wake_timer_) + return; + wake_timer_ = std::make_shared(espp::Timer::Config{ + .name = "switch2 wake", + .period = wake_interval_, + .callback = [this]() -> bool { + // 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) { + // A notification we queued has been transmitted; free its flow-control slot. + if (characteristic == pro2_input_) { + tx_completions_.fetch_add(1); + last_tx_complete_us_.store(esp_timer_get_time()); + if (notify_in_flight_.load() > 0) + notify_in_flight_.fetch_sub(1); + } +} + +std::string Switch2Pro::pool_stats() { + // Walk every NimBLE mempool. The host mbuf (MSYS) pools are what a notify draws + // from; if their free count trends to 0 (min_free==0), the host ran out of + // buffers → ENOMEM originates host-side. If they stay healthy while we still + // ENOMEM, the stall is downstream at the controller's ACL tx buffers. + std::string s; + struct os_mempool *mp = nullptr; + struct os_mempool_info info; + char line[80]; + while ((mp = os_mempool_info_get_next(mp, &info)) != nullptr) { + if (info.omi_num_blocks <= 1) // skip tiny 1-block control pools — noise + continue; + snprintf(line, sizeof(line), "%s=%d/%d(min%d) ", info.omi_name[0] ? info.omi_name : "?", + info.omi_num_free, info.omi_num_blocks, info.omi_min_free); + s += line; + } + return s; +} + +bool Switch2Pro::msys1_headroom() { + struct os_mempool *mp = nullptr; + struct os_mempool_info info; + while ((mp = os_mempool_info_get_next(mp, &info)) != nullptr) { + if (std::strcmp(info.omi_name, "msys_1") == 0) + return (info.omi_num_blocks - info.omi_num_free) < kMaxOutstandingMbufs; + } + return true; // pool not found (shouldn't happen) — fail open, don't block the stream +} + +void Switch2Pro::poll_conn_state() { + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(active_conn_handle_, &desc) != 0) + return; + uint8_t tx_phy = 0, rx_phy = 0; + ble_gap_read_le_phy(active_conn_handle_, &tx_phy, &rx_phy); + if (desc.conn_itvl == last_itvl_ && desc.conn_latency == last_latency_ && + tx_phy == last_tx_phy_ && rx_phy == last_rx_phy_) + return; + last_itvl_ = desc.conn_itvl; + last_latency_ = desc.conn_latency; + last_tx_phy_ = tx_phy; + last_rx_phy_ = rx_phy; + // PHY: 1 = 1M, 2 = 2M, 3 = coded. Interval in 1.25 ms units, timeout in 10 ms. + logger_.info("LINK CHANGE: itvl={:.2f}ms latency={} timeout={}ms tx_phy={} rx_phy={}", + desc.conn_itvl * 1.25f, desc.conn_latency, desc.supervision_timeout * 10, tx_phy, + rx_phy); +} + +void Switch2Pro::input_stream_loop() { + // Two streaming models (Config::continuous_streaming): + // + // * continuous (default): send one report every connection interval with the + // counter incrementing every time, exactly like a real controller (the + // fresh-pair capture shows a real device streaming 62 Hz at 15 ms). Verified + // stable and lag-free on the C6-class chips. + // * on-change: notify only when the app's button/stick state changed since the + // last delivered report, plus a keepalive every kKeepaliveIntervals. A + // reduced-traffic fallback that partially masks the ESP32-S3 BTDM + // controller's tx-servicing bug (it stops draining tx ~3 s into any + // sustained encrypted stream — see README "Known issues"). + while (!stream_stop_.load()) { + if (!input_subscribed_ || active_conn_handle_ == 0xffff || pro2_input_ == nullptr) { + have_streamed_ = false; // (re)subscribe forces a fresh initial send + idle_intervals_ = 0; + stream_start_us_ = 0; // reset the wedge diagnostics for the next run + wedge_reported_ = false; + send_attempts_ = 0; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + // NOTE: poll_conn_state() is NOT called here — it issues an HCI LE-Read-PHY + // command, and running that at the 62 Hz stream rate floods the HCI path and + // wedges the host's data-tx draining after ~3 s. It runs in the 500 ms + // heartbeat below instead. ble_gap_conn_find() is local (no HCI) so it's cheap. + uint32_t itvl_us = 15000; + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(active_conn_handle_, &desc) == 0 && desc.conn_itvl > 0) + itvl_us = static_cast(desc.conn_itvl) * 1250; // 1.25 ms units -> us + + // --- tx-wedge telemetry --- + const int64_t now_us = esp_timer_get_time(); + if (stream_start_us_ == 0) { // first live tick of this streaming run + // Reset the per-session state here (in the streaming thread) rather than in + // the on_subscribe callback, so these stay single-writer. + report_counter_ = 0; // fresh byte-0 sequence for the console to track + motion_idx_ = 0; + enomem_count_ = 0; + backpressure_skips_ = 0; + notify_in_flight_.store(0); + tx_completions_.store(0); + last_itvl_ = 0; // force a fresh LINK baseline log from poll_conn_state + last_latency_ = 0xffff; + last_tx_phy_ = 0; + last_rx_phy_ = 0; + stream_start_us_ = hb_last_us_ = now_us; + last_tx_complete_us_.store(now_us); + hb_last_completions_ = tx_completions_.load(); + 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_, buf[0], buf[2], + buf[3], buf[4], buf[0x0b], enabled_features_.load()); + return sent; +} + +void Switch2Pro::inject_ltk(uint8_t peer_type, const uint8_t *peer_val_le) { + struct ble_store_value_sec sec = {}; + sec.peer_addr.type = peer_type; + std::copy(peer_val_le, peer_val_le + 6, sec.peer_addr.val); + sec.key_size = 16; + sec.ediv = 0; // no SMP key distribution — the console uses the LTK directly + sec.rand_num = 0; + // NimBLE hands ltk[] straight to the controller with no byte-swap, so it must + // be in the same order the console's controller uses: ltk_ (= A1 ^ B1) as + // computed. (The 0x03/0x07 "send pairing info" blob is this value reversed, + // but that is just the on-wire transmission form, not the key order.) + std::copy(ltk_.begin(), ltk_.end(), sec.ltk); + sec.ltk_present = 1; + sec.authenticated = 1; + int rc = ble_store_write_our_sec(&sec); + logger_.info("injected LTK into NimBLE store (rc={}) — ready for LL encryption", rc); +} + +void Switch2Pro::inject_pairing_ltk() { + struct ble_gap_conn_desc desc; + if (active_conn_handle_ == 0xffff || ble_gap_conn_find(active_conn_handle_, &desc) != 0) { + logger_.warn("cannot inject LTK: no active connection"); + return; + } + inject_ltk(desc.peer_id_addr.type, desc.peer_id_addr.val); +} + +void Switch2Pro::save_bond() { + struct ble_gap_conn_desc desc; + if (active_conn_handle_ == 0xffff || ble_gap_conn_find(active_conn_handle_, &desc) != 0) + return; + StoredBond b{}; + b.magic = kBondMagic; + b.peer_type = desc.peer_id_addr.type; + std::copy(std::begin(desc.peer_id_addr.val), std::end(desc.peer_id_addr.val), b.peer_val); + std::copy(ltk_.begin(), ltk_.end(), b.ltk); + std::copy(host_addr_.begin(), host_addr_.end(), b.host_addr); + nvs_handle_t h; + if (nvs_open(kNvsNamespace, NVS_READWRITE, &h) != ESP_OK) { + logger_.error("save_bond: nvs_open failed"); + return; + } + nvs_set_blob(h, kNvsBondKey, &b, sizeof(b)); + nvs_commit(h); + nvs_close(h); + bond_peer_type_ = b.peer_type; + std::copy(std::begin(b.peer_val), std::end(b.peer_val), bond_peer_val_.begin()); + logger_.info("saved bond to NVS (console addr + LTK)"); +} + +bool Switch2Pro::load_bond() { + nvs_handle_t h; + if (nvs_open(kNvsNamespace, NVS_READONLY, &h) != ESP_OK) + return false; + StoredBond b{}; + size_t sz = sizeof(b); + esp_err_t err = nvs_get_blob(h, kNvsBondKey, &b, &sz); + nvs_close(h); + if (err != ESP_OK || sz != sizeof(b) || b.magic != kBondMagic) + return false; + bond_peer_type_ = b.peer_type; + std::copy(std::begin(b.peer_val), std::end(b.peer_val), bond_peer_val_.begin()); + std::copy(std::begin(b.ltk), std::end(b.ltk), ltk_.begin()); + std::copy(std::begin(b.host_addr), std::end(b.host_addr), host_addr_.begin()); + return true; +} + +void Switch2Pro::handle_pairing(bool via_vibration_command, uint8_t transport, PairingSub sub, + const uint8_t *payload, size_t len) { + // Pairing responses use byte4=0x10, byte5=0x78, and a payload that begins + // with a 0x01 status byte (exact framing from ndeadly's captures). + switch (sub) { + case PairingSub::EXCHANGE_ADDRESSES: { + // 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. + inject_pairing_ltk(); + // Persist {console address, LTK} so we can reconnect/wake after a reboot + // without re-running the pairing exchange. + save_bond(); + reconnect_mode_ = true; + break; + } + default: + 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..a5d386d638 --- /dev/null +++ b/components/switch2_pro/src/switch2_pro_pairing.cpp @@ -0,0 +1,67 @@ +#include "switch2_pro_pairing.hpp" + +#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). + if (psa_crypto_init() != PSA_SUCCESS) { + ESP_LOGE(kPairingTag, "psa_crypto_init failed"); + return out; // zeros — self_test() flags it, live pairing fails cleanly + } + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); + 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..93cbbf0d68 --- /dev/null +++ b/components/switch2_pro/tools/patch_nimble_5ms.py @@ -0,0 +1,259 @@ +#!/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, and +differs by chip family: + + * RISC-V NimBLE controller (esp32c6/c61/c2/h2) — `libble_app.a`, object + `ble_ll_conn.c.o`. The floor is an `addi a5, a4, -6`; flip the immediate to + -4: 93 07 a7 ff -> 93 07 c7 ff. + + * BTDM / RivieraWaves controller (esp32s3 Xtensa, esp32c3 RISC-V) — + `libbtdm_app.a` (and the `_flash` variant), object `llc_con_upd.o`, function + `r_llc_con_upd_param_in_range`. The floor is a compare of the requested + min-interval against 6: + S3 (Xtensa): bltui a4, 6 (b6 64 01) -> bltui a4, 4 (b6 44 01) + C3 (RISC-V): li a6,5;bgeu a6,a2 (15 48) -> li a6,3;bgeu a6,a2 (0d 48) + Both give a new floor of 4 units = 5 ms. This is the peripheral-side + validator the console's LL_CONNECTION_PARAM_REQ / _UPDATE_IND path runs + through (verified: its only caller is the RivieraWaves ip_funcs jump table, + and its siblings are ll_connection_param_req_handler / + ll_connection_update_ind_handler). + +WARNING: this modifies files inside your global $IDF_PATH install, affecting +every project that uses that IDF. A `.original` backup is written next to each +patched archive; `--restore` puts them back. + +Approach adapted from the MIT-licensed zhantss/ESP32-BLE5-NSController-Emulator +(RISC-V/NimBLE); the S3/C3 BTDM equivalent was reverse-engineered here from the +same reject-below-6 semantics. The reverse-engineered requirement is documented +in ndeadly/switch2_controller_research. This script ships no Espressif or +Nintendo binaries — it only edits the archives already present in the user's +local ESP-IDF. +""" + +import argparse +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", + }, + # --- BTDM / RivieraWaves controller: libbtdm_app.a[+_flash], llc_con_upd.o --- + "esp32s3": { + "arch": "xtensa-esp32s3", + "archives": [ + "components/bt/controller/lib_esp32c3_family/esp32s3/libbtdm_app.a", + "components/bt/controller/lib_esp32c3_family/esp32s3/libbtdm_app_flash.a", + ], + "object": "llc_con_upd.o", + "old": bytes([0xB6, 0x64, 0x01]), # bltui a4,6 (reject min < 6 / 7.5 ms) + "new": bytes([0xB6, 0x44, 0x01]), # bltui a4,4 (reject min < 4 / 5 ms) + "disasm_old": r"bltui\s+a4, ?6,", + "disasm_new": r"bltui\s+a4, ?4,", + }, + "esp32c3": { + "arch": "riscv", + "archives": [ + "components/bt/controller/lib_esp32c3_family/esp32c3/libbtdm_app.a", + "components/bt/controller/lib_esp32c3_family/esp32c3/libbtdm_app_flash.a", + ], + "object": "llc_con_upd.o", + "old": bytes([0x15, 0x48]), # c.li a6,5; bgeu a6,a2 -> reject min <= 5 (floor 6) + "new": bytes([0x0D, 0x48]), # c.li a6,3; bgeu a6,a2 -> reject min <= 3 (floor 4) + "disasm_old": r"li\s+a6,5", + "disasm_new": r"li\s+a6,3", + }, +} + + +def resolve_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: + path = os.path.join(tmp, obj) + with open(path, "wb") as f: + f.write(data) + subprocess.run([ar, "r", lib, path], cwd=os.path.dirname(path) or ".", 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) + if n_new > 0 and n_old == 0: + print(f"{tag}: already patched; nothing to do") + continue + if n_old == 0: + sys.exit(f"{tag}: expected byte pattern not found in {obj} — IDF version may " + f"differ; not patching") + if n_old > 1: + sys.exit(f"{tag}: pattern appears {n_old}x in {obj} (expected 1) — refusing to " + f"patch ambiguously") + 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..b068dad1fc --- /dev/null +++ b/components/switch2_pro/tools/smoke_test_5ms.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Smoke-test the 5 ms BLE connection-interval patch — no hardware required. + +For the given target it locates the controller archive(s) in $IDF_PATH, extracts +the object that holds the min-interval floor, disassembles the relevant function +with the target's GNU objdump, and reports the *actual instruction* that enforces +the floor: + + unpatched -> the 7.5 ms floor instruction (e.g. `bltui a4, 6`) => 5 ms REJECTED + patched -> the 5 ms floor instruction (e.g. `bltui a4, 4`) => 5 ms ACCEPTED + +This proves the patch does what it claims at the disassembly level, independent +of the byte-pattern match the patcher uses. Exit code 0 = patched (5 ms capable), +1 = unpatched, 2 = indeterminate / error. + +It reuses the per-target spec from patch_nimble_5ms.py (same directory), so the +two tools can never drift. Run it before and after the patcher to see the floor +change from 6 (7.5 ms) to 4 (5 ms). + +On-hardware confirmation (the second half of "does the console accept it"): +flash a BLE peripheral built with the patched IDF, then on the GAP connect / +connection-update event log the negotiated interval. With esp-nimble-cpp: + + void onConnect(NimBLEConnInfo& info) { + ESP_LOGI("smoke", "conn interval = %u units (%.2f ms)", + info.getConnInterval(), info.getConnInterval() * 1.25f); + } + +Point a central that drives a fast interval at it (the Switch 2, or a BlueZ host +with its own min-interval floor lowered). A patched controller logs 4 units +(5.00 ms); a stock one never goes below 6 (7.50 ms) or drops the link. +""" + +import argparse +import os +import re +import subprocess +import sys +import tempfile +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: + out = subprocess.run([objdump, "-d", obj_path], capture_output=True, text=True).stdout + if not func: + return out + # keep only the named function body (up to the next symbol header) + lines, keep, buf = out.splitlines(), False, [] + for ln in lines: + if re.search(rf"<{re.escape(func)}>:", ln): + keep = True + elif keep and re.match(r"^[0-9a-f]{8} <", ln): + break + if keep: + buf.append(ln) + return "\n".join(buf) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--idf-path", default=os.environ.get("IDF_PATH"), help="ESP-IDF root") + ap.add_argument("--target", required=True, help="/ ".join(TARGETS)) + ap.add_argument("--objdump", default=None, help="override the objdump binary") + args = ap.parse_args() + if not args.idf_path: + sys.exit("set --idf-path or the IDF_PATH environment variable") + + spec = spec_for(args.target) + ar = resolve_tool("ar", spec["arch"], None) + objdump = resolve_tool("objdump", spec["arch"], args.objdump) + libs = archive_paths(args.idf_path, spec) + obj = spec["object"] + func = FLOOR_FUNC[stack_of(spec)] + re_old, re_new = re.compile(spec["disasm_old"]), re.compile(spec["disasm_new"]) + + print(f"target {args.target} ({spec['arch']}, {stack_of(spec)} controller)") + print(f"objdump: {objdump} object: {obj}" + + (f" function: {func}" if func else "")) + print("-" * 68) + + verdicts = [] + for lib in libs: + tag = os.path.basename(lib) + with tempfile.TemporaryDirectory() as tmp: + subprocess.run([ar, "x", lib, obj], cwd=tmp, check=True) + disasm = disassemble(objdump, os.path.join(tmp, obj), func) + old_lines = [l.strip() for l in disasm.splitlines() if re_old.search(l)] + new_lines = [l.strip() for l in disasm.splitlines() if re_new.search(l)] + if new_lines and not old_lines: + verdict, floor = "PATCHED (5 ms ACCEPTED)", new_lines[0] + elif old_lines and not new_lines: + verdict, floor = "unpatched (5 ms REJECTED)", old_lines[0] + else: + verdict, floor = "INDETERMINATE", (old_lines + new_lines or [""])[0] + verdicts.append(verdict) + print(f" {tag}") + print(f" floor instruction : {floor}") + print(f" verdict : {verdict}") + + print("-" * 68) + if all(v.startswith("PATCHED") for v in verdicts): + print(f"PASS — {args.target} controller accepts a 5 ms connection interval.") + return 0 + if all(v.startswith("unpatched") for v in verdicts): + print(f"unpatched — run tools/patch_nimble_5ms.py --target {args.target} to enable 5 ms.") + return 1 + print("INDETERMINATE — archives disagree or the floor instruction moved (IDF version?).") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/doc/Doxyfile b/doc/Doxyfile index 5ac85abd7d..314182fd9f 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,9 @@ 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_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..c67bf93a9c --- /dev/null +++ b/doc/en/ble/switch2_pro.rst @@ -0,0 +1,62 @@ +Switch 2 Pro Controller +*********************** + +The `Switch2Pro` component emulates a **Nintendo Switch 2 Pro Controller over +BLE** so that a real Nintendo Switch 2 console accepts it as a native +controller — including pairing, waking the console from sleep, reconnecting, and +streaming input reports. It is built on :cpp:class:`espp::BleGattServer` +(NimBLE) and implements the reverse-engineered Nintendo custom GATT interface +(not HID-over-GATT) and the console's custom pairing handshake (not BLE SMP). + +.. warning:: + + **Supported target: ESP32-C6** (and the other open-NimBLE-controller chips: + C61/C2/H2), where pairing, input streaming, reconnect, and wake-from-sleep + all work against a real console. The **ESP32-S3 builds and pairs but does not + yet stream input reliably** — its closed BTDM BLE controller degrades the + link under sustained encrypted notifications. See the component README's + "Known issues" for details; S3/C3 support is a work in progress. + +.. note:: + + Interoperability only. This component contains no Nintendo or Espressif + binaries; the pairing "authentication" relies on a published fixed key and is + a possession check, not per-device attestation. + +.. code-block:: cpp + + #include "switch2_pro.hpp" + + espp::Switch2Pro controller({.device_name = "Pro Controller"}); + controller.init(); // verifies pairing crypto, builds GATT, advertises + + // feed input state; a driver-owned task streams it once the console subscribes + espp::switch2::Pro2InputReport report; + report.set_a(true); + controller.set_input_report(report); + +The 5 ms connection interval +---------------------------- + +A real controller *reconnects* at a 5 ms connection interval, below the 7.5 ms +Bluetooth spec minimum, chosen by the console in its ``CONNECT_IND``. Accepting +it (required for reconnect and wake-from-sleep) needs the opt-in Kconfig option +``SWITCH2_PRO_PATCH_NIMBLE_5MS``, which binary-patches the prebuilt BLE +controller library in your global ``$IDF_PATH`` install. It is **off by +default** (it mutates your ESP-IDF install); fresh pairing and first-session +input work without it. See the component README and ``tools/patch_nimble_5ms.py``. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + switch2_pro_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/switch2_pro.inc +.. include-build-file:: inc/switch2_pro_report.inc +.. include-build-file:: inc/switch2_pro_protocol.inc diff --git a/doc/en/ble/switch2_pro_example.md b/doc/en/ble/switch2_pro_example.md new file mode 100644 index 0000000000..3d95c09c5b --- /dev/null +++ b/doc/en/ble/switch2_pro_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/switch2_pro/example/README.md +```