diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 0f450ea..e17139f 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -523,6 +523,8 @@ Target hardware families: | DualShock 4 | input, Switch motion, rumble, lightbar | | Switch Pro | input, motion, rumble, player LED | | Joy-Con L/R | each half as the standalone controller exposed by Bluepad32 | +| Switch 2 Pro | BLE setup, sticks/motion and C/GL/GR observed; rumble feel/reconnect still need qualification | +| Joy-Con 2 L/R | solo and merged-pair software support; both-order/disconnect regressions pass; hardware qualification pending | | Wii Remote | buttons, accelerometer, rumble | | Wii Remote + Classic Controller | extension controls | | Wii U Pro | buttons, sticks, rumble | @@ -703,6 +705,33 @@ Set B delivery evidence: firmware-timestamped input, explicit start/stop/run identity and visible capacity/time/disconnect termination. It does not write profiles until Save. +### Switch 2 controller input — Expanded support implemented + +- Scoped proprietary BLE implementation informed by Bluepad32 PR219 at + `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`; no unrelated Steam/Xbox/Sony + fork changes. Dynamic GATT discovery, matched acknowledgements, calibration, + normalized motion and retained finite/stateful rumble replace the unsafe + fixed-handle/timeout-advance behavior in that proposal. +- Joy-Con 2 pairs retain the first-ready USB slot and left profile identity; + either connection order works. Right-half motion, both-half feedback, + sideways solo operation and neutral detach transitions are implemented. + A pair still consumes two of the four physical Bluetooth connections. +- Seven source-only inputs (C, GL/GR and four rail buttons) are editable in + Profile Studio, including Shift mappings and action/macro modifiers. + Profile schema 7 uses reserved bytes in the unchanged 384-byte payload; + schemas 1–6 retain their prior settings and initialize extras unmapped. +- Application pairing has its own bounded persistent authorization list. + Fresh pairing remains window-gated; Clear pairings forgets the list and + reports storage failure instead of falsely acknowledging a clear. + These proprietary BLE links are unencrypted, not authenticated SMP bonds. +- Verification: 287 tests passed and all five firmware variants built. + The flashed real Switch 2 Pro delivered sticks, motion and independent + C/GL/GR combinations; schema-7 extra mappings saved/read/restored. + All 32 pre-existing profiles, metadata and active indices survived. + Joy-Con hardware, physical rumble feel, reconnect endurance and mixed-radio + qualification remain separate from those software results. +- Full operation and limitations: [README.md](README.md#switch-2-controller-input). + ### Native Switch-family HD rumble — Implemented, qualification incomplete Standalone agent handoff: [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md). @@ -732,9 +761,9 @@ Current constraints: precedence, earliest deadlines, rotating ties, periodic credit reservations, and generation-bound grant completion. Radio power policy is unchanged. - Adapter configuration schema 3 persists up to 16 explicit physical approvals. - Existing profile schema 6/catalog 2, bonds and wake identity are unchanged. -- Joy-Cons are currently separate, horizontally mapped controllers in - Bluepad32. A paired two-Joy-Con logical controller is not implemented. + Profile schema 7/catalog 2 preserves prior settings; bonds and wake identity are unchanged. +- Original Switch Joy-Cons remain separate, horizontally mapped controllers. + Joy-Con 2 logical pairing is a distinct BLE implementation described above. Delivery order: diff --git a/CMakeLists.txt b/CMakeLists.txt index 24b2bb4..275f47f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,9 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") ${BLUEPAD32_ROOT}/src/components/bluepad32 ${CMAKE_CURRENT_BINARY_DIR}/libbluepad32 ) + target_sources(bluepad32 PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_hid_parser_switch2.c + ${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c) endif() # Firmware sources live under one include root and are grouped by responsibility. @@ -287,6 +290,9 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=$,1,16> SWITCH2_WAKE_CONFIGURED=${SWITCH2_WAKE_CONFIGURED_VALUE} ) + # GATT's automatic SMP retry is incompatible with Switch 2's proprietary + # pairing. Reject only those links; retain normal SMP for every other pad. + target_link_options(switch-pico PRIVATE "LINKER:--wrap=sm_request_pairing") if(SWITCH_PICO_CYW43_PACKET_READ) target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/platform/pico/cyw43_packet_transport.c) diff --git a/README.md b/README.md index aac6bd7..6e11915 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,16 @@ exact pre-capture firmware and persistent state. - DualSense: hold Create + PS. - DualShock 4: hold Share + PS. - Switch Pro: press its sync button. + - Switch 2 Pro / Joy-Con 2: hold SYNC while the Pico pairing window is open; do not pair through the PC's Bluetooth settings. - Xbox Bluetooth controller: hold its pair button. - 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required. 5. Wait for the controller's player light to settle. Repeat step 4 for additional controllers while the window remains open. Holding BOOTSEL again extends the deadline by 60 seconds from that point. -Pairing order determines the initial USB slot assignment. Up to four controllers map 1:1 to the four emulated Switch Pro Controller interfaces. +Pairing order determines the initial USB slot assignment. Up to four physical Bluetooth controllers are supported. Ordinary controllers each occupy one emulated Switch Pro interface; a merged Joy-Con 2 pair consumes two Bluetooth connections but occupies one USB player slot. With no active controller, the Pico runs Bluepad32 discovery and autoconnect. After any controller becomes active, active discovery pauses to protect input, motion, and rumble latency; bonded controllers may still initiate incoming reconnects. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button. Hold BOOTSEL for the bounded pairing window before pairing a new controller or a controller that requires host-side discovery. Outside that window, BTstack remains non-bondable and rejects new Classic and BLE authentication. -To clear every stored Classic and BLE pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears all bonds, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. +To clear every stored Classic, BLE and proprietary Switch 2 pairing without a PC, hold BOOTSEL continuously for 10 seconds. The normal pairing window opens after two seconds; continuing to hold until the LED changes to a rapid blink clears remembered controllers, disconnects active controllers, publishes neutral state to every slot, and closes new authentication. Release BOOTSEL, open a new pairing window, and pair controllers again. A persistent-storage failure is reported rather than acknowledging a successful clear. ### LED meanings and device state @@ -240,10 +241,12 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and ` `profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and four custom macro sequences are editable. Its live playtest compares current raw stick and trigger input with the unsaved draft, shows deadzone/saturation boundaries and digital thresholds, and highlights pressed physical controls. Select a controller identity and one of its eight profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another local port. +Switch 2's **C, GL, GR, Left SL/SR and Right SL/SR** are additional source-only controls. Map each to a normal button or trigger, assign a button-only alternate Shift mapping, or use it in action/macro chords, cancellation and modifiers. Extra mappings default to disabled. The live playtest shows raw extras separately; they are not fictitious output channels on the emulated Switch Pro/XInput controller. + The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets. Profile names and controller aliases are stored as independently checksummed -catalog metadata. Runtime profiles use schema 6 and 384-byte records; names remain separate. The +catalog metadata. Runtime profiles use schema 7 and unchanged 384-byte records; schemas 1–6 migrate with extra inputs unmapped and existing settings preserved. Names remain separate. The editor can rename and copy profiles across controllers and slots, import or export JSON backups, and reset one section without discarding the rest of the draft. Its response-curve cards provide named presets, exact Q8.8 fine @@ -254,7 +257,7 @@ rumble/light pulse only to the selected live controller. `profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--identity` uses the zero-based index from `profiles list`. -`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. +`pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses, including Switch 2's application-level authorizations. `pairings clear --yes` forgets them all, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. `diagnostics` reports Bluetooth initialization stage, real BTstack timer callbacks, controller report traffic, host/local rumble requests and @@ -266,7 +269,7 @@ adds transport timings, clock/voltage settings and packet-size diagnostics. ### Per-controller profiles -The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons and the L2/R2 analog triggers. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. +The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons, L2/R2 analog triggers and seven Switch 2 extra inputs. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. - The chord cycles persistent profiles `1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 1`. - Chord buttons are consumed locally and are not forwarded to the host. @@ -314,6 +317,8 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS |---|---:|---:|---:| | DualSense / DualShock 4 | Yes | Yes | Yes | | Switch Pro / Joy-Con | Yes | Yes | Yes | +| Switch 2 Pro | Yes, including remappable C/GL/GR | Yes, amplitude translation | Yes | +| Joy-Con 2 solo / merged pair | Implemented; hardware qualification pending | Implemented | Implemented | | PS Move ZCM1/ZCM2 | Buttons/trigger | Yes | Yes, after calibration | | Wii Remote | Mode-dependent | Yes | Accelerometer | | 8BitDo in Switch-compatible Bluetooth mode | Yes | Model-dependent | Yes when the mode exposes IMU | @@ -321,6 +326,22 @@ When a controller becomes ready, RGB-capable devices such as DualSense and DualS Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and 8192 units per g in SDL-oriented axes before conversion to Nintendo samples. PS Move motion remains neutral until all model-specific calibration blocks have been received and validated; buttons and rumble remain available while calibration is pending or unavailable. The latest normalized sample is duplicated across the report's three nominal 5 ms slots and remains pending until a regular `0x30` USB report successfully consumes it. +### Switch 2 controller input + +The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069` (Pro), `057E:2067` (left Joy-Con 2), and `057E:2066` (right Joy-Con 2). This is controller **input** support, distinct from the existing Switch 2 console-wake feature and from emulating a native Switch 2 USB controller. + +- **Pairing:** fresh SYNC pairing requires the existing bounded pairing window. A directed reconnect must target this adapter's Bluetooth address and match its persistent application-level authorization. These links are unencrypted and are **not authenticated SMP bonds**. No global Bluetooth security downgrade is made; automatic SMP requests for these devices fail closed while other controllers retain their existing policy. Public/static addresses can own profiles; transient private addresses are not promoted to persistent identities. +- **Joy-Con ownership:** an opposite ready half automatically joins a solo half. Either connection order works; the first-ready player slot is retained, with the left controller's identity/profile owning the pair. The right half supplies motion. A disconnected half's inputs and pending effects are removed immediately; the surviving half returns to sideways solo operation and its own profile identity. A lone half has rotated controls and SL/SR shoulders. Two pairs exhaust the four physical Bluetooth connections. Pairing does not reopen discovery outside the existing connection policy. +- **Protocol:** service, characteristic and CCCD UUIDs are discovered rather than trusting fixed ATT handles. Setup requires matching acknowledgements, reads user/factory stick calibration and gyro bias, and rejects malformed/failed transactions. Motion is normalized to the existing SDL-oriented units; sensor clock/range classification and physical axis accuracy still need wider model qualification. +- **Rumble:** strong/weak amplitudes feed fixed low/high carriers, repeated in three-frame HOLD packets on an approximately 13 ms cadence. Keepalives retain active effects instead of silencing them; finite effects expire, XInput held effects persist until replaced/stopped, and teardown cancels output. Pro output mirrors the same two-band effect to both actuators. This does **not** preserve Nintendo HD substeps or independent left/right HD effects and does not use the original Switch-native opt-in backend. +- **Not implemented:** Joy-Con mouse output, native GameChat signaling, NFC/IR and Switch 2 NSO GameCube support. C and back/rail inputs can instead be remapped to controls the selected USB mode supports. + +This is a scoped reimplementation informed by [Bluepad32 PR #219](https://github.com/ricardoquesada/bluepad32/pull/219), reviewed at `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`, not a wholesale fork import. Protocol references: [ndeadly's research](https://github.com/ndeadly/switch2_controller_research), [Nadeflore](https://github.com/Nadeflore/switch2-controllers), [Switch2Connect](https://github.com/TommyWabg/Switch2Connect), and [SDL's Switch 2 sensor implementation](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c). + +**Verification:** 287 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use the SDK's real BTstack types/accessors and cover discovery, acknowledgement ordering, calibration, persistence, output deadlines and teardown. Lifecycle tests cover both Joy-Con connection orders, multiple pairs, detach/replacement and pairing-policy isolation. The editor's extra/Shift mappings were exercised in Chromium. + +On the flashed Pico, a real Switch 2 Pro (`3C:A9:AB:65:73:12`) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored on hardware; all 32 pre-existing profiles, metadata and active selections were compared against a pre-flash backup and preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Physical rumble feel, Joy-Con 2 pair behavior, long-duration reconnect and mixed-controller transport remain hardware qualification items. Use schema-7-capable firmware after saving expanded profiles. + ### Rumble per controller Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps. @@ -784,15 +805,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file: | Resource | Used or reserved | Device capacity | |---|---:|---:| -| Executable flash image | 787,824 bytes | 4 MiB | +| Executable flash image | 806,872 bytes | 4 MiB | | Indexed profile arenas | 256 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash | -| BTstack bonds | 8 KiB | 4 MiB flash | +| BTstack bonds and Switch 2 application authorizations | 8 KiB | 4 MiB flash | | RP2350 terminal sector | 4 KiB | 4 MiB flash | -| Allocated/reserved SRAM, including heap and stacks | 139,616 bytes | 520 KiB | +| Allocated/reserved SRAM, including heap and stacks | 141,984 bytes | 520 KiB | -The executable plus persistent reservations consume 1,070,448 bytes of flash, -leaving 3,123,856 bytes. Allocated SRAM sections leave 392,864 bytes of link-time +The executable plus persistent reservations consume 1,089,496 bytes of flash, +leaving 3,104,808 bytes. Allocated SRAM sections leave 390,496 bytes of link-time headroom; this is not a runtime heap high-water measurement. Core 0 has a 4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested catalog migration/compaction rather than overflowing its 4 KiB scratch bank. diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.c b/bluepad32_config/parser/uni_hid_parser_switch2.c new file mode 100644 index 0000000..5cab1d5 --- /dev/null +++ b/bluepad32_config/parser/uni_hid_parser_switch2.c @@ -0,0 +1,977 @@ +// SPDX-License-Identifier: Apache-2.0 +// Proprietary, unencrypted Switch 2 BLE transport. No SMP or bond-store changes. +// Protocol descriptions: https://github.com/ndeadly/switch2_controller_research +// Pairing/calibration/rumble payloads: https://github.com/Nadeflore/switch2-controllers +// Hold-frame cadence: https://github.com/TommyWabg/Switch2Connect +// Sensor conversion independently adapted from SDL_hidapi_switch2.c (SDL/Valve). + +#include "parser/uni_hid_parser_switch2.h" + +#include +#include + +#include +#include "bt/uni_bt_defines.h" +#include "parser/uni_hid_parser_imu.h" +#include "parser/uni_switch2_pairing.h" +#include "sdkconfig.h" +#include "uni_hid_device.h" +#include "uni_log.h" + +#define SW2_TIMEOUT_MS 2000 +#define SW2_OUTPUT_INTERVAL_MS 13 +#define SW2_REPORT_SIZE 63 +#define SW2_ACK 0x78 +#define SW2_CCCD_UUID 0x2902 + +// BTstack deserializes UUIDs into canonical (not ATT wire) byte order. +static const uint8_t sw2_service_uuid[16] = { + 0xab, 0x7d, 0xe9, 0xbe, 0x89, 0xfe, 0x49, 0xad, 0x82, 0x8f, 0x11, 0x8f, 0x09, 0xdf, 0x7f, 0xd0}; +static const uint8_t sw2_input_uuid[16] = { + 0xab, 0x7d, 0xe9, 0xbe, 0x89, 0xfe, 0x49, 0xad, 0x82, 0x8f, 0x11, 0x8f, 0x09, 0xdf, 0x7f, 0xd2}; +static const uint8_t sw2_command_uuid[16] = { + 0x64, 0x9d, 0x4a, 0xc9, 0x8e, 0xb7, 0x4e, 0x6c, 0xaf, 0x44, 0x1e, 0xa5, 0x4f, 0xe5, 0xf0, 0x05}; +static const uint8_t sw2_response_uuid[16] = { + 0xc7, 0x65, 0xa9, 0x61, 0xd9, 0xd8, 0x4d, 0x36, 0xa2, 0x0a, 0x53, 0x15, 0xb1, 0x11, 0x83, 0x6a}; +static const uint8_t sw2_rumble_pro_uuid[16] = { + 0xcc, 0x48, 0x3f, 0x51, 0x92, 0x58, 0x42, 0x7d, 0xa9, 0x39, 0x63, 0x0c, 0x31, 0xf7, 0x2b, 0x05}; +static const uint8_t sw2_rumble_left_uuid[16] = { + 0x28, 0x93, 0x26, 0xcb, 0xa4, 0x71, 0x48, 0x5d, 0xa8, 0xf4, 0x24, 0x0c, 0x14, 0xf1, 0x82, 0x41}; +static const uint8_t sw2_rumble_right_uuid[16] = { + 0xfa, 0x19, 0xb0, 0xfb, 0xcd, 0x1f, 0x46, 0xa7, 0x84, 0xa1, 0xbb, 0xb0, 0x9e, 0x00, 0xc1, 0x49}; + +// Public application-protocol payloads from Nadeflore Controller.pair(), NOT +// secret keys or an authenticated BLE pairing mechanism. No security claim. +static const uint8_t sw2_pair_exchange[17] = { + 0x00, 0xea, 0xbd, 0x47, 0x13, 0x89, 0x35, 0x42, 0xc6, 0x79, 0xee, 0x07, 0xf2, 0x53, 0x2c, 0x6c, 0x31}; +static const uint8_t sw2_pair_confirm[17] = { + 0x00, 0x40, 0xb0, 0x8a, 0x5f, 0xcd, 0x1f, 0x9b, 0x41, 0x12, 0x5c, 0xac, 0xc6, 0x3f, 0x38, 0xa0, 0x73}; + +// Every state transition requires its own completed ATT operation and/or a +// matching application ACK. A timeout disconnects, never skips a setup step. +typedef enum { + SW2_OFF, SW2_ADMITTED, SW2_SERVICE, SW2_CHARACTERISTICS, + SW2_RESPONSE_DESCRIPTOR, SW2_INPUT_DESCRIPTOR, SW2_SUBSCRIBE_RESPONSE, + SW2_INFO, SW2_PAIR, SW2_CALIBRATION, SW2_GYRO_CALIBRATION, + SW2_SUBSCRIBE_INPUT, SW2_FEATURES, SW2_READY, +} sw2_state_t; +typedef enum { SW2_QUERY_NONE, SW2_QUERY_DISCOVERY, SW2_QUERY_CCCD, SW2_QUERY_COMMAND, SW2_QUERY_RUMBLE } sw2_query_t; +typedef struct { + uint16_t center[2]; + uint16_t positive[2]; + uint16_t negative[2]; +} sw2_stick_t; +typedef struct { + uni_hid_device_t* device; + bd_addr_t address; + hci_con_handle_t handle; + bool allocated; + sw2_state_t state; + sw2_query_t query; + uint8_t address_type; + bool needs_pair; + gatt_client_service_t service; + gatt_client_characteristic_t input, response, command, rumble; + uint16_t response_cccd, input_cccd; + gatt_client_notification_t response_listener, input_listener; + bool response_listening, input_listening; + btstack_timer_source_t timeout_timer, output_timer; + bool timeout_active, output_active; + // ATT write-request buffers must outlive the asynchronous call. + uint8_t command_data[32], rumble_data[33], cccd_data[2]; + uint8_t command_length; + bool command_pending, command_sent, command_acked; + uint8_t step, calibration_slot; + bool factory_calibration, calibration_done; + uint32_t memory_address; + uint8_t memory_length; + sw2_stick_t sticks[2]; + int32_t gyro_bias[3]; + uint8_t extra_buttons, leds; + bool leds_pending; + uint8_t rumble_id, weak, strong; + bool rumble_scheduled, rumble_held; + uint32_t rumble_start, rumble_end; + uint32_t sensor_start, sensor_host_start, sensor_last; + uint8_t sensor_warmup; + int32_t gyro_full_scale; +} sw2_instance_t; + +// Separate bounded storage: never squeeze transport resources into parser_data +// (256 bytes). Retired buffers are not reused until their old BLE link is gone. +static sw2_instance_t sw2_instances[CONFIG_BLUEPAD32_MAX_DEVICES]; + +static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* packet, uint16_t size); +static void sw2_output_tick(btstack_timer_source_t* timer); +static void sw2_continue(sw2_instance_t* ins); +static void sw2_complete_command(sw2_instance_t* ins); + +static bool sw2_product(uint16_t pid) { + return pid == UNI_SW2_PRO_PID || pid == UNI_SW2_JOYCON_L_PID || pid == UNI_SW2_JOYCON_R_PID; +} + +bool uni_hid_parser_switch2_is_ble_device(const uni_hid_device_t* d) { + return d && d->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE && d->vendor_id == UNI_SW2_NINTENDO_VID && + sw2_product(d->product_id); +} + +static sw2_instance_t* sw2_instance(const uni_hid_device_t* d) { + if (!uni_hid_parser_switch2_is_ble_device(d)) + return NULL; + for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i) { + sw2_instance_t* ins = &sw2_instances[i]; + if (ins->device == d && ins->state != SW2_OFF && memcmp(ins->address, d->conn.btaddr, 6) == 0) + return ins; + } + return NULL; +} + +static bool sw2_live(sw2_instance_t* ins) { + return ins->device && sw2_instance(ins->device) == ins && ins->handle == ins->device->conn.handle; +} + +static void sw2_disarm_timeout(sw2_instance_t* ins) { + if (ins->timeout_active) + btstack_run_loop_remove_timer(&ins->timeout_timer); + ins->timeout_active = false; +} + +void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins) + return; + sw2_disarm_timeout(ins); + if (ins->output_active) + btstack_run_loop_remove_timer(&ins->output_timer); + ins->output_active = false; + if (ins->response_listening) + gatt_client_stop_listening_for_characteristic_value_updates(&ins->response_listener); + if (ins->input_listening) + gatt_client_stop_listening_for_characteristic_value_updates(&ins->input_listener); + ins->response_listening = ins->input_listening = false; + ins->command_pending = ins->rumble_scheduled = false; + ins->extra_buttons = 0; + ins->state = SW2_OFF; + ins->device = NULL; +} + +static void sw2_fail(sw2_instance_t* ins, const char* reason, unsigned status) { + if (!ins->device) + return; + uni_hid_device_t* d = ins->device; + loge("Switch2: %s (state=%u, status=%u)\n", reason, (unsigned)ins->state, status); + uni_hid_parser_switch2_teardown(d); + uni_hid_device_disconnect(d); + uni_hid_device_delete(d); +} + +static void sw2_timeout(btstack_timer_source_t* timer) { + sw2_instance_t* ins = btstack_run_loop_get_timer_context(timer); + ins->timeout_active = false; + if (sw2_live(ins)) + sw2_fail(ins, "transaction timeout", 0); +} + +static void sw2_arm_timeout(sw2_instance_t* ins) { + sw2_disarm_timeout(ins); + btstack_run_loop_set_timer_handler(&ins->timeout_timer, sw2_timeout); + btstack_run_loop_set_timer_context(&ins->timeout_timer, ins); + btstack_run_loop_set_timer(&ins->timeout_timer, SW2_TIMEOUT_MS); + ins->timeout_active = true; + btstack_run_loop_add_timer(&ins->timeout_timer); +} + +static void sw2_schedule_output(sw2_instance_t* ins, uint32_t ms) { + if (ins->output_active) + btstack_run_loop_remove_timer(&ins->output_timer); + btstack_run_loop_set_timer_handler(&ins->output_timer, sw2_output_tick); + btstack_run_loop_set_timer_context(&ins->output_timer, ins); + btstack_run_loop_set_timer(&ins->output_timer, ms ? ms : 1); + ins->output_active = true; + btstack_run_loop_add_timer(&ins->output_timer); +} + +static bool sw2_check_query(sw2_instance_t* ins, uint8_t status) { + if (status != ERROR_CODE_SUCCESS) { + sw2_fail(ins, "GATT request rejected", status); + return false; + } + return true; +} + +static void sw2_discover_descriptors(sw2_instance_t* ins, bool input) { + ins->state = input ? SW2_INPUT_DESCRIPTOR : SW2_RESPONSE_DESCRIPTOR; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_characteristic_descriptors( + sw2_gatt_handler, ins->handle, input ? &ins->input : &ins->response)); +} + +static void sw2_subscribe(sw2_instance_t* ins, bool input) { + ins->state = input ? SW2_SUBSCRIBE_INPUT : SW2_SUBSCRIBE_RESPONSE; + ins->query = SW2_QUERY_CCCD; + ins->cccd_data[0] = 1; + ins->cccd_data[1] = 0; + if (input) { + gatt_client_listen_for_characteristic_value_updates(&ins->input_listener, sw2_gatt_handler, ins->handle, &ins->input); + ins->input_listening = true; + } else { + gatt_client_listen_for_characteristic_value_updates(&ins->response_listener, sw2_gatt_handler, ins->handle, &ins->response); + ins->response_listening = true; + } + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_write_characteristic_descriptor_using_descriptor_handle( + sw2_gatt_handler, ins->handle, input ? ins->input_cccd : ins->response_cccd, 2, ins->cccd_data)); +} + +static bool sw2_transient_write_error(uint8_t status) { + return status == BTSTACK_ACL_BUFFERS_FULL || status == GATT_CLIENT_IN_WRONG_STATE; +} + +static void sw2_try_command(sw2_instance_t* ins) { + if (!ins->command_pending || ins->command_sent || ins->query != SW2_QUERY_NONE) + return; + // Re-check immediately before every application-pairing write, including a + // deferred write after ACL backpressure. A closed window cannot write MACs. + if (ins->command_data[0] == 0x15 && !switch_pico_switch2_pairing_allowed()) { + sw2_fail(ins, "pairing window closed", 0); + return; + } + bool no_response = (ins->command.properties & ATT_PROPERTY_WRITE_WITHOUT_RESPONSE) != 0; + uint8_t status; + if (no_response) { + status = gatt_client_write_value_of_characteristic_without_response( + ins->handle, ins->command.value_handle, ins->command_length, ins->command_data); + } else { + ins->query = SW2_QUERY_COMMAND; + status = gatt_client_write_value_of_characteristic(sw2_gatt_handler, ins->handle, + ins->command.value_handle, ins->command_length, ins->command_data); + } + if (status != ERROR_CODE_SUCCESS) { + ins->query = SW2_QUERY_NONE; + if (sw2_transient_write_error(status)) { + sw2_schedule_output(ins, SW2_OUTPUT_INTERVAL_MS); + return; + } + sw2_fail(ins, "command write failed", status); + return; + } + ins->command_sent = true; +} + +static void sw2_send_command(sw2_instance_t* ins, uint8_t command, uint8_t subcommand, + const uint8_t* data, uint8_t length) { + if (ins->command_pending || length > sizeof(ins->command_data) - 8) { + sw2_fail(ins, "overlapping or oversized command", length); + return; + } + uint8_t* out = ins->command_data; + memset(out, 0, 8); + out[0] = command; + out[1] = 0x91; + out[2] = 1; + out[3] = subcommand; + out[5] = length; + if (length) + memcpy(out + 8, data, length); + ins->command_length = 8 + length; + ins->command_pending = true; + ins->command_sent = ins->command_acked = false; + sw2_arm_timeout(ins); + sw2_try_command(ins); +} + +static void sw2_read_memory(sw2_instance_t* ins, uint32_t address, uint8_t length) { + uint8_t data[8] = {length, 0x7e, 0, 0}; + little_endian_store_32(data, 4, address); + ins->memory_address = address; + ins->memory_length = length; + sw2_send_command(ins, 0x02, 0x04, data, sizeof(data)); +} + +static void sw2_unpack_stick(const uint8_t* data, uint16_t out[2]) { + out[0] = data[0] | ((uint16_t)(data[1] & 15) << 8); + out[1] = (data[1] >> 4) | ((uint16_t)data[2] << 4); +} + +static bool sw2_calibration(sw2_stick_t* out, const uint8_t* data) { + sw2_stick_t value; + sw2_unpack_stick(data, value.center); + sw2_unpack_stick(data + 3, value.positive); + sw2_unpack_stick(data + 6, value.negative); + for (unsigned i = 0; i < 2; ++i) { + if (!value.center[i] || value.center[i] == 4095 || !value.positive[i] || !value.negative[i] || + value.positive[i] > 4095 - value.center[i] || value.negative[i] > value.center[i]) + return false; + } + *out = value; + return true; +} + +static void sw2_continue(sw2_instance_t* ins) { + switch (ins->state) { + case SW2_INFO: + sw2_read_memory(ins, 0x13000, 0x40); + break; + case SW2_PAIR: { + uint8_t data[14] = {0, 2}; + if (ins->step == 0) { + bd_addr_t local; + gap_local_bd_addr(local); + for (unsigned i = 0; i < 6; ++i) + data[2 + i] = data[8 + i] = local[5 - i]; + sw2_send_command(ins, 0x15, 0x01, data, sizeof(data)); + } else if (ins->step == 1) { + sw2_send_command(ins, 0x15, 0x04, sw2_pair_exchange, sizeof(sw2_pair_exchange)); + } else if (ins->step == 2) { + sw2_send_command(ins, 0x15, 0x02, sw2_pair_confirm, sizeof(sw2_pair_confirm)); + } else { + sw2_send_command(ins, 0x15, 0x03, data, 1); + } + break; + } + case SW2_CALIBRATION: { + uint32_t address = ins->factory_calibration ? 0x130a8 : 0x1fc042; + if (ins->calibration_slot) + address += ins->factory_calibration ? 0x40 : 0x20; + sw2_read_memory(ins, address, 0x0b); + break; + } + case SW2_GYRO_CALIBRATION: + sw2_read_memory(ins, 0x13044, 12); + break; + case SW2_FEATURES: { + const uint8_t features[4] = {0x04, 0, 0, 0}; + sw2_send_command(ins, 0x0c, ins->step ? 0x04 : 0x02, features, sizeof(features)); + break; + } + case SW2_READY: + if (ins->leds_pending && !ins->command_pending) { + const uint8_t data[8] = {ins->leds, 0, 0, 0, 0, 0, 0, 0}; + ins->leds_pending = false; + sw2_send_command(ins, 0x09, 0x07, data, sizeof(data)); + } + break; + default: + break; + } +} + +static void sw2_complete_command(sw2_instance_t* ins) { + if (!ins->command_pending || !ins->command_acked || ins->query == SW2_QUERY_COMMAND) + return; + ins->command_pending = false; + sw2_disarm_timeout(ins); + switch (ins->state) { + case SW2_INFO: + ins->state = ins->needs_pair ? SW2_PAIR : SW2_CALIBRATION; + break; + case SW2_PAIR: + if (++ins->step == 4) { + if (!uni_switch2_pairing_remember(ins->address_type, ins->address)) { + sw2_fail(ins, "could not persist application pairing", 0); + return; + } + ins->needs_pair = false; + ins->step = 0; + ins->state = SW2_CALIBRATION; + } + break; + case SW2_CALIBRATION: + if (ins->calibration_done) + ins->state = SW2_GYRO_CALIBRATION; + break; + case SW2_GYRO_CALIBRATION: + sw2_subscribe(ins, true); + return; + case SW2_FEATURES: + if (++ins->step == 2) { + ins->state = SW2_READY; + ins->device->controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + if (!uni_hid_device_set_ready_complete(ins->device)) + return; // Platform may have destroyed the device. + sw2_schedule_output(ins, 1); + } + break; + default: + break; + } + sw2_continue(ins); +} + +static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t length) { + if (!ins->command_pending || !ins->command_sent || ins->command_acked || length < 8 || + data[0] != ins->command_data[0] || data[3] != ins->command_data[3] || data[1] != 1 || data[2] != 1) + return; // Unsolicited, truncated or stale ACK is not progress. + if (data[5] != SW2_ACK) { + sw2_fail(ins, "negative application ACK", data[5]); + return; + } + if (data[0] == 0x02) { + if (length < 16 || data[8] != ins->memory_length || + little_endian_read_32(data, 12) != ins->memory_address || length < 16 + ins->memory_length) + return; // Includes a stale memory response with the same cmd/subcmd. + const uint8_t* value = data + 16; + if (ins->state == SW2_INFO) { + if (little_endian_read_16(value, 18) != UNI_SW2_NINTENDO_VID || + little_endian_read_16(value, 20) != ins->device->product_id) { + sw2_fail(ins, "controller identity mismatch", 0); + return; + } + } else if (ins->state == SW2_CALIBRATION) { + // Both solo Joy-Cons store their one stick in calibration slot 1. + unsigned stick = ins->device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : ins->calibration_slot; + bool valid = sw2_calibration(&ins->sticks[stick], value); + if (!valid && !ins->factory_calibration) { + ins->factory_calibration = true; + } else { + if (!valid) + logi("Switch2: invalid factory stick calibration, using full-range mapping\n"); + ins->factory_calibration = false; + if (ins->device->product_id == UNI_SW2_PRO_PID && ins->calibration_slot == 0) + ins->calibration_slot = 1; + else + ins->calibration_done = true; + } + } else if (ins->state == SW2_GYRO_CALIBRATION) { + for (unsigned i = 0; i < 3; ++i) { + uint32_t bits = little_endian_read_32(value, 4 * i); + float bias; + memcpy(&bias, &bits, sizeof(bias)); + // Erased/invalid flash is not a floating-point sensor value. + if (isfinite(bias) && bias >= -40.0f && bias <= 40.0f) + ins->gyro_bias[i] = (int32_t)(bias * (57.295779513f * UNI_IMU_GYRO_RES_PER_DEG_S)); + } + } + } else if (ins->state == SW2_PAIR) { + unsigned payload_length = ins->step == 0 ? 9 : ins->step == 3 ? 1 : 17; + if (length < 8 + payload_length) + return; + if (data[8] != 1) { + sw2_fail(ins, "pairing command rejected", data[8]); + return; + } + } + ins->command_acked = true; + sw2_complete_command(ins); +} + +static void sw2_query_complete(sw2_instance_t* ins, uint8_t status) { + sw2_query_t query = ins->query; + if (query == SW2_QUERY_NONE) + return; + ins->query = SW2_QUERY_NONE; + if (status != ATT_ERROR_SUCCESS) { + sw2_fail(ins, "GATT transaction failed", status); + return; + } + if (query == SW2_QUERY_COMMAND) { + sw2_complete_command(ins); + return; + } + if (query == SW2_QUERY_RUMBLE) { + ++ins->rumble_id; + // A command queued behind this write has its own timeout already. + if (!ins->command_pending) + sw2_disarm_timeout(ins); + sw2_try_command(ins); + return; + } + sw2_disarm_timeout(ins); + switch (ins->state) { + case SW2_SERVICE: + if (!ins->service.start_group_handle) { + sw2_fail(ins, "missing proprietary service", 0); + return; + } + ins->state = SW2_CHARACTERISTICS; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_characteristics_for_service(sw2_gatt_handler, ins->handle, &ins->service)); + break; + case SW2_CHARACTERISTICS: + if (!ins->input.value_handle || !ins->response.value_handle || !ins->command.value_handle || !ins->rumble.value_handle) { + sw2_fail(ins, "missing required characteristic", 0); + return; + } + sw2_discover_descriptors(ins, false); + break; + case SW2_RESPONSE_DESCRIPTOR: + if (!ins->response_cccd) { + sw2_fail(ins, "missing response CCCD", 0); + return; + } + sw2_discover_descriptors(ins, true); + break; + case SW2_INPUT_DESCRIPTOR: + if (!ins->input_cccd) { + sw2_fail(ins, "missing input CCCD", 0); + return; + } + sw2_subscribe(ins, false); + break; + case SW2_SUBSCRIBE_RESPONSE: + ins->state = SW2_INFO; + sw2_continue(ins); + break; + case SW2_SUBSCRIBE_INPUT: + ins->state = SW2_FEATURES; + ins->step = 0; + sw2_continue(ins); + break; + default: + sw2_fail(ins, "unexpected query completion", query); + break; + } +} + +static void sw2_characteristic(sw2_instance_t* ins, const gatt_client_characteristic_t* characteristic) { + if (ins->state != SW2_CHARACTERISTICS) + return; + const uint8_t* rumble_uuid = ins->device->product_id == UNI_SW2_PRO_PID ? sw2_rumble_pro_uuid : + ins->device->product_id == UNI_SW2_JOYCON_L_PID ? sw2_rumble_left_uuid : sw2_rumble_right_uuid; + gatt_client_characteristic_t* target = NULL; + uint16_t properties = ATT_PROPERTY_NOTIFY; + if (memcmp(characteristic->uuid128, sw2_input_uuid, 16) == 0) + target = &ins->input; + else if (memcmp(characteristic->uuid128, sw2_response_uuid, 16) == 0) + target = &ins->response; + else if (memcmp(characteristic->uuid128, sw2_command_uuid, 16) == 0) { + target = &ins->command; + properties = ATT_PROPERTY_WRITE | ATT_PROPERTY_WRITE_WITHOUT_RESPONSE; + } else if (memcmp(characteristic->uuid128, rumble_uuid, 16) == 0) { + target = &ins->rumble; + properties = ATT_PROPERTY_WRITE | ATT_PROPERTY_WRITE_WITHOUT_RESPONSE; + } + if (!target) + return; + if (target->value_handle || !(characteristic->properties & properties) || + characteristic->start_handle <= ins->service.start_group_handle || + characteristic->value_handle <= characteristic->start_handle || + characteristic->end_handle < characteristic->value_handle || + characteristic->end_handle > ins->service.end_group_handle) { + sw2_fail(ins, "invalid or ambiguous characteristic", characteristic->value_handle); + return; + } + *target = *characteristic; +} + +static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* packet, uint16_t size) { + (void)channel; + if (packet_type != HCI_EVENT_PACKET || !packet || size < 4) + return; + uint8_t event = packet[0]; + // BTstack GATT events have extended lengths: notification length is uint16 + // and event[1] may wrap. Validate the actual callback size before accessors. + uint16_t required; + switch (event) { + case GATT_EVENT_SERVICE_QUERY_RESULT: required = 28; break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: required = 32; break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: required = 26; break; + case GATT_EVENT_QUERY_COMPLETE: required = 9; break; + case GATT_EVENT_NOTIFICATION: required = 12; break; + default: return; + } + if (size < required) + return; + uni_hid_device_t* d = uni_hid_device_get_instance_for_connection_handle(little_endian_read_16(packet, 2)); + sw2_instance_t* ins = sw2_instance(d); + if (!ins || !sw2_live(ins)) + return; + switch (event) { + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (ins->state != SW2_SERVICE) + return; + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + if (ins->service.start_group_handle || memcmp(service.uuid128, sw2_service_uuid, 16) != 0 || + !service.start_group_handle || service.end_group_handle <= service.start_group_handle) { + sw2_fail(ins, "invalid or ambiguous service", 0); + return; + } + ins->service = service; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + sw2_characteristic(ins, &characteristic); + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (ins->state != SW2_RESPONSE_DESCRIPTOR && ins->state != SW2_INPUT_DESCRIPTOR) + return; + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + if (descriptor.uuid16 != SW2_CCCD_UUID) + return; + bool input = ins->state == SW2_INPUT_DESCRIPTOR; + gatt_client_characteristic_t* characteristic = input ? &ins->input : &ins->response; + uint16_t* handle = input ? &ins->input_cccd : &ins->response_cccd; + if (*handle || descriptor.handle <= characteristic->value_handle || descriptor.handle > characteristic->end_handle) { + sw2_fail(ins, "invalid or ambiguous CCCD", descriptor.handle); + return; + } + *handle = descriptor.handle; + break; + } + case GATT_EVENT_QUERY_COMPLETE: + sw2_query_complete(ins, gatt_event_query_complete_get_att_status(packet)); + break; + case GATT_EVENT_NOTIFICATION: { + uint16_t length = gatt_event_notification_get_value_length(packet); + if (length > size - 12) + return; + uint16_t handle = gatt_event_notification_get_value_handle(packet); + const uint8_t* value = gatt_event_notification_get_value(packet); + if (ins->response_listening && handle == ins->response.value_handle) + sw2_response(ins, value, length); + else if (ins->state == SW2_READY && ins->input_listening && handle == ins->input.value_handle && length == SW2_REPORT_SIZE) { + uni_hid_parser_switch2_parse_input_report(d, value, length); + uni_hid_device_process_controller(d); + } + break; + } + } +} + +bool uni_bt_le_switch2_handle_advertisement(const uint8_t* packet, uint16_t size) { + if (!packet || size < 12 || packet[0] != GAP_EVENT_ADVERTISING_REPORT || + (unsigned)packet[1] + 2 > size || packet[1] < 10 || packet[11] > packet[1] - 10) + return false; + // Standard ADV_IND only; never connect to scan responses or wake-host data. + if (gap_event_advertising_report_get_advertising_event_type(packet) != 0) + return false; + const uint8_t* data = gap_event_advertising_report_get_data(packet); + unsigned length = gap_event_advertising_report_get_data_length(packet); + const uint8_t* manufacturer = NULL; + for (unsigned offset = 0; offset < length;) { + unsigned field_length = data[offset]; + if (!field_length) + break; + if (field_length > length - offset - 1) + return false; + if (data[offset + 1] == 0xff && field_length >= 19) { + const uint8_t* candidate = data + offset + 2; + if (little_endian_read_16(candidate, 0) == 0x0553 && + little_endian_read_16(candidate, 5) == UNI_SW2_NINTENDO_VID && + sw2_product(little_endian_read_16(candidate, 7))) { + if (manufacturer) + return false; + manufacturer = candidate; + } + } + offset += field_length + 1; + } + if (!manufacturer) + return false; + bd_addr_t local, address; + gap_local_bd_addr(local); + gap_event_advertising_report_get_address(packet, address); + bool fresh = true, reconnect = true; + for (unsigned i = 0; i < 6; ++i) { + fresh &= manufacturer[12 + i] == 0; + reconnect &= manufacturer[12 + i] == local[5 - i]; + } + // Returning true consumes recognized but disallowed Switch2 advertisements, + // preventing generic HID/SMP admission from bypassing this protocol policy. + if ((!fresh && !reconnect) || (fresh && !switch_pico_switch2_pairing_allowed())) + return true; + uint8_t address_type = gap_event_advertising_report_get_address_type(packet); + if (address_type != BD_ADDR_TYPE_LE_PUBLIC && + !(address_type == BD_ADDR_TYPE_LE_RANDOM && (address[0] & 0xc0) == 0xc0)) + return true; + if (!fresh && !uni_switch2_pairing_known(address_type, address)) + return true; + if (uni_hid_device_get_instance_for_address(address)) + return true; + uint8_t rssi = gap_event_advertising_report_get_rssi(packet); + const uint16_t cod = UNI_BT_COD_MAJOR_PERIPHERAL | UNI_BT_COD_MINOR_GAMEPAD; + if (uni_hid_device_on_device_discovered(address, "Switch2", cod, rssi) != UNI_ERROR_SUCCESS) + return true; + sw2_instance_t* ins = NULL; + for (unsigned i = 0; i < CONFIG_BLUEPAD32_MAX_DEVICES; ++i) { + if (sw2_instances[i].state == SW2_OFF && + (!sw2_instances[i].allocated || gap_get_connection_type(sw2_instances[i].handle) == GAP_CONNECTION_INVALID)) { + ins = &sw2_instances[i]; + break; + } + } + if (!ins) + return true; + uni_hid_device_t* d = uni_hid_device_create(address); + if (!d) + return true; + memset(ins, 0, sizeof(*ins)); + ins->allocated = true; + ins->device = d; + memcpy(ins->address, address, sizeof(ins->address)); + ins->address_type = address_type; + ins->handle = UNI_BT_CONN_HANDLE_INVALID; + ins->needs_pair = fresh; + ins->state = SW2_ADMITTED; + for (unsigned stick = 0; stick < 2; ++stick) { + for (unsigned axis = 0; axis < 2; ++axis) { + ins->sticks[stick].center[axis] = 2048; + ins->sticks[stick].positive[axis] = 2047; + ins->sticks[stick].negative[axis] = 2048; + } + } + uni_hid_device_set_vendor_id(d, UNI_SW2_NINTENDO_VID); + uni_hid_device_set_product_id(d, little_endian_read_16(manufacturer, 7)); + uni_bt_conn_set_protocol(&d->conn, UNI_BT_CONN_PROTOCOL_BLE); + uni_hid_device_guess_controller_type_from_pid_vid(d); + d->sdp_query_type = SDP_QUERY_NOT_NEEDED; + uni_hid_device_set_cod(d, cod); + uni_hid_device_set_name(d, "Switch2"); + uni_bt_conn_set_state(&d->conn, UNI_BT_CONN_STATE_DEVICE_DISCOVERED); + d->conn.rssi = rssi; + gap_stop_scan(); // Existing BLE lifecycle owns resuming scan under policy. + uint8_t status = gap_connect(address, address_type); + if (status != ERROR_CODE_SUCCESS) + sw2_fail(ins, "BLE connection request failed", status); + return true; +} + +void uni_hid_parser_switch2_on_le_connected(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_ADMITTED) + return; + ins->handle = d->conn.handle; + if (!d->conn.connected) + uni_hid_device_connect(d); + // A platform callback can reject/disconnect and destroy this device. + if (sw2_instance(d) == ins && d->conn.connected) + uni_hid_device_set_ready(d); +} + +void uni_hid_parser_switch2_setup(uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_ADMITTED) + return; + ins->handle = d->conn.handle; + // Standard-compliant 7.5ms minimum; negotiation failure is not setup failure. + int status = gap_update_connection_parameters(ins->handle, 6, 6, 0, 600); + if (status != ERROR_CODE_SUCCESS) + logi("Switch2: connection interval request declined (%d)\n", status); + ins->state = SW2_SERVICE; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_primary_services_by_uuid128(sw2_gatt_handler, ins->handle, sw2_service_uuid)); +} + +uint8_t uni_hid_parser_switch2_extra_buttons(const uni_hid_device_t* d) { + sw2_instance_t* ins = sw2_instance(d); + return ins ? ins->extra_buttons : 0; +} + +bool uni_hid_parser_switch2_identity_address_type(const uni_hid_device_t* d, uint8_t* out) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || !out) + return false; + *out = ins->address_type; + return true; +} + +void uni_hid_parser_switch2_init_report(uni_hid_device_t* d) { + (void)d; // Full snapshots replace state only after their complete length is validated. +} + +static int32_t sw2_axis(uint16_t raw, const sw2_stick_t* stick, unsigned axis, bool invert) { + int32_t delta = (int32_t)raw - stick->center[axis]; + int32_t result = delta * 512 / (delta < 0 ? stick->negative[axis] : stick->positive[axis]); + if (invert) + result = -result; + return result < -512 ? -512 : result > 511 ? 511 : result; +} + +static void sw2_motion(sw2_instance_t* ins, uni_gamepad_t* gp, const uint8_t* report) { + uint32_t timestamp = little_endian_read_32(report, 42); + if (!timestamp || (timestamp == ins->sensor_last && !ins->gyro_full_scale)) + return; + ins->sensor_last = timestamp; + if (!ins->gyro_full_scale) { + // SDL detects two sensor clock/range variants. BLE reports need not be + // 4ms apart as USB reports are: compare sensor ticks against elapsed + // host time over >=400ms instead of assuming USB's 100 * 4ms interval. + uint32_t now = btstack_run_loop_get_time_ms(); + if (ins->sensor_warmup < 5) { + if (++ins->sensor_warmup == 5) { + ins->sensor_start = timestamp; + ins->sensor_host_start = now; + } + return; + } + uint32_t elapsed = now - ins->sensor_host_start; + if (elapsed < 400) + return; + uint32_t ticks = timestamp - ins->sensor_start; // Defined wraparound. + if (!ticks || elapsed > 2000) { + ins->sensor_start = timestamp; + ins->sensor_host_start = now; + return; + } + uint32_t rate = ticks / elapsed; + // round(34.8 or 40 radians/s * 180/pi * q10). + ins->gyro_full_scale = rate >= 900 && rate <= 1100 ? 2041747 : 2346835; + } + // The common report is SDL's USB 0x05 report without the report-ID byte. + // SDL vertical/paired axes: X, Z, -Y. Backend alone rotates solo Joy-Cons. + static const uint8_t axes[3] = {0, 2, 1}; + for (unsigned i = 0; i < 3; ++i) { + unsigned axis = axes[i]; + int32_t accel = (int16_t)little_endian_read_16(report, 48 + 2 * axis); + int32_t gyro = (int16_t)little_endian_read_16(report, 54 + 2 * axis); + gp->accel[i] = uni_imu_scale(accel, 32767, 8 * UNI_IMU_ACCEL_RES_PER_G); + gp->gyro[i] = uni_imu_scale(gyro, 32767, ins->gyro_full_scale) - ins->gyro_bias[axis]; + if (i == 2) { + gp->accel[i] = -gp->accel[i]; + gp->gyro[i] = -gp->gyro[i]; + } + } +} + +void uni_hid_parser_switch2_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY || !report || len != SW2_REPORT_SIZE) + return; + uni_gamepad_t* gp = &d->controller.gamepad; + memset(gp, 0, sizeof(*gp)); + uint32_t buttons = little_endian_read_32(report, 4); + gp->buttons = ((buttons & 0x04) ? BUTTON_A : 0) | ((buttons & 0x08) ? BUTTON_B : 0) | + ((buttons & 0x01) ? BUTTON_X : 0) | ((buttons & 0x02) ? BUTTON_Y : 0) | + ((buttons & 0x40) ? BUTTON_SHOULDER_R : 0) | ((buttons & 0x400000) ? BUTTON_SHOULDER_L : 0) | + ((buttons & 0x80) ? BUTTON_TRIGGER_R : 0) | ((buttons & 0x800000) ? BUTTON_TRIGGER_L : 0) | + ((buttons & 0x400) ? BUTTON_THUMB_R : 0) | ((buttons & 0x800) ? BUTTON_THUMB_L : 0); + gp->dpad = ((buttons & 0x10000) ? DPAD_DOWN : 0) | ((buttons & 0x20000) ? DPAD_UP : 0) | + ((buttons & 0x40000) ? DPAD_RIGHT : 0) | ((buttons & 0x80000) ? DPAD_LEFT : 0); + gp->misc_buttons = ((buttons & 0x100) ? MISC_BUTTON_SELECT : 0) | ((buttons & 0x200) ? MISC_BUTTON_START : 0) | + ((buttons & 0x1000) ? MISC_BUTTON_SYSTEM : 0) | ((buttons & 0x2000) ? MISC_BUTTON_CAPTURE : 0); + gp->brake = (buttons & 0x800000) ? 1023 : 0; + gp->throttle = (buttons & 0x80) ? 1023 : 0; + ins->extra_buttons = (buttons & 0x4000) ? UNI_SW2_BUTTON_C : 0; + if (d->product_id == UNI_SW2_PRO_PID) + ins->extra_buttons |= ((buttons & 0x2000000) ? UNI_SW2_BUTTON_GL : 0) | ((buttons & 0x1000000) ? UNI_SW2_BUTTON_GR : 0); + else if (d->product_id == UNI_SW2_JOYCON_L_PID) + ins->extra_buttons |= ((buttons & 0x200000) ? UNI_SW2_BUTTON_LEFT_SL : 0) | ((buttons & 0x100000) ? UNI_SW2_BUTTON_LEFT_SR : 0); + else + ins->extra_buttons |= ((buttons & 0x20) ? UNI_SW2_BUTTON_RIGHT_SL : 0) | ((buttons & 0x10) ? UNI_SW2_BUTTON_RIGHT_SR : 0); + uint16_t axes[2]; + if (d->product_id != UNI_SW2_JOYCON_R_PID) { + sw2_unpack_stick(report + 10, axes); + gp->axis_x = sw2_axis(axes[0], &ins->sticks[0], 0, false); + gp->axis_y = sw2_axis(axes[1], &ins->sticks[0], 1, true); + } + if (d->product_id != UNI_SW2_JOYCON_L_PID) { + sw2_unpack_stick(report + 13, axes); + gp->axis_rx = sw2_axis(axes[0], &ins->sticks[1], 0, false); + gp->axis_ry = sw2_axis(axes[1], &ins->sticks[1], 1, true); + } + sw2_motion(ins, gp, report); + d->controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD; +} + +void uni_hid_parser_switch2_set_player_leds(uni_hid_device_t* d, uint8_t leds) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins) + return; + ins->leds = leds & 0x0f; // Bluepad32 passes a bitmask, not a player number. + ins->leds_pending = true; + if (ins->state == SW2_READY) + sw2_continue(ins); +} + +static void sw2_rumble_block(uint8_t* out, uint8_t id, uint8_t weak, uint8_t strong) { + // Three consecutive equal frames form a safe sustained HOLD. Weak controls + // the high-frequency amplitude, strong the low-frequency amplitude. + uint64_t frame = 0x0e1u | ((uint64_t)strong * 4 << 10) | ((uint64_t)0x1e1 << 20) | + ((uint64_t)weak * 4 << 30); + out[0] = 0x50 | (id & 15); + for (unsigned i = 0; i < 5; ++i) + out[1 + i] = (uint8_t)(frame >> (8 * i)); + memcpy(out + 6, out + 1, 5); + memcpy(out + 11, out + 1, 5); +} + +static void sw2_send_rumble(sw2_instance_t* ins, uint32_t now) { + if (ins->query != SW2_QUERY_NONE || ins->command_pending) + return; + uint8_t weak = 0, strong = 0; + if (ins->rumble_scheduled) { + if (!ins->rumble_held && (int32_t)(now - ins->rumble_end) >= 0) + ins->rumble_scheduled = false; + else if ((int32_t)(now - ins->rumble_start) >= 0) { + weak = ins->weak; + strong = ins->strong; + } + } + ins->rumble_data[0] = 0; + sw2_rumble_block(ins->rumble_data + 1, ins->rumble_id, weak, strong); + uint16_t length = 17; + if (ins->device->product_id == UNI_SW2_PRO_PID) { + memcpy(ins->rumble_data + 17, ins->rumble_data + 1, 16); + length = 33; + } + bool no_response = (ins->rumble.properties & ATT_PROPERTY_WRITE_WITHOUT_RESPONSE) != 0; + uint8_t status; + if (no_response) { + status = gatt_client_write_value_of_characteristic_without_response(ins->handle, ins->rumble.value_handle, + length, ins->rumble_data); + } else { + ins->query = SW2_QUERY_RUMBLE; + if (!ins->command_pending) + sw2_arm_timeout(ins); + status = gatt_client_write_value_of_characteristic(sw2_gatt_handler, ins->handle, ins->rumble.value_handle, + length, ins->rumble_data); + } + if (status == ERROR_CODE_SUCCESS) { + if (no_response) + ++ins->rumble_id; + } else { + ins->query = SW2_QUERY_NONE; + if (!ins->command_pending) + sw2_disarm_timeout(ins); + if (!sw2_transient_write_error(status)) + sw2_fail(ins, "rumble write failed", status); + } +} + +static void sw2_output_tick(btstack_timer_source_t* timer) { + sw2_instance_t* ins = btstack_run_loop_get_timer_context(timer); + ins->output_active = false; + if (!sw2_live(ins)) + return; + sw2_try_command(ins); + if (!ins->device) + return; + if (ins->state != SW2_READY) + return; // A blocked setup command rescheduled itself, or awaits its ACK. + uint32_t now = btstack_run_loop_get_time_ms(); + sw2_send_rumble(ins, now); + if (!ins->device) + return; + uint32_t next = SW2_OUTPUT_INTERVAL_MS; + if (ins->rumble_scheduled && (!ins->rumble_held || (int32_t)(now - ins->rumble_start) < 0)) { + uint32_t boundary = (int32_t)(now - ins->rumble_start) < 0 ? ins->rumble_start : ins->rumble_end; + if ((int32_t)(boundary - now) > 0 && boundary - now < next) + next = boundary - now; + } + sw2_schedule_output(ins, next); +} + +void uni_hid_parser_switch2_play_dual_rumble(uni_hid_device_t* d, uint16_t delay_ms, uint16_t duration_ms, + uint8_t weak, uint8_t strong) { + sw2_instance_t* ins = sw2_instance(d); + if (!ins || ins->state != SW2_READY) + return; + uint32_t now = btstack_run_loop_get_time_ms(); + ins->weak = weak; + ins->strong = strong; + ins->rumble_start = now + delay_ms; + ins->rumble_end = ins->rumble_start + duration_ms; + ins->rumble_held = duration_ms == UINT16_MAX; + ins->rumble_scheduled = duration_ms != 0 && (weak != 0 || strong != 0); + // UINT16_MAX is the host's stateful/held sentinel; every other duration is + // finite. Keepalive preserves either effect until replacement or stop. + sw2_schedule_output(ins, 1); +} diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.h b/bluepad32_config/parser/uni_hid_parser_switch2.h new file mode 100644 index 0000000..607d3fe --- /dev/null +++ b/bluepad32_config/parser/uni_hid_parser_switch2.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct uni_hid_device_s; + +#define UNI_SW2_NINTENDO_VID 0x057e +#define UNI_SW2_PRO_PID 0x2069 +#define UNI_SW2_JOYCON_L_PID 0x2067 +#define UNI_SW2_JOYCON_R_PID 0x2066 + +enum { + UNI_SW2_BUTTON_C = 1u << 0, + UNI_SW2_BUTTON_GL = 1u << 1, + UNI_SW2_BUTTON_GR = 1u << 2, + UNI_SW2_BUTTON_LEFT_SL = 1u << 3, + UNI_SW2_BUTTON_LEFT_SR = 1u << 4, + UNI_SW2_BUTTON_RIGHT_SL = 1u << 5, + UNI_SW2_BUTTON_RIGHT_SR = 1u << 6, +}; + +// Supplied by the platform's existing bounded pairing-window policy. +bool switch_pico_switch2_pairing_allowed(void); + +bool uni_bt_le_switch2_handle_advertisement(const uint8_t* packet, uint16_t size); +bool uni_hid_parser_switch2_is_ble_device(const struct uni_hid_device_s* d); +void uni_hid_parser_switch2_on_le_connected(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_setup(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_teardown(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_init_report(struct uni_hid_device_s* d); +void uni_hid_parser_switch2_parse_input_report(struct uni_hid_device_s* d, const uint8_t* report, uint16_t len); +void uni_hid_parser_switch2_set_player_leds(struct uni_hid_device_s* d, uint8_t leds); +void uni_hid_parser_switch2_play_dual_rumble(struct uni_hid_device_s* d, uint16_t delay_ms, uint16_t duration_ms, + uint8_t weak, uint8_t strong); +uint8_t uni_hid_parser_switch2_extra_buttons(const struct uni_hid_device_s* d); +// Validated public/static-random advertisement address, never an RPA or SMP identity. +bool uni_hid_parser_switch2_identity_address_type(const struct uni_hid_device_s* d, uint8_t* out); + +#ifdef __cplusplus +} +#endif diff --git a/bluepad32_config/parser/uni_switch2_pairing.c b/bluepad32_config/parser/uni_switch2_pairing.c new file mode 100644 index 0000000..7a3af4e --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_pairing.c @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "parser/uni_switch2_pairing.h" + +#include +#include +#include + +#define PAIRING_TAG 0x53325052u // S2PR, separate from BTstack BTD/BTL records. +#define HEADER_SIZE 2u +#define RECORD_SIZE 7u +#define STORE_SIZE (HEADER_SIZE + UNI_SWITCH2_PAIRING_CAPACITY * RECORD_SIZE) + +static bool stable_address(uint8_t type, const uint8_t address[6]) { + if (address == NULL || type > 1) return false; + unsigned any = 0; + unsigned not_ff = 0; + for (unsigned i = 0; i < 6; ++i) { + any |= address[i]; + not_ff |= address[i] ^ 0xffu; + } + if (any == 0 || not_ff == 0) return false; + if (type == 0) return true; + if ((address[0] & 0xc0u) != 0xc0u) return false; + // The random portion of a static random address cannot be all zero/one. + any = address[0] & 0x3fu; + not_ff = (address[0] & 0x3fu) ^ 0x3fu; + for (unsigned i = 1; i < 6; ++i) { + any |= address[i]; + not_ff |= address[i] ^ 0xffu; + } + return any != 0 && not_ff != 0; +} + +static bool load(const btstack_tlv_t** tlv, void** context, uint8_t data[STORE_SIZE]) { + btstack_tlv_get_instance(tlv, context); + if (*tlv == NULL || (*tlv)->get_tag == NULL || (*tlv)->store_tag == NULL) return false; + int size = (*tlv)->get_tag(*context, PAIRING_TAG, data, STORE_SIZE); + if (size == 0) { + data[0] = 1; + data[1] = 0; + return true; + } + if (size < (int)HEADER_SIZE || data[0] != 1 || data[1] > UNI_SWITCH2_PAIRING_CAPACITY || + size != (int)(HEADER_SIZE + data[1] * RECORD_SIZE)) return false; + for (uint8_t i = 0; i < data[1]; ++i) { + const uint8_t* row = data + HEADER_SIZE + i * RECORD_SIZE; + if (!stable_address(row[0], row + 1)) return false; + if (i != 0 && memcmp(row - RECORD_SIZE, row, RECORD_SIZE) >= 0) return false; + } + return true; +} + +bool uni_switch2_pairing_known(uint8_t type, const uint8_t address[6]) { + if (!stable_address(type, address)) return false; + const btstack_tlv_t* tlv; + void* context; + uint8_t data[STORE_SIZE]; + if (!load(&tlv, &context, data)) return false; + for (uint8_t i = 0; i < data[1]; ++i) { + const uint8_t* row = data + HEADER_SIZE + i * RECORD_SIZE; + if (row[0] == type && memcmp(row + 1, address, 6) == 0) return true; + } + return false; +} + +bool uni_switch2_pairing_remember(uint8_t type, const uint8_t address[6]) { + if (!stable_address(type, address)) return false; + const btstack_tlv_t* tlv; + void* context; + uint8_t data[STORE_SIZE]; + if (!load(&tlv, &context, data)) return false; + uint8_t record[RECORD_SIZE]; + record[0] = type; + memcpy(record + 1, address, 6); + uint8_t index = 0; + for (; index < data[1]; ++index) { + int order = memcmp(record, data + HEADER_SIZE + index * RECORD_SIZE, RECORD_SIZE); + if (order == 0) return true; + if (order < 0) break; + } + if (data[1] == UNI_SWITCH2_PAIRING_CAPACITY) return false; + uint8_t* insertion = data + HEADER_SIZE + index * RECORD_SIZE; + memmove(insertion + RECORD_SIZE, insertion, (data[1] - index) * RECORD_SIZE); + memcpy(insertion, record, RECORD_SIZE); + ++data[1]; + return tlv->store_tag(context, PAIRING_TAG, data, HEADER_SIZE + data[1] * RECORD_SIZE) == 0; +} + +bool uni_switch2_pairing_clear(void) { + const btstack_tlv_t* tlv; + void* context; + btstack_tlv_get_instance(&tlv, &context); + if (tlv == NULL || tlv->store_tag == NULL) return false; + // Store an empty record rather than the void delete API: callers must know + // whether forgetting was persisted before acknowledging clear-pairings. + const uint8_t empty[HEADER_SIZE] = {1, 0}; + return tlv->store_tag(context, PAIRING_TAG, empty, sizeof(empty)) == 0; +} + +bool uni_switch2_pairing_get(uint8_t index, uint8_t* type, uint8_t address[6]) { + if (type == NULL || address == NULL) return false; + const btstack_tlv_t* tlv; + void* context; + uint8_t data[STORE_SIZE]; + if (!load(&tlv, &context, data) || index >= data[1]) return false; + const uint8_t* row = data + HEADER_SIZE + index * RECORD_SIZE; + *type = row[0]; + memcpy(address, row + 1, 6); + return true; +} diff --git a/bluepad32_config/parser/uni_switch2_pairing.h b/bluepad32_config/parser/uni_switch2_pairing.h new file mode 100644 index 0000000..91e4202 --- /dev/null +++ b/bluepad32_config/parser/uni_switch2_pairing.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Application-level trust, not SMP bonds or cryptographic authentication. +// Call on the Bluetooth/storage core after the BTstack TLV backend is ready. +#define UNI_SWITCH2_PAIRING_CAPACITY 16 +bool uni_switch2_pairing_known(uint8_t address_type, const uint8_t address[6]); +bool uni_switch2_pairing_remember(uint8_t address_type, const uint8_t address[6]); +bool uni_switch2_pairing_clear(void); +bool uni_switch2_pairing_get(uint8_t index, uint8_t* address_type, uint8_t address[6]); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 8bcd5b1..547810c 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index 3eabf8e..158586b 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index e99b233..cadea1e 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index d0b92a5..6b16d3a 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/firmware/switch-pico.elf b/firmware/switch-pico.elf index fe578af..18e52eb 100755 Binary files a/firmware/switch-pico.elf and b/firmware/switch-pico.elf differ diff --git a/firmware/switch-pico.uf2 b/firmware/switch-pico.uf2 index fdbad07..ea65ae4 100644 Binary files a/firmware/switch-pico.uf2 and b/firmware/switch-pico.uf2 differ diff --git a/patches/bluepad32-sdl3-imu.patch b/patches/bluepad32-sdl3-imu.patch index a9225fa..6e4a2ce 100644 --- a/patches/bluepad32-sdl3-imu.patch +++ b/patches/bluepad32-sdl3-imu.patch @@ -1,8 +1,34 @@ +diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c +--- a/src/components/bluepad32/bt/uni_bt.c ++++ b/src/components/bluepad32/bt/uni_bt.c +@@ -73,6 +73,12 @@ + #include "uni_log.h" + #include "uni_property.h" + ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO. ++extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device, ++ uint16_t local_cid); ++#endif ++ + // globals + bd_addr_t uni_local_bd_addr; + +@@ -473,6 +479,10 @@ + if (device == NULL) { + loge("--->>> CANNOT FIND DEVICE"); + } else { ++#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT ++ if (uni_platform_on_l2cap_can_send_now(device, local_cid)) ++ break; ++#endif + uni_hid_device_send_queued_reports(device); + } + break; diff --git a/src/components/bluepad32/bt/uni_bt_bredr.c b/src/components/bluepad32/bt/uni_bt_bredr.c -index 955cc6f..4013cc1 100644 --- a/src/components/bluepad32/bt/uni_bt_bredr.c +++ b/src/components/bluepad32/bt/uni_bt_bredr.c -@@ -423,13 +423,14 @@ void uni_bt_bredr_on_l2cap_channel_opened(uint16_t channel, const uint8_t* packe +@@ -423,13 +423,14 @@ status = l2cap_event_channel_opened_get_status(packet); if (status) { logi("L2CAP Connection failed: 0x%02x.\n", status); @@ -21,8 +47,164 @@ index 955cc6f..4013cc1 100644 uni_hid_device_disconnect(device); uni_hid_device_delete(device); /* 'device' is destroyed, don't use */ +diff --git a/src/components/bluepad32/bt/uni_bt_le.c b/src/components/bluepad32/bt/uni_bt_le.c +--- a/src/components/bluepad32/bt/uni_bt_le.c ++++ b/src/components/bluepad32/bt/uni_bt_le.c +@@ -68,6 +68,7 @@ + #include "bt/uni_bt_conn.h" + #include "bt/uni_bt_defines.h" + #include "parser/uni_hid_parser.h" ++#include "parser/uni_hid_parser_switch2.h" + #include "uni_common.h" + #include "uni_config.h" + #include "uni_hid_device.h" +@@ -107,7 +108,7 @@ + uni_hid_device_t* device; + + device = uni_hid_device_get_instance_for_connection_handle(con_handle); +- if (device) { ++ if (device && !uni_hid_parser_switch2_is_ble_device(device)) { + status = hids_client_disconnect(device->hids_cid); + if (status != ERROR_CODE_SUCCESS) { + loge("Failed to disconnect HIDS client for hids_cid=%d, status=%d\n", device->hids_cid, status); +@@ -560,10 +561,22 @@ + type = hci_event_packet_get_type(packet); + switch (type) { + case SM_EVENT_JUST_WORKS_REQUEST: ++ con_handle = sm_event_just_works_request_get_handle(packet); ++ device = uni_hid_device_get_instance_for_connection_handle(con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ hog_disconnect(con_handle); ++ break; ++ } + logi("Just works requested\n"); + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_NUMERIC_COMPARISON_REQUEST: ++ con_handle = sm_event_numeric_comparison_request_get_handle(packet); ++ device = uni_hid_device_get_instance_for_connection_handle(con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ hog_disconnect(con_handle); ++ break; ++ } + logi("Confirming numeric comparison: %" PRIu32 "\n", + sm_event_numeric_comparison_request_get_passkey(packet)); + sm_numeric_comparison_confirm(sm_event_passkey_display_number_get_handle(packet)); +@@ -598,6 +611,12 @@ + break; + case SM_EVENT_REENCRYPTION_COMPLETE: + con_handle = sm_event_reencryption_complete_get_handle(packet); ++ device = uni_hid_device_get_instance_for_connection_handle(con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ // Proprietary Switch 2 GATT must never enter HOGP or re-pair SMP. ++ hog_disconnect(con_handle); ++ break; ++ } + switch (sm_event_reencryption_complete_get_status(packet)) { + case ERROR_CODE_SUCCESS: + logi("Re-encryption complete, success\n"); +@@ -630,6 +649,10 @@ + con_handle = sm_event_pairing_complete_get_handle(packet); + if (!device) { + loge("SM_EVENT_PAIRING_COMPLETE: Invalid device for addr %s\n", bd_addr_to_str(addr)); ++ hog_disconnect(con_handle); ++ break; ++ } ++ if (uni_hid_parser_switch2_is_ble_device(device)) { + hog_disconnect(con_handle); + break; + } +@@ -700,7 +723,18 @@ + con_handle = hci_subevent_le_connection_complete_get_connection_handle(packet); + logi("Using con_handle: %#x\n", con_handle); + ++ if (uni_hid_parser_switch2_is_ble_device(device) && ++ hci_subevent_le_connection_complete_get_status(packet) != ERROR_CODE_SUCCESS) { ++ // A failed event has no valid connection handle to disconnect. ++ uni_hid_device_delete(device); ++ resume_scanning_hint(); ++ break; ++ } + uni_hid_device_set_connection_handle(device, con_handle); ++ if (uni_hid_parser_switch2_is_ble_device(device)) { ++ uni_hid_parser_switch2_on_le_connected(device); ++ break; ++ } + sm_request_pairing(con_handle); + + // Resume scanning +@@ -739,6 +773,8 @@ + if (device->conn.protocol != UNI_BT_CONN_PROTOCOL_BLE) + // Abort on non BLE connections + return; ++ if (uni_hid_parser_switch2_is_ble_device(device)) ++ return; // Switch 2 uses its own application-level pairing and GATT setup. + + logi("Connection encrypted: %u\n", hci_event_encryption_change_get_encryption_enabled(packet)); + if (hci_event_encryption_change_get_encryption_enabled(packet) == 0) { +@@ -758,7 +794,11 @@ + appearance = 0; + name[0] = 0; + +- ARG_UNUSED(size); ++ if (packet == NULL || size < 12 || ++ (uint16_t)gap_event_advertising_report_get_data_length(packet) > size - 12u) ++ return; ++ if (uni_bt_le_switch2_handle_advertisement(packet, size)) ++ return; + + gap_event_advertising_report_get_address(packet, addr); + if (uni_hid_device_get_instance_for_address(addr)) { +diff --git a/src/components/bluepad32/controller/uni_gamepad.c b/src/components/bluepad32/controller/uni_gamepad.c +--- a/src/components/bluepad32/controller/uni_gamepad.c ++++ b/src/components/bluepad32/controller/uni_gamepad.c +@@ -35,6 +35,9 @@ + {CONTROLLER_TYPE_SwitchJoyConRight, "Switch JoyCon Right"}, + {CONTROLLER_TYPE_SwitchJoyConPair, "Switch JoyCon Pair"}, + {CONTROLLER_TYPE_SwitchInputOnlyController, "Switch Input Only"}, ++ {CONTROLLER_TYPE_Switch2ProController, "Switch 2 Pro"}, ++ {CONTROLLER_TYPE_Switch2JoyConLeft, "Joy-Con 2 Left"}, ++ {CONTROLLER_TYPE_Switch2JoyConRight, "Joy-Con 2 Right"}, + {CONTROLLER_TYPE_MobileTouch, "Mobile Touch"}, + {CONTROLLER_TYPE_XInputSwitchController, "XInput Switch"}, + {CONTROLLER_TYPE_PS5Controller, "DualSense"}, +diff --git a/src/components/bluepad32/include/controller/uni_controller_list.h b/src/components/bluepad32/include/controller/uni_controller_list.h +--- a/src/components/bluepad32/include/controller/uni_controller_list.h ++++ b/src/components/bluepad32/include/controller/uni_controller_list.h +@@ -568,6 +568,9 @@ + // * ZhiXu Gamepad Wireless + // * Sunwaytek Wireless Motion Controller for Nintendo Switch + { MAKE_CONTROLLER_ID( 0x057e, 0x2009 ), k_eControllerType_SwitchProController, NULL }, // Nintendo Switch Pro Controller ++ { MAKE_CONTROLLER_ID( 0x057e, 0x2069 ), k_eControllerType_Switch2ProController, NULL }, ++ { MAKE_CONTROLLER_ID( 0x057e, 0x2067 ), k_eControllerType_Switch2JoyConLeft, NULL }, ++ { MAKE_CONTROLLER_ID( 0x057e, 0x2066 ), k_eControllerType_Switch2JoyConRight, NULL }, + //{ MAKE_CONTROLLER_ID( 0x057e, 0x2017 ), k_eControllerType_SwitchProController, NULL }, // Nintendo Online SNES Controller + //{ MAKE_CONTROLLER_ID( 0x057e, 0x2019 ), k_eControllerType_SwitchProController, NULL }, // Nintendo Online N64 Controller + //{ MAKE_CONTROLLER_ID( 0x057e, 0x201e ), k_eControllerType_SwitchProController, NULL }, // Nintendo Online SEGA Genesis Controller +diff --git a/src/components/bluepad32/include/controller/uni_controller_type.h b/src/components/bluepad32/include/controller/uni_controller_type.h +--- a/src/components/bluepad32/include/controller/uni_controller_type.h ++++ b/src/components/bluepad32/include/controller/uni_controller_type.h +@@ -70,6 +70,9 @@ + k_eControllerType_OUYAController = 55, // (Bluepad32) + k_eControllerType_PSMoveController = 56, // (Bluepad32) + k_eControllerType_AtariJoystick = 57, // (Bluepad32) ++ k_eControllerType_Switch2ProController = 58, ++ k_eControllerType_Switch2JoyConLeft = 59, ++ k_eControllerType_Switch2JoyConRight = 60, + + k_eControllerType_LastController, // Don't add game controllers below this enumeration - this enumeration can + // change value +@@ -120,6 +123,9 @@ + #define CONTROLLER_TYPE_OUYAController k_eControllerType_OUYAController + #define CONTROLLER_TYPE_PSMoveController k_eControllerType_PSMoveController + #define CONTROLLER_TYPE_AtariJoystick k_eControllerType_AtariJoystick ++#define CONTROLLER_TYPE_Switch2ProController k_eControllerType_Switch2ProController ++#define CONTROLLER_TYPE_Switch2JoyConLeft k_eControllerType_Switch2JoyConLeft ++#define CONTROLLER_TYPE_Switch2JoyConRight k_eControllerType_Switch2JoyConRight + #define CONTROLLER_TYPE_LastController k_eControllerType_LastController + #define CONTROLLER_TYPE_GenericKeyboard k_eControllertype_GenericKeyboard + #define CONTROLLER_TYPE_GenericMouse k_eControllertype_GenericMouse diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h -index 6af4969..0aebb0a 100644 --- a/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h +++ b/src/components/bluepad32/include/parser/uni_hid_parser_psmove.h @@ -14,6 +14,8 @@ @@ -34,11 +216,47 @@ index 6af4969..0aebb0a 100644 void uni_hid_parser_psmove_set_lightbar_color(struct uni_hid_device_s* d, uint8_t r, uint8_t g, uint8_t b); void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d, uint16_t start_delay_ms, +diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h +--- a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h ++++ b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h +@@ -23,4 +23,22 @@ + bool uni_hid_parser_switch_does_name_match(struct uni_hid_device_s* d, const char* name); + void uni_hid_parser_switch_device_dump(struct uni_hid_device_s* d); + ++// BTstack-thread-only native owner. Qualification belongs to the caller: ++// type and firmware are unverified bytes from a successful device-info reply. ++bool uni_hid_parser_switch_native_info(struct uni_hid_device_s* d, ++ uint8_t* type, uint8_t* firmware_hi, ++ uint8_t* firmware_lo); ++bool uni_hid_parser_switch_native_acquire(struct uni_hid_device_s* d); ++// Retires compatibility timers and requests neutral; no audio is queued. ++void uni_hid_parser_switch_native_release(struct uni_hid_device_s* d); ++// Exactly 11 bytes on interrupt L2CAP. False means NOT submitted, never queued. ++// Caller must retry/resynchronize; true is transport acceptance, not playback. ++bool uni_hid_parser_switch_native_send(struct uni_hid_device_s* d, const uint8_t rumble[8]); ++ ++// Internal device-send/lifecycle hooks, including queued subcommand retries. ++// Returns the L2CAP status, or success when discarding an obsolete writer. ++int uni_hid_parser_switch_send_report(struct uni_hid_device_s* d, uint16_t cid, ++ const uint8_t* report, uint16_t len); ++void uni_hid_parser_switch_teardown(struct uni_hid_device_s* d); ++ + #endif // UNI_HID_PARSER_SWITCH_H +diff --git a/src/components/bluepad32/include/uni.h b/src/components/bluepad32/include/uni.h +--- a/src/components/bluepad32/include/uni.h ++++ b/src/components/bluepad32/include/uni.h +@@ -34,6 +34,7 @@ + #include "parser/uni_hid_parser_ds5.h" + #include "parser/uni_hid_parser_keyboard.h" + #include "parser/uni_hid_parser_mouse.h" ++#include "parser/uni_hid_parser_switch2.h" + #include "parser/uni_hid_parser_xboxone.h" + #include "platform/uni_platform.h" + #include "uni_circular_buffer.h" diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds4.c b/src/components/bluepad32/parser/uni_hid_parser_ds4.c -index ea063b8..7670caf 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_ds4.c +++ b/src/components/bluepad32/parser/uni_hid_parser_ds4.c -@@ -297,17 +297,17 @@ void uni_hid_parser_ds4_parse_feature_report(uni_hid_device_t* d, const uint8_t* +@@ -297,17 +297,17 @@ // Set gyroscope calibration and normalization parameters. // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; @@ -59,7 +277,7 @@ index ea063b8..7670caf 100644 ins->gyro_calib_data[2].sens_numer = speed_2x * DS4_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[2].sens_denom = abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); -@@ -476,7 +476,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor +@@ -476,7 +476,7 @@ // Gyro for (size_t i = 0; i < ARRAY_SIZE(r->gyro); i++) { @@ -68,7 +286,7 @@ index ea063b8..7670caf 100644 int32_t calib_data = mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); ctl->gamepad.gyro[i] = calib_data; -@@ -484,7 +484,7 @@ static void ds4_parse_input_report_11(uni_hid_device_t* d, const ds4_input_repor +@@ -484,7 +484,7 @@ // Accel for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { @@ -78,10 +296,9 @@ index ea063b8..7670caf 100644 mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); ctl->gamepad.accel[i] = calib_data; diff --git a/src/components/bluepad32/parser/uni_hid_parser_ds5.c b/src/components/bluepad32/parser/uni_hid_parser_ds5.c -index a22ef26..3d5ecef 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_ds5.c +++ b/src/components/bluepad32/parser/uni_hid_parser_ds5.c -@@ -487,17 +487,17 @@ void uni_hid_parser_ds5_parse_feature_report(uni_hid_device_t* d, const uint8_t* +@@ -487,17 +487,17 @@ // Set gyroscope calibration and normalization parameters. // Data values will be normalized to 1/DS_GYRO_RES_PER_DEG_S degree/s. speed_2x = r->gyro_speed_plus + r->gyro_speed_minus; @@ -102,7 +319,8 @@ index a22ef26..3d5ecef 100644 ins->gyro_calib_data[2].sens_numer = speed_2x * DS5_GYRO_RES_PER_DEG_S; ins->gyro_calib_data[2].sens_denom = abs(r->gyro_roll_plus - r->gyro_roll_bias) + abs(r->gyro_roll_minus - r->gyro_roll_bias); -@@ -618,11 +618,11 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* report, uint16_t len) { +@@ -617,12 +617,12 @@ + ctl->gamepad.buttons |= BUTTON_THUMB_R; // Thumb R if (r->buttons[2] & 0x01) ctl->gamepad.misc_buttons |= MISC_BUTTON_SYSTEM; // PS - if (r->buttons[2] & 0x04) @@ -117,7 +335,7 @@ index a22ef26..3d5ecef 100644 int32_t calib_data = mult_frac(ins->gyro_calib_data[i].sens_numer, raw_data, ins->gyro_calib_data[i].sens_denom); ctl->gamepad.gyro[i] = calib_data; -@@ -630,7 +630,7 @@ void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t* d, const uint8_t* r +@@ -630,7 +630,7 @@ // Accel for (size_t i = 0; i < ARRAY_SIZE(r->accel); i++) { @@ -127,7 +345,6 @@ index a22ef26..3d5ecef 100644 mult_frac(ins->accel_calib_data[i].sens_numer, raw_data, ins->accel_calib_data[i].sens_denom); ctl->gamepad.accel[i] = calib_data; diff --git a/src/components/bluepad32/parser/uni_hid_parser_psmove.c b/src/components/bluepad32/parser/uni_hid_parser_psmove.c -index 0265f93..5c0f2bb 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_psmove.c +++ b/src/components/bluepad32/parser/uni_hid_parser_psmove.c @@ -8,6 +8,7 @@ @@ -138,7 +355,7 @@ index 0265f93..5c0f2bb 100644 #include -@@ -27,11 +28,6 @@ typedef enum psmove_fsm { +@@ -27,11 +28,6 @@ PSMOVE_FSM_LED_UPDATED, // LED updated } psmove_fsm_t; @@ -150,7 +367,7 @@ index 0265f93..5c0f2bb 100644 typedef enum { PSMOVE_STATE_RUMBLE_DISABLED, -@@ -41,9 +37,10 @@ typedef enum { +@@ -41,9 +37,10 @@ // psmove_instance_t represents data used by the psmove driver instance. typedef struct psmove_instance_s { @@ -162,7 +379,7 @@ index 0265f93..5c0f2bb 100644 btstack_timer_source_t rumble_timer_duration; btstack_timer_source_t rumble_timer_delayed_start; -@@ -127,6 +124,7 @@ static void psmove_send_output_report(uni_hid_device_t* d, psmove_output_report_ +@@ -127,6 +124,7 @@ static void on_psmove_set_rumble_on(btstack_timer_source_t* ts); static void on_psmove_set_rumble_off(btstack_timer_source_t* ts); static void psmove_play_dual_rumble_now(uni_hid_device_t* d, uint16_t duration_ms, uint8_t magnitude); @@ -170,7 +387,7 @@ index 0265f93..5c0f2bb 100644 void uni_hid_parser_psmove_init_report(uni_hid_device_t* d) { uni_controller_t* ctl = &d->controller; -@@ -154,6 +152,7 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t +@@ -154,6 +152,7 @@ } uni_controller_t* ctl = &d->controller; @@ -178,7 +395,7 @@ index 0265f93..5c0f2bb 100644 // Buttons if (r->buttons[0] & 0x01) -@@ -187,18 +186,39 @@ void uni_hid_parser_psmove_parse_input_report(uni_hid_device_t* d, const uint8_t +@@ -187,16 +186,37 @@ ctl->gamepad.throttle = r->trigger * 4; @@ -205,8 +422,8 @@ index 0265f93..5c0f2bb 100644 if (r->battery <= 5) ctl->battery = r->battery * 51; - } - ++} ++ +void uni_hid_parser_psmove_parse_feature_report( + uni_hid_device_t* d, const uint8_t* report, uint16_t len) { + psmove_instance_t* ins = get_psmove_instance(d); @@ -220,12 +437,10 @@ index 0265f93..5c0f2bb 100644 + } else if (result == UNI_PSMOVE_CALIBRATION_INVALID) { + loge("psmove: invalid IMU calibration; motion disabled\n"); + } -+} -+ + } + void uni_hid_parser_psmove_play_dual_rumble(struct uni_hid_device_s* d, - uint16_t start_delay_ms, - uint16_t duration_ms, -@@ -261,25 +281,34 @@ void uni_hid_parser_psmove_setup(struct uni_hid_device_s* d) { +@@ -261,25 +281,34 @@ switch (d->product_id) { case ZCM1_PID: @@ -241,11 +456,14 @@ index 0265f93..5c0f2bb 100644 default: - loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", ins->model); - ins->model = PSMOVE_MODEL_ZCM1; +- break; +- } +- + loge("psmove: Unknown PSMove PID = %#x, assuming ZCM1\n", d->product_id); + ins->model = UNI_PSMOVE_IMU_MODEL_ZCM1; - break; - } - ++ break; ++ } ++ + psmove_request_calibration_report(d); uni_hid_device_set_ready_complete(d); } @@ -816,7 +1034,6 @@ diff --git a/src/components/bluepad32/parser/uni_hid_parser_switch.c b/src/compo process_fsm(d); } diff --git a/src/components/bluepad32/parser/uni_hid_parser_wii.c b/src/components/bluepad32/parser/uni_hid_parser_wii.c -index be2103e..4819639 100644 --- a/src/components/bluepad32/parser/uni_hid_parser_wii.c +++ b/src/components/bluepad32/parser/uni_hid_parser_wii.c @@ -19,6 +19,7 @@ @@ -827,7 +1044,7 @@ index be2103e..4819639 100644 #include "controller/uni_controller.h" #include "hid_usage.h" -@@ -585,9 +586,7 @@ static void process_drm_ka(uni_hid_device_t* d, const uint8_t* report, uint16_t +@@ -585,9 +586,7 @@ uni_controller_t* ctl = &d->controller; @@ -841,27 +1058,39 @@ index be2103e..4819639 100644 diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad32/uni_hid_device.c --- a/src/components/bluepad32/uni_hid_device.c +++ b/src/components/bluepad32/uni_hid_device.c -@@ -447,6 +447,9 @@ +@@ -32,6 +32,7 @@ + #include "parser/uni_hid_parser_stadia.h" + #include "parser/uni_hid_parser_steam.h" + #include "parser/uni_hid_parser_switch.h" ++#include "parser/uni_hid_parser_switch2.h" + #include "parser/uni_hid_parser_wii.h" + #include "parser/uni_hid_parser_xboxone.h" + #include "platform/uni_platform.h" +@@ -447,6 +448,11 @@ return; } + if (d->report_parser.setup == uni_hid_parser_switch_setup) + uni_hid_parser_switch_teardown(d); ++ if (uni_hid_parser_switch2_is_ble_device(d)) ++ uni_hid_parser_switch2_teardown(d); + // Disconnect child first if (d->child) uni_hid_device_disconnect(d->child); -@@ -490,6 +493,9 @@ +@@ -490,6 +496,11 @@ loge("uni_hid_device_delete: invalid hid device: NULL\n"); return; } + if (d->report_parser.setup == uni_hid_parser_switch_setup) + uni_hid_parser_switch_teardown(d); ++ if (uni_hid_parser_switch2_is_ble_device(d)) ++ uni_hid_parser_switch2_teardown(d); + // Delete child first if (d->child) -@@ -655,6 +661,7 @@ +@@ -655,6 +666,7 @@ d->report_parser.setup = uni_hid_parser_psmove_setup; d->report_parser.init_report = uni_hid_parser_psmove_init_report; d->report_parser.parse_input_report = uni_hid_parser_psmove_parse_input_report; @@ -869,7 +1098,23 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 d->report_parser.set_lightbar_color = uni_hid_parser_psmove_set_lightbar_color; d->report_parser.play_dual_rumble = uni_hid_parser_psmove_play_dual_rumble; logi("Device detected as PS Move: 0x%02x\n", type); -@@ -809,7 +816,11 @@ +@@ -718,6 +730,15 @@ + d->report_parser.device_dump = uni_hid_parser_switch_device_dump; + logi("Device detected as Nintendo Switch Pro controller: 0x%02x\n", type); + break; ++ case CONTROLLER_TYPE_Switch2ProController: ++ case CONTROLLER_TYPE_Switch2JoyConLeft: ++ case CONTROLLER_TYPE_Switch2JoyConRight: ++ d->report_parser.setup = uni_hid_parser_switch2_setup; ++ d->report_parser.init_report = uni_hid_parser_switch2_init_report; ++ d->report_parser.parse_input_report = uni_hid_parser_switch2_parse_input_report; ++ d->report_parser.set_player_leds = uni_hid_parser_switch2_set_player_leds; ++ d->report_parser.play_dual_rumble = uni_hid_parser_switch2_play_dual_rumble; ++ break; + case CONTROLLER_TYPE_SteamController: + d->report_parser.setup = uni_hid_parser_steam_setup; + d->report_parser.init_report = uni_hid_parser_steam_init_report; +@@ -809,7 +830,11 @@ return; } @@ -882,56 +1127,3 @@ diff --git a/src/components/bluepad32/uni_hid_device.c b/src/components/bluepad3 if (err != 0) { logd("Could not send report (error=0x%04x). Adding it to queue\n", err); if (uni_circular_buffer_put(&d->outgoing_buffer, cid, report, len) != 0) { -diff --git a/src/components/bluepad32/bt/uni_bt.c b/src/components/bluepad32/bt/uni_bt.c ---- a/src/components/bluepad32/bt/uni_bt.c -+++ b/src/components/bluepad32/bt/uni_bt.c -@@ -73,6 +73,12 @@ - #include "uni_log.h" - #include "uni_property.h" - -+#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT -+// The opt-in PCM sender owns its CAN_SEND_NOW event, not the generic HID FIFO. -+extern bool uni_platform_on_l2cap_can_send_now(uni_hid_device_t* device, -+ uint16_t local_cid); -+#endif -+ - // globals - bd_addr_t uni_local_bd_addr; - -@@ -473,6 +479,10 @@ void uni_bt_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t* pack - if (device == NULL) { - loge("--->>> CANNOT FIND DEVICE"); - } else { -+#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT -+ if (uni_platform_on_l2cap_can_send_now(device, local_cid)) -+ break; -+#endif - uni_hid_device_send_queued_reports(device); - } - break; -diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h ---- a/src/components/bluepad32/include/parser/uni_hid_parser_switch.h -+++ b/src/components/bluepad32/include/parser/uni_hid_parser_switch.h -@@ -23,4 +23,22 @@ - bool uni_hid_parser_switch_does_name_match(struct uni_hid_device_s* d, const char* name); - void uni_hid_parser_switch_device_dump(struct uni_hid_device_s* d); - -+// BTstack-thread-only native owner. Qualification belongs to the caller: -+// type and firmware are unverified bytes from a successful device-info reply. -+bool uni_hid_parser_switch_native_info(struct uni_hid_device_s* d, -+ uint8_t* type, uint8_t* firmware_hi, -+ uint8_t* firmware_lo); -+bool uni_hid_parser_switch_native_acquire(struct uni_hid_device_s* d); -+// Retires compatibility timers and requests neutral; no audio is queued. -+void uni_hid_parser_switch_native_release(struct uni_hid_device_s* d); -+// Exactly 11 bytes on interrupt L2CAP. False means NOT submitted, never queued. -+// Caller must retry/resynchronize; true is transport acceptance, not playback. -+bool uni_hid_parser_switch_native_send(struct uni_hid_device_s* d, const uint8_t rumble[8]); -+ -+// Internal device-send/lifecycle hooks, including queued subcommand retries. -+// Returns the L2CAP status, or success when discarding an obsolete writer. -+int uni_hid_parser_switch_send_report(struct uni_hid_device_s* d, uint16_t cid, -+ const uint8_t* report, uint16_t len); -+void uni_hid_parser_switch_teardown(struct uni_hid_device_s* d); -+ - #endif // UNI_HID_PARSER_SWITCH_H diff --git a/src/firmware/core/controller_state.h b/src/firmware/core/controller_state.h index b2b8392..3989db8 100644 --- a/src/firmware/core/controller_state.h +++ b/src/firmware/core/controller_state.h @@ -44,6 +44,9 @@ struct ControllerState { bool button_left_stick; bool button_right_stick; + // Source-only controls: C, GL, GR, left SL/SR, right SL/SR. + uint8_t extra_buttons = 0; + uint16_t left_trigger; uint16_t right_trigger; diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index b49f426..dd01bc4 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -23,6 +23,8 @@ #include #include #include +#include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_pairing.h" #ifdef SWITCH_PICO_USB_OUTPUT_MODES #include "adapter/adapter_usb_mode.h" #endif @@ -156,6 +158,13 @@ struct BackendSlot { ControllerIdentity identity; // Non-null with active=false is a connected device still becoming ready. uni_hid_device_t* device; + // A pair occupies one output/profile, but still consumes two of the four + // Bluepad32 physical device indices. The left half always owns identity. + uni_hid_device_t* companion; + uni_gamepad_t gamepad; + uni_gamepad_t companion_gamepad; + uint8_t extra_buttons; + uint8_t companion_extra_buttons; uint32_t state_generation; uint32_t connection_generation; bool active; @@ -174,6 +183,7 @@ struct BackendSlot { }; critical_section_t g_state_lock; +uni_hid_device_t* g_retired_devices[kSlotCount]{}; BackendSlot g_slots[kSlotCount]; ControllerMacroCapture g_macro_capture; // Catalog migration/compaction needs more than the 4 KiB scratch bank. @@ -239,12 +249,13 @@ bool valid_slot(uint8_t slot) { bool has_free_slot() { critical_section_enter_blocking(&g_state_lock); - bool free_slot = false; + unsigned physical_count = 0; for (const BackendSlot& slot : g_slots) { - free_slot = free_slot || slot.device == nullptr; + physical_count += slot.device != nullptr; + physical_count += slot.companion != nullptr; } critical_section_exit(&g_state_lock); - return free_slot; + return physical_count < kSlotCount; } bool has_active_controller() { @@ -257,12 +268,68 @@ bool has_active_controller() { return active_controller; } -int slot_for_device(const uni_hid_device_t* device) { +int physical_index_for_device(const uni_hid_device_t* device) { if (device == nullptr) { return -1; } - const int slot = uni_hid_device_get_idx_for_instance(device); - return slot >= 0 && slot < kSlotCount ? slot : -1; + const int index = uni_hid_device_get_idx_for_instance(device); + return index >= 0 && index < kSlotCount ? index : -1; +} + +int slot_for_device(const uni_hid_device_t* device) { + if (device != nullptr) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (g_slots[index].device == device || + g_slots[index].companion == device) { + return index; + } + } + } + return -1; +} + +// Called under the state lock. Bluepad32's index is a transport resource, not +// an output index once two Joy-Cons merge. Never evict an unrelated output. +int reserve_device_slot(uni_hid_device_t* device) { + const int physical_index = physical_index_for_device(device); + if (physical_index < 0) { + return -1; + } + const int tracked = slot_for_device(device); + if (g_retired_devices[physical_index] == device && + uni_hid_parser_switch2_is_ble_device(device)) { + return -1; + } + if (tracked >= 0) { + return tracked; + } + for (const BackendSlot& slot : g_slots) { + if ((slot.device != nullptr && + physical_index_for_device(slot.device) == physical_index) || + (slot.companion != nullptr && + physical_index_for_device(slot.companion) == physical_index)) { + return -1; + } + } + if (g_slots[physical_index].device == nullptr) { + return physical_index; + } + for (uint8_t index = 0; index < kSlotCount; ++index) { + if (g_slots[index].device == nullptr) { + return index; + } + } + return -1; +} + +int joycon_side(const uni_hid_device_t* device) { + if (!uni_hid_parser_switch2_is_ble_device(device)) { + return 0; + } + if (device->product_id == UNI_SW2_JOYCON_L_PID) { + return -1; + } + return device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : 0; } bool addresses_equal(const bd_addr_t first, const bd_addr_t second) { @@ -344,6 +411,15 @@ ControllerIdentity identity_for_device(const uni_hid_device_t* device) { if (mapping != nullptr) { return make_ble_identity(*mapping, device); } + uint8_t address_type = BD_ADDR_TYPE_UNKNOWN; + if (uni_hid_parser_switch2_identity_address_type( + device, &address_type)) { + BleIdentityMapping proprietary{}; + proprietary.identity_address_type = address_type; + memcpy(proprietary.identity_address, device->conn.btaddr, + sizeof(proprietary.identity_address)); + return make_ble_identity(proprietary, device); + } break; } case GAP_CONNECTION_INVALID: @@ -492,8 +568,10 @@ bool lighting_target_is_current( const uni_hid_device_t* device) { critical_section_enter_blocking(&g_state_lock); const bool current = - slot_index < kSlotCount && g_slots[slot_index].active && - g_slots[slot_index].device == device && + device != nullptr && slot_index < kSlotCount && + g_slots[slot_index].active && + (g_slots[slot_index].device == device || + g_slots[slot_index].companion == device) && g_slots[slot_index].connection_generation == connection_generation; critical_section_exit(&g_state_lock); @@ -506,14 +584,17 @@ ConnectionStatus compute_connection_status() { critical_section_enter_blocking(&g_state_lock); bool all_ready = true; bool any_connecting = false; + unsigned physical_count = 0; for (const BackendSlot& slot : g_slots) { const bool has_device = slot.device != nullptr; - all_ready = all_ready && slot.active && has_device; + physical_count += has_device; + physical_count += slot.companion != nullptr; + all_ready = all_ready && (!has_device || slot.active); any_connecting = any_connecting || (!slot.active && has_device); } critical_section_exit(&g_state_lock); - if (all_ready) { + if (all_ready && physical_count == kSlotCount) { return ConnectionStatus::Ready; } return any_connecting ? ConnectionStatus::Connecting @@ -544,6 +625,11 @@ void publish_all_neutral() { slot.pre_hotkey_button_mask = 0; slot.identity = controller_identity_global(); slot.device = nullptr; + slot.companion = nullptr; + slot.gamepad = {}; + slot.companion_gamepad = {}; + slot.extra_buttons = 0; + slot.companion_extra_buttons = 0; slot.active = false; slot.rumble_pending = false; slot.retained_host_rumble_valid = false; @@ -837,6 +923,81 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, return state; } +int32_t negate_motion_axis(int32_t value) { + return value == INT32_MIN ? INT32_MAX : -value; +} + +void rotate_solo_joycon(uni_gamepad_t& gamepad, int side, + uint8_t extras) { + const uint32_t buttons = gamepad.buttons; + if (side < 0) { + const int32_t x = gamepad.axis_x; + gamepad.axis_x = clamp_axis(gamepad.axis_y); + gamepad.axis_y = clamp_axis(-clamp_axis(x)); + gamepad.buttons &= ~(BUTTON_A | BUTTON_B | BUTTON_X | BUTTON_Y); + gamepad.buttons |= + ((gamepad.dpad & DPAD_LEFT) ? uint32_t{BUTTON_A} : 0u) | + ((gamepad.dpad & DPAD_DOWN) ? uint32_t{BUTTON_B} : 0u) | + ((gamepad.dpad & DPAD_UP) ? uint32_t{BUTTON_X} : 0u) | + ((gamepad.dpad & DPAD_RIGHT) ? uint32_t{BUTTON_Y} : 0u) | + ((extras & UNI_SW2_BUTTON_LEFT_SL) ? uint32_t{BUTTON_SHOULDER_L} : 0u) | + ((extras & UNI_SW2_BUTTON_LEFT_SR) ? uint32_t{BUTTON_SHOULDER_R} : 0u); + } else { + gamepad.axis_x = clamp_axis(-clamp_axis(gamepad.axis_ry)); + gamepad.axis_y = clamp_axis(gamepad.axis_rx); + gamepad.buttons &= + ~(BUTTON_A | BUTTON_B | BUTTON_X | BUTTON_Y | BUTTON_THUMB_R); + gamepad.buttons |= + ((buttons & BUTTON_B) ? uint32_t{BUTTON_A} : 0u) | + ((buttons & BUTTON_Y) ? uint32_t{BUTTON_B} : 0u) | + ((buttons & BUTTON_A) ? uint32_t{BUTTON_X} : 0u) | + ((buttons & BUTTON_X) ? uint32_t{BUTTON_Y} : 0u) | + ((buttons & BUTTON_THUMB_R) ? uint32_t{BUTTON_THUMB_L} : 0u) | + ((extras & UNI_SW2_BUTTON_RIGHT_SL) ? uint32_t{BUTTON_SHOULDER_L} : 0u) | + ((extras & UNI_SW2_BUTTON_RIGHT_SR) ? uint32_t{BUTTON_SHOULDER_R} : 0u); + } + gamepad.dpad = 0; + gamepad.axis_rx = 0; + gamepad.axis_ry = 0; + int32_t* motion_axes[] = {gamepad.accel, gamepad.gyro}; + for (int32_t* axes : motion_axes) { + const int32_t x = axes[0]; + axes[0] = side < 0 ? negate_motion_axis(axes[1]) : axes[1]; + axes[1] = side < 0 ? x : negate_motion_axis(x); + } +} +uni_gamepad_t logical_gamepad(const BackendSlot& slot) { + uni_gamepad_t gamepad = slot.gamepad; + if (slot.companion != nullptr) { + const uni_gamepad_t& right = slot.companion_gamepad; + gamepad.dpad |= right.dpad; + gamepad.buttons |= right.buttons; + gamepad.misc_buttons |= right.misc_buttons; + gamepad.axis_rx = right.axis_rx; + gamepad.axis_ry = right.axis_ry; + gamepad.throttle = right.throttle; + // The right half is the sole aim source. A left report must not + // republish an already consumed right-hand motion sample. + memcpy(gamepad.accel, right.accel, sizeof(gamepad.accel)); + memcpy(gamepad.gyro, right.gyro, sizeof(gamepad.gyro)); + } else { + const int side = joycon_side(slot.device); + if (side != 0) { + rotate_solo_joycon(gamepad, side, slot.extra_buttons); + } + } + return gamepad; +} + +void refresh_topology_input(BackendSlot& slot) { + const uni_gamepad_t gamepad = logical_gamepad(slot); + slot.pre_hotkey_button_mask = logical_button_mask(gamepad); + // Topology changes release the lost half immediately; motion stays neutral + // until a fresh report from the newly selected source arrives. + slot.state = map_gamepad(gamepad, false, slot.pre_hotkey_button_mask); + slot.state.extra_buttons = + slot.extra_buttons | slot.companion_extra_buttons; +} struct HotkeyDecision { bool motion_enabled; }; @@ -868,6 +1029,27 @@ void reset_slot_hotkeys(BackendSlot& slot) { slot.retained_host_rumble = {}; } +void invalidate_slot(BackendSlot& slot) { + reset_slot_hotkeys(slot); + slot.rumble_pending = false; + slot.pending_rumble = {}; + slot.state = make_neutral_state(); + ++slot.state_generation; + ++slot.connection_generation; +} + +void release_slot(BackendSlot& slot) { + invalidate_slot(slot); + slot.identity = controller_identity_global(); + slot.device = nullptr; + slot.companion = nullptr; + slot.gamepad = {}; + slot.companion_gamepad = {}; + slot.extra_buttons = 0; + slot.companion_extra_buttons = 0; + slot.active = false; +} + HotkeyDecision update_controller_hotkeys( uint8_t slot_index, uni_hid_device_t* device) { @@ -1010,6 +1192,9 @@ bool update_pairing_window(uint32_t now_ms) { const bool requested = g_pairing_window_requested; g_pairing_window_requested = false; critical_section_exit(&g_state_lock); + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { + return false; + } if (requested) { ConfigurationServiceSnapshot configuration{}; @@ -1079,8 +1264,19 @@ void refresh_pairing_snapshot() { snapshot, Bluepad32PairingTransport::kBle, static_cast(address_type), address); } + for (uint8_t index = 0; index < UNI_SWITCH2_PAIRING_CAPACITY; ++index) { + uint8_t address_type = BD_ADDR_TYPE_UNKNOWN; + bd_addr_t address{}; + if (uni_switch2_pairing_get(index, &address_type, address)) { + append_pairing_record( + snapshot, Bluepad32PairingTransport::kBle, address_type, address); + } + } critical_section_enter_blocking(&g_state_lock); + if (g_pairing_snapshot.status == Bluepad32PairingSnapshotStatus::kFailed) { + snapshot.status = Bluepad32PairingSnapshotStatus::kFailed; + } snapshot.generation = g_pairing_snapshot.generation + 1; snapshot.completed_clear_pairings_token = g_pairing_snapshot.completed_clear_pairings_token; @@ -1103,6 +1299,7 @@ void recompute_connection_status(); void process_clear_pairings(uint32_t now_ms) { uni_hid_device_t* devices[kSlotCount]{}; + uint8_t device_count = 0; critical_section_enter_blocking(&g_state_lock); const uint32_t request_token = g_clear_pairings_requested_token; @@ -1114,17 +1311,17 @@ void process_clear_pairings(uint32_t now_ms) { g_pairing_window_requested = false; for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; - devices[slot_index] = slot.device; - slot.state = make_neutral_state(); - slot.identity = controller_identity_global(); - slot.device = nullptr; - slot.active = false; - slot.rumble_pending = false; - slot.feedback_pending = false; - slot.feedback_until_ms = 0; - reset_slot_hotkeys(slot); - ++slot.state_generation; - ++slot.connection_generation; + if (slot.device != nullptr) { + g_retired_devices[physical_index_for_device(slot.device)] = + slot.device; + devices[device_count++] = slot.device; + } + if (slot.companion != nullptr) { + g_retired_devices[physical_index_for_device(slot.companion)] = + slot.companion; + devices[device_count++] = slot.companion; + } + release_slot(slot); } } critical_section_exit(&g_state_lock); @@ -1138,18 +1335,40 @@ void process_clear_pairings(uint32_t now_ms) { g_pairing_window_open = false; gap_set_bondable_mode(false); sm_set_accepted_stk_generation_methods(0); + const bool proprietary_cleared = uni_switch2_pairing_clear(); uni_bt_del_keys_unsafe(); for (uni_hid_device_t* device : devices) { if (device != nullptr) { +#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE + switch_native_output_detach(device); +#endif +#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT + haptics_experiment_detach(device); +#endif uni_hid_device_disconnect(device); } } refresh_pairing_snapshot(); + if (!proprietary_cleared) { + uni_bt_stop_scanning_unsafe(); + uni_bt_allow_incoming_connections(false); + g_connection_policy_state = ConnectionPolicyState::FailedClosed; + critical_section_enter_blocking(&g_state_lock); + g_pairing_snapshot.status = Bluepad32PairingSnapshotStatus::kFailed; + g_clear_pairings_in_progress_token = 0; + g_clear_pairings_requested_token = 0; + g_pairing_window_requested = false; + critical_section_exit(&g_state_lock); + return; + } g_connection_status = ConnectionStatus::Scanning; g_status_led_tick = 0; g_pairing_reset_feedback_deadline_ms = now_ms + kPairingResetFeedbackDurationMs; + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { + g_connection_policy_state = ConnectionPolicyState::Uninitialized; + } apply_connection_policy(); critical_section_enter_blocking(&g_state_lock); g_pairing_snapshot.completed_clear_pairings_token = @@ -1160,6 +1379,9 @@ void process_clear_pairings(uint32_t now_ms) { void apply_connection_policy() { + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { + return; + } const bool free_slot = has_free_slot(); const bool active_controller = has_active_controller(); const bool pairing_open = @@ -1418,6 +1640,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) { uni_hid_device_t* device = nullptr; uni_hid_device_t* profile_lighting_device = nullptr; uint32_t profile_lighting_generation = 0; + uni_hid_device_t* companion = nullptr; + uint32_t dispatch_generation = 0; bool profile_lighting_dispatch = false; bool profile_lighting_restore = false; bool profile_rumble_dispatch = false; @@ -1571,6 +1795,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) { device = slot.device; } } + companion = slot.companion; + dispatch_generation = slot.connection_generation; critical_section_exit(&g_state_lock); #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE if (host_dispatch && switch_native_output_owns(device)) @@ -1585,39 +1811,45 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } #endif - if (profile_lighting_restore && - lighting_target_is_current( - slot_index, profile_lighting_generation, - profile_lighting_device)) { - apply_slot_lighting(slot_index, - profile_lighting_device); + uni_hid_device_t* lighting_targets[] = { + profile_lighting_device, companion}; + for (uni_hid_device_t* target : lighting_targets) { + if (!lighting_target_is_current( + slot_index, profile_lighting_generation, target)) { + continue; + } + if (profile_lighting_restore) { + apply_slot_lighting(slot_index, target); + } + if (profile_lighting_dispatch) { + apply_profile_lighting( + profile_feedback.active_profile_number, target); + } } - if (profile_lighting_dispatch && - lighting_target_is_current( - slot_index, profile_lighting_generation, - profile_lighting_device)) { - apply_profile_lighting( - profile_feedback.active_profile_number, - profile_lighting_device); - } - if (profile_rumble_dispatch && device != nullptr && - device->report_parser.play_dual_rumble != nullptr) { - __atomic_add_fetch( - &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - dispatch_rumble( - device, kProfileFeedbackPhaseDurationMs, - kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); - } else if (feedback_dispatch) { - __atomic_add_fetch( - &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - dispatch_rumble( - device, feedback.duration_ms, - feedback.weak_magnitude, feedback.strong_magnitude); - } else if (host_dispatch && - device->report_parser.play_dual_rumble != nullptr) { - __atomic_add_fetch( - &g_rumble_dispatches, 1, __ATOMIC_RELAXED); - dispatch_host_rumble(device, envelope.duration_ms, envelope.rumble); + uni_hid_device_t* rumble_targets[] = {device, companion}; + for (uni_hid_device_t* target : rumble_targets) { + if (!lighting_target_is_current( + slot_index, dispatch_generation, target) || + target->report_parser.play_dual_rumble == nullptr) { + continue; + } + if (profile_rumble_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); + dispatch_rumble( + target, kProfileFeedbackPhaseDurationMs, + kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); + } else if (feedback_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); + dispatch_rumble( + target, feedback.duration_ms, + feedback.weak_magnitude, feedback.strong_magnitude); + } else if (host_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); + dispatch_host_rumble(target, envelope.duration_ms, envelope.rumble); + } } } @@ -1686,117 +1918,165 @@ void platform_on_device_connected(uni_hid_device_t* device) { uni_hid_device_disconnect(device); return; } - - const int slot_index = slot_for_device(device); - if (slot_index < 0) { - return; - } - const ControllerIdentity connection_identity = - identity_for_device(device); - - bool tracked_connection = false; + const ControllerIdentity connection_identity = identity_for_device(device); critical_section_enter_blocking(&g_state_lock); - BackendSlot& slot = g_slots[slot_index]; - if (!slot.active && slot.device == nullptr) { - slot.device = device; - slot.rumble_pending = false; - reset_slot_hotkeys(slot); - tracked_connection = true; - } else { - tracked_connection = slot.device == device; + const int physical_index = physical_index_for_device(device); + if (physical_index >= 0) { + g_retired_devices[physical_index] = nullptr; } - if (tracked_connection) { - slot.identity = connection_identity; + const int slot_index = reserve_device_slot(device); + if (slot_index >= 0) { + BackendSlot& slot = g_slots[slot_index]; + if (slot.device == nullptr) { + slot.device = device; + slot.identity = connection_identity; + slot.rumble_pending = false; + reset_slot_hotkeys(slot); + } } critical_section_exit(&g_state_lock); - - if (tracked_connection) { + if (slot_index >= 0) { recompute_connection_status(); + } else { + uni_hid_device_disconnect(device); } } void platform_on_device_disconnected(uni_hid_device_t* device) { + const int slot_index = slot_for_device(device); + if (slot_index < 0) { + return; + } #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE switch_native_output_detach(device); #endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT haptics_experiment_detach(device); #endif - const int slot_index = slot_for_device(device); - if (slot_index < 0) { - return; - } - - bool disconnected_tracked_device = false; + uni_hid_device_t* survivor = nullptr; + ControllerIdentity survivor_identity{}; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; - if (slot.device == device) { - slot.state = make_neutral_state(); - slot.identity = controller_identity_global(); - slot.device = nullptr; - slot.active = false; - slot.rumble_pending = false; - reset_slot_hotkeys(slot); - ++slot.state_generation; - ++slot.connection_generation; - disconnected_tracked_device = true; + g_retired_devices[physical_index_for_device(device)] = device; + if (slot.companion != nullptr) { + invalidate_slot(slot); + if (slot.device == device) { + slot.device = slot.companion; + slot.gamepad = slot.companion_gamepad; + slot.extra_buttons = slot.companion_extra_buttons; + } + slot.companion = nullptr; + slot.companion_gamepad = {}; + slot.companion_extra_buttons = 0; + survivor = slot.device; + slot.identity = identity_for_device(survivor); + survivor_identity = slot.identity; + refresh_topology_input(slot); + } else { + release_slot(slot); } critical_section_exit(&g_state_lock); clear_ble_identity_for_device(device); - - if (disconnected_tracked_device) { - // Re-evaluate from scratch: resume discovery only after the final - // active controller disconnects; otherwise keep passive incoming - // reconnect support without inquiry-induced latency. - g_connection_policy_state = ConnectionPolicyState::Uninitialized; - recompute_connection_status(); + if (survivor != nullptr) { + if (survivor->report_parser.play_dual_rumble != nullptr) { + dispatch_rumble(survivor, 0, 0, 0); + } + apply_slot_lighting(static_cast(slot_index), survivor); + if (survivor_identity.stable) { + profile_service_observe_identity_on_storage_core(survivor_identity); + } } + // Losing a half frees transport capacity, not another logical player. + g_connection_policy_state = ConnectionPolicyState::Uninitialized; + recompute_connection_status(); } uni_error_t platform_on_device_ready(uni_hid_device_t* device) { if (device == nullptr || !uni_hid_device_is_gamepad(device)) { return UNI_ERROR_INVALID_CONTROLLER; } - - const int slot_index = slot_for_device(device); - if (slot_index < 0) { + if (g_connection_policy_state == ConnectionPolicyState::FailedClosed) { return UNI_ERROR_NO_SLOTS; } - const ControllerIdentity connection_identity = - identity_for_device(device); - bool occupied_mismatch = false; bool became_active = false; + bool paired = false; uint32_t lighting_generation = 0; + uni_hid_device_t* owner = device; + uni_hid_device_t* companion = nullptr; + ControllerIdentity connection_identity = identity_for_device(device); critical_section_enter_blocking(&g_state_lock); - BackendSlot& slot = g_slots[slot_index]; - occupied_mismatch = slot.device != nullptr && slot.device != device; - if (!occupied_mismatch) { - slot.identity = connection_identity; - slot.device = device; - if (!slot.active) { - slot.state = make_neutral_state(); - slot.active = true; - slot.rumble_pending = false; - reset_slot_hotkeys(slot); - ++slot.state_generation; - became_active = true; - lighting_generation = slot.connection_generation; - } - } - critical_section_exit(&g_state_lock); - - if (occupied_mismatch) { + int slot_index = reserve_device_slot(device); + if (slot_index < 0) { + critical_section_exit(&g_state_lock); return UNI_ERROR_NO_SLOTS; } + BackendSlot& pending = g_slots[slot_index]; + if (!pending.active) { + const int side = joycon_side(device); + int partner_index = -1; + if (side != 0) { + for (uint8_t index = 0; index < kSlotCount; ++index) { + const BackendSlot& candidate = g_slots[index]; + if (index != slot_index && candidate.active && + candidate.companion == nullptr && + joycon_side(candidate.device) == -side) { + partner_index = index; + break; + } + } + } + if (partner_index >= 0) { + BackendSlot& partner = g_slots[partner_index]; + invalidate_slot(partner); + if (side < 0) { + partner.companion = partner.device; + partner.companion_gamepad = partner.gamepad; + partner.companion_extra_buttons = partner.extra_buttons; + partner.device = device; + partner.gamepad = {}; + partner.extra_buttons = 0; + } else { + partner.companion = device; + partner.companion_gamepad = {}; + partner.companion_extra_buttons = 0; + } + // Preserve the existing player's output index regardless of + // physical connection order, with the left identity as profile owner. + partner.identity = identity_for_device(partner.device); + refresh_topology_input(partner); + release_slot(pending); + slot_index = partner_index; + paired = true; + } else { + pending.identity = connection_identity; + pending.device = device; + pending.state = make_neutral_state(); + pending.active = true; + pending.rumble_pending = false; + reset_slot_hotkeys(pending); + ++pending.state_generation; + } + became_active = true; + } + const BackendSlot& current = g_slots[slot_index]; + owner = current.device; + companion = current.companion; + lighting_generation = current.connection_generation; + connection_identity = current.identity; + critical_section_exit(&g_state_lock); if (became_active) { #ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE - switch_native_output_attach(static_cast(slot_index), - lighting_generation, device, connection_identity); + if (!paired) { + switch_native_output_attach(static_cast(slot_index), + lighting_generation, device, connection_identity); + } #endif #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT - haptics_experiment_attach( - static_cast(slot_index), lighting_generation, device); + if (!paired && !uni_hid_parser_switch2_is_ble_device(device)) { + haptics_experiment_attach( + static_cast(slot_index), lighting_generation, device); + } #ifdef SWITCH_PICO_HD_RUMBLE if (connection_identity.vendor_id == 0x054c && (connection_identity.product_id == 0x0ce6 || @@ -1805,11 +2085,20 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { } #endif #endif + if (paired) { + uni_hid_device_t* halves[] = {owner, companion}; + for (uni_hid_device_t* half : halves) { + if (half->report_parser.play_dual_rumble != nullptr) { + dispatch_rumble(half, 0, 0, 0); + } + } + } if (lighting_target_is_current( - static_cast(slot_index), - lighting_generation, device)) { - apply_slot_lighting( - static_cast(slot_index), device); + static_cast(slot_index), lighting_generation, owner)) { + apply_slot_lighting(static_cast(slot_index), owner); + if (companion != nullptr) { + apply_slot_lighting(static_cast(slot_index), companion); + } } if (connection_identity.stable) { profile_service_observe_identity_on_storage_core( @@ -1831,22 +2120,39 @@ void platform_on_controller_data(uni_hid_device_t* device, } __atomic_add_fetch(&g_controller_reports, 1, __ATOMIC_RELAXED); - uni_gamepad_t gamepad = controller->gamepad; - const uint16_t pre_hotkey_button_mask = - logical_button_mask(gamepad); + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (!slot.active) { + critical_section_exit(&g_state_lock); + return; + } + const uint8_t extras = uni_hid_parser_switch2_extra_buttons(device); + if (slot.companion == device) { + slot.companion_gamepad = controller->gamepad; + slot.companion_extra_buttons = extras; + } else { + slot.gamepad = controller->gamepad; + slot.extra_buttons = extras; + } + const uni_gamepad_t gamepad = logical_gamepad(slot); + uni_hid_device_t* owner = slot.device; + const bool fresh_motion = + slot.companion == nullptr || slot.companion == device; + const uint8_t merged_extras = + slot.extra_buttons | slot.companion_extra_buttons; + critical_section_exit(&g_state_lock); + const uint16_t pre_hotkey_button_mask = logical_button_mask(gamepad); if (wake_chord_rising_edge( - static_cast(slot_index), device, - pre_hotkey_button_mask)) { + static_cast(slot_index), owner, pre_hotkey_button_mask)) { switch2_wake_request(); } const HotkeyDecision hotkeys = update_controller_hotkeys( - static_cast(slot_index), device); - const uint16_t output_button_mask = pre_hotkey_button_mask; + static_cast(slot_index), owner); + ControllerState state = map_gamepad( + gamepad, hotkeys.motion_enabled && fresh_motion, pre_hotkey_button_mask); + state.extra_buttons = merged_extras; publish_device_state( - static_cast(slot_index), device, - pre_hotkey_button_mask, - map_gamepad( - gamepad, hotkeys.motion_enabled, output_button_mask)); + static_cast(slot_index), owner, pre_hotkey_button_mask, state); } const uni_property_t* platform_get_property(uni_property_idx_t index) { @@ -1914,6 +2220,24 @@ uni_platform* get_platform() { } // namespace +extern "C" bool switch_pico_switch2_pairing_allowed(void) { + return g_initialized && + pairing_window_active_at(btstack_run_loop_get_time_ms()); +} + +extern "C" void __real_sm_request_pairing(hci_con_handle_t handle); +extern "C" void __wrap_sm_request_pairing(hci_con_handle_t handle) { + uni_hid_device_t* device = + uni_hid_device_get_instance_for_connection_handle(handle); + if (uni_hid_parser_switch2_is_ble_device(device)) { + // GATT's implicit authentication retry must not enter standard SMP for + // this proprietary protocol. Retain storage until HCI teardown. + uni_hid_device_disconnect(device); + return; + } + __real_sm_request_pairing(handle); +} + #if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE) extern "C" bool uni_platform_on_l2cap_can_send_now( uni_hid_device_t* device, uint16_t cid) { @@ -2088,8 +2412,9 @@ void bluepad32_input_backend_request_pairing_snapshot() { critical_section_enter_blocking(&g_state_lock); g_pairing_snapshot_requested = true; - g_pairing_snapshot.status = - Bluepad32PairingSnapshotStatus::kPending; + if (g_pairing_snapshot.status != Bluepad32PairingSnapshotStatus::kFailed) { + g_pairing_snapshot.status = Bluepad32PairingSnapshotStatus::kPending; + } critical_section_exit(&g_state_lock); } diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index dc37634..1f349b6 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -20,6 +20,7 @@ enum class Bluepad32PairingTransport : uint8_t { enum class Bluepad32PairingSnapshotStatus : uint8_t { kReady = 0, kPending = 1, + kFailed = 2, }; struct Bluepad32PairingRecord { @@ -95,6 +96,9 @@ struct Bluepad32BackendDiagnostics { void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); void bluepad32_input_backend_open_pairing_window(); +// Core 1 parser admission gate for fresh proprietary Switch 2 pairing; this +// never opens a pairing window or changes the bounded connection policy. +extern "C" bool switch_pico_switch2_pairing_allowed(void); // Repeated calls coalesce until Core 1 completes the operation and return the // same nonzero token. uint32_t bluepad32_input_backend_clear_pairings(); diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index df14959..150edfc 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -53,10 +53,15 @@ bool valid_button(uint8_t button) { } bool valid_control_output(uint8_t output) { - return output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT || + return output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL || output == CONTROLLER_PROFILE_NO_BUTTON; } +bool valid_source_control(uint8_t control) { + return control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT || + control == CONTROLLER_PROFILE_NO_BUTTON; +} + bool valid_turbo_settings(const ControllerProfileTurboSettings& settings) { return settings.rate_hz >= CONTROLLER_PROFILE_TURBO_RATE_MIN && settings.rate_hz <= CONTROLLER_PROFILE_TURBO_RATE_MAX && @@ -285,8 +290,13 @@ bool controller_profile_validate(const ControllerProfile& profile) { return false; } } - if (!valid_control_output(profile.shortcuts.modifier) || - !valid_control_output(profile.shift.modifier) || + for (uint8_t output : profile.extra_button_map) { + if (!valid_control_output(output)) { + return false; + } + } + if (!valid_source_control(profile.shortcuts.modifier) || + !valid_source_control(profile.shift.modifier) || static_cast(profile.shift.mode) > static_cast(ControllerProfileShiftMode::kToggle) || (profile.shift.mode != ControllerProfileShiftMode::kOff && @@ -312,6 +322,11 @@ bool controller_profile_validate(const ControllerProfile& profile) { return false; } } + for (uint8_t output : profile.shift.extra_button_map) { + if (!valid_button(output)) { + return false; + } + } for (uint8_t button = 0; button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++button) { if ((profile.turbo_override_mask & (1u << button)) != 0 && @@ -359,7 +374,7 @@ bool controller_profile_validate(const ControllerProfile& profile) { const ControllerProfileMacro& macro = profile.macros[macro_index]; if ((macro.trigger_mask & ~kLogicalControlMask) != 0 || - !valid_control_output(macro.cancel_control) || + !valid_source_control(macro.cancel_control) || static_cast(macro.mode) > static_cast(ControllerProfileMacroMode::kRepeat) || macro.repeat_count == 0 || @@ -503,6 +518,8 @@ bool controller_profile_encode(const ControllerProfile& profile, static_cast(stream_offset - macro_start); output[336 + macro_index * 2] = static_cast(macro.mode); output[337 + macro_index * 2] = macro.repeat_count; + output[358 + macro_index] = static_cast( + macro.trigger_mask >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); } output[256] = profile.shortcuts.modifier; memcpy(&output[257], profile.shortcuts.selectors, @@ -526,6 +543,14 @@ bool controller_profile_encode(const ControllerProfile& profile, output[settings_offset++] = settings.burst_count; } } + memcpy(&output[344], profile.extra_button_map, + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + memcpy(&output[351], profile.shift.extra_button_map, + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + output[362] = static_cast( + profile.switching_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); + output[363] = static_cast( + profile.motion_toggle_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL); return stream_offset <= CONTROLLER_PROFILE_MACRO_STREAM_SIZE; } @@ -538,7 +563,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } const uint16_t schema_version = profile_read_u16(&input[0]); const size_t expected_size = - schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION + schema_version >= CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION ? CONTROLLER_PROFILE_ENCODED_SIZE : CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE; if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION || @@ -555,6 +580,10 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, schema_version >= CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION; const bool sparse_macros = schema_version >= CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION; + const bool has_expanded_settings = + schema_version >= CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION; + const bool has_extra_controls = + schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION; if ((has_control_mapping ? input[61] != 0 || input[71] != 0 : !profile_bytes_are_zero(&input[60], 2) || @@ -649,7 +678,9 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, (static_cast(descriptor[2] & 0x03u) << 16); const uint8_t cancel = static_cast((descriptor[2] >> 2) & 0x1fu); - if (cancel > CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + if (cancel >= (has_extra_controls + ? CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT + : CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) && cancel != 0x1fu) { return false; } @@ -657,7 +688,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, cancel == 0x1fu ? CONTROLLER_PROFILE_NO_BUTTON : cancel; macro.first_step = decoded_step_count; macro.step_count = descriptor[4]; - if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + if (has_expanded_settings) { macro.mode = static_cast( input[336 + macro_index * 2]); macro.repeat_count = input[337 + macro_index * 2]; @@ -762,7 +793,7 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, static_cast(input[82 + index]); } } - if (schema_version >= CONTROLLER_PROFILE_SCHEMA_VERSION) { + if (has_expanded_settings) { profile.shortcuts.modifier = input[256]; memcpy(profile.shortcuts.selectors, &input[257], CONTROLLER_PROFILE_COUNT); @@ -783,8 +814,13 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } } if (!profile_bytes_are_zero( - &input[settings_offset], 336 - settings_offset) || - !profile_bytes_are_zero(&input[344], 40)) { + &input[settings_offset], 336 - settings_offset)) { + return false; + } + if (!has_extra_controls && + (!profile_bytes_are_zero(&input[344], 40) || + !valid_control_output(profile.shortcuts.modifier) || + !valid_control_output(profile.shift.modifier))) { return false; } } else { @@ -795,6 +831,35 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, } } } + if (has_extra_controls) { + memcpy(profile.extra_button_map, &input[344], + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + memcpy(profile.shift.extra_button_map, &input[351], + CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT); + for (size_t index = 358; index < 364; ++index) { + if ((input[index] & 0x80u) != 0) { + return false; + } + } + if (!profile_bytes_are_zero(&input[364], 20)) { + return false; + } + for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].trigger_mask |= + static_cast(input[358 + index]) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + } + profile.switching_chord |= static_cast(input[362]) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + profile.motion_toggle_chord |= static_cast(input[363]) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + } else { + for (const ControllerProfileMacro& macro : profile.macros) { + if (!valid_control_output(macro.cancel_control)) { + return false; + } + } + } if (!controller_profile_validate(profile)) { return false; } diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index c745842..6e65f17 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -10,12 +10,15 @@ constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2; constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; constexpr uint16_t CONTROLLER_PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4; constexpr uint16_t CONTROLLER_PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5; -constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 6; +constexpr uint16_t CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION = 6; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 7; constexpr size_t CONTROLLER_PROFILE_LEGACY_ENCODED_SIZE = 256; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 384; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 8; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; -constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; +constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 25; +constexpr uint8_t CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT = 7; +constexpr uint8_t CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL = 18; constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17; constexpr uint8_t CONTROLLER_PROFILE_MACRO_COUNT = 4; @@ -123,6 +126,8 @@ struct ControllerProfileShiftConfiguration { uint8_t modifier = CONTROLLER_PROFILE_NO_BUTTON; uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + uint8_t extra_button_map[CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT]{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; }; struct ControllerProfileTurboSettings { @@ -184,6 +189,8 @@ struct ControllerProfileMacro { struct ControllerProfile { uint8_t button_map[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; + uint8_t extra_button_map[CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT]{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; ControllerProfileStickConfiguration sticks[2]{}; ControllerProfileTriggerConfiguration triggers[2]{}; uint8_t weak_rumble_scale = UINT8_MAX; diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index 942880e..839d978 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -187,7 +187,7 @@ void route_trigger_output( } return; } - if (output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + if (output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { const uint8_t trigger_index = static_cast( output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); if (value > output_triggers[trigger_index]) { @@ -255,6 +255,10 @@ uint16_t controller_profile_extract_button_mask(const ControllerState& state) { uint32_t controller_profile_extract_control_mask( const ControllerState& state, const ControllerProfile& profile) { uint32_t mask = controller_profile_extract_button_mask(state); + mask |= static_cast( + state.extra_buttons & + ((1u << CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT) - 1u)) + << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; const uint16_t trigger_values[2] = { transform_trigger(state.left_trigger, profile.triggers[0]), transform_trigger(state.right_trigger, profile.triggers[1]), @@ -288,6 +292,8 @@ void controller_profile_remove_control_mask( (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL)) != 0) { state->right_trigger = 0; } + state->extra_buttons &= static_cast( + ~(control_mask >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL)); } void controller_profile_apply_button_mask(uint16_t button_mask, @@ -354,11 +360,14 @@ uint16_t controller_profile_map_button_mask( ControllerProfileTransformResult controller_profile_transform( const ControllerState& input, const ControllerProfile& profile, - const uint8_t* button_map) { + const uint8_t* button_map, const uint8_t* extra_button_map) { const uint8_t* selected_map = button_map == nullptr ? profile.button_map : button_map; + const uint8_t* selected_extra_map = + extra_button_map == nullptr ? profile.extra_button_map : extra_button_map; ControllerProfileTransformResult result{}; result.state = input; + result.state.extra_buttons = 0; const uint16_t input_button_mask = controller_profile_extract_button_mask(input); uint16_t output_button_mask = @@ -379,12 +388,25 @@ ControllerProfileTransformResult controller_profile_transform( } const uint8_t output = selected_map[input_button]; if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && - output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { output_triggers[ output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = UINT16_MAX; } } + for (uint8_t extra = 0; + extra < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++extra) { + if ((input.extra_buttons & (1u << extra)) == 0) { + continue; + } + const uint8_t output = selected_extra_map[extra]; + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + output_button_mask |= static_cast(1u << output); + } else if (output < CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL) { + output_triggers[ + output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = UINT16_MAX; + } + } const uint16_t transformed_triggers[2] = { transform_trigger(input.left_trigger, profile.triggers[0]), diff --git a/src/firmware/profile/controller_profile_transform.h b/src/firmware/profile/controller_profile_transform.h index 3cea832..3a9909f 100644 --- a/src/firmware/profile/controller_profile_transform.h +++ b/src/firmware/profile/controller_profile_transform.h @@ -27,7 +27,8 @@ uint16_t controller_profile_map_button_mask( ControllerProfileTransformResult controller_profile_transform( const ControllerState& input, const ControllerProfile& profile, - const uint8_t* button_map = nullptr); + const uint8_t* button_map = nullptr, + const uint8_t* extra_button_map = nullptr); uint8_t controller_profile_scale_rumble_magnitude(uint8_t magnitude, uint8_t scale); diff --git a/src/firmware/profile/controller_synthetic_input.cpp b/src/firmware/profile/controller_synthetic_input.cpp index f95ecf5..4e7bef1 100644 --- a/src/firmware/profile/controller_synthetic_input.cpp +++ b/src/firmware/profile/controller_synthetic_input.cpp @@ -332,7 +332,8 @@ ControllerProfileTransformResult controller_synthetic_input_apply( ControllerProfileTransformResult result = controller_profile_transform( gated_input, profile, - context->shift_active ? profile.shift.button_map : nullptr); + context->shift_active ? profile.shift.button_map : nullptr, + context->shift_active ? profile.shift.extra_button_map : nullptr); if (context->macro_active && context->macro_index < CONTROLLER_PROFILE_MACRO_COUNT) { const ControllerProfileMacro& macro = diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 3981b70..681dc75 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -465,7 +465,9 @@ size_t encode_pairing_snapshot(const Bluepad32PairingSnapshot& snapshot, Operation::kPairingRead, snapshot.status == Bluepad32PairingSnapshotStatus::kReady ? Status::kOk - : Status::kPending, + : snapshot.status == Bluepad32PairingSnapshotStatus::kFailed + ? Status::kStorageError + : Status::kPending, snapshot.overflow ? 1 : 0, 0, snapshot.generation, payload, offset, output, output_size); } @@ -511,7 +513,8 @@ size_t encode_profile_playtest( snapshot.identity, &payload[12], CONTROLLER_IDENTITY_ENCODED_SIZE) || snapshot.state.motion_sample_count > - CONTROLLER_MOTION_SAMPLE_CAPACITY) { + CONTROLLER_MOTION_SAMPLE_CAPACITY || + (snapshot.state.extra_buttons & 0x80u) != 0) { return 0; } payload[0] = 1; @@ -532,6 +535,7 @@ size_t encode_profile_playtest( payload[38] = snapshot.state.motion_sample_count; payload[39] = snapshot.battery; payload[40] = snapshot.capabilities; + payload[54] = snapshot.state.extra_buttons; if (snapshot.state.motion_sample_count != 0) { payload[0] |= 2; const ControllerMotionSample& motion = diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 5203d47..2e63396 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -23,8 +23,8 @@ constexpr size_t kProfileListRowSize = 16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1; constexpr size_t kProfileListPayloadSize = 1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize; -constexpr uint16_t kProfilePlaytestSchemaVersion = 2; -constexpr size_t kProfilePlaytestPayloadSize = 54; +constexpr uint16_t kProfilePlaytestSchemaVersion = 3; +constexpr size_t kProfilePlaytestPayloadSize = 55; constexpr size_t kProfileMetadataPayloadSize = (CONTROLLER_PROFILE_COUNT + 1) * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index d75d54f..51c0e52 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -137,7 +137,8 @@ PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2 PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 PROFILE_ACTION_CONTROL_SCHEMA_VERSION = 4 PROFILE_SPARSE_MACRO_SCHEMA_VERSION = 5 -PROFILE_SCHEMA_VERSION = 6 +PROFILE_EXPANDED_SCHEMA_VERSION = 6 +PROFILE_SCHEMA_VERSION = 7 PROFILE_LEGACY_SIZE = 256 PROFILE_SIZE = 384 PROFILE_CAPACITY = 8 @@ -163,8 +164,10 @@ PROFILE_TURBO_BURST_MIN = 1 PROFILE_TURBO_BURST_MAX = 255 PROFILE_MACRO_REPEAT_MIN = 1 PROFILE_MACRO_REPEAT_MAX = 255 -PROFILE_PLAYTEST_SCHEMA_VERSION = 2 -PROFILE_PLAYTEST_SIZE = 54 +PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION = 2 +PROFILE_PLAYTEST_LEGACY_SIZE = 54 +PROFILE_PLAYTEST_SCHEMA_VERSION = 3 +PROFILE_PLAYTEST_SIZE = 55 PROFILE_PLAYTEST_SLOT_COUNT = 4 PROFILE_METADATA_SCHEMA_VERSION = 1 PROFILE_METADATA_MAX_BYTES = 31 @@ -276,7 +279,9 @@ LOGICAL_BUTTONS = ( "dpad_left", "dpad_right", ) -LOGICAL_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +OUTPUT_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +EXTRA_BUTTONS = ("c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr") +LOGICAL_CONTROLS = OUTPUT_CONTROLS + EXTRA_BUTTONS PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") TURBO_MODES = ("off", "turbo", "auto_burst", "burst") @@ -684,13 +689,16 @@ def _button_name(value: int) -> str | None: return LOGICAL_BUTTONS[value] -def _control_index(value: Any, name: str) -> int: +def _control_index( + value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION +) -> int: if value is None: return PROFILE_NONE_BUTTON - if type(value) is not str or value not in LOGICAL_CONTROLS: - choices = ", ".join(LOGICAL_CONTROLS) + controls = LOGICAL_CONTROLS if schema_version >= PROFILE_SCHEMA_VERSION else OUTPUT_CONTROLS + if type(value) is not str or value not in controls: + choices = ", ".join(controls) raise ConfigManagerError(f"{name} must be null or one of: {choices}") - return LOGICAL_CONTROLS.index(value) + return controls.index(value) def _control_name(value: int) -> str | None: @@ -714,12 +722,14 @@ def _button_mask_from_json(value: Any, name: str) -> int: return mask -def _control_mask_from_json(value: Any, name: str) -> int: +def _control_mask_from_json( + value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION +) -> int: if type(value) is not list: raise ConfigManagerError(f"{name} must be a JSON array") mask = 0 for entry in value: - index = _control_index(entry, name) + index = _control_index(entry, name, schema_version=schema_version) if index == PROFILE_NONE_BUTTON: raise ConfigManagerError(f"{name} cannot contain null") bit = 1 << index @@ -887,6 +897,7 @@ class ProfilePlaytest: battery: int capabilities: int motion: tuple[int, int, int, int, int, int] | None + extra_buttons: int = 0 def to_json_object(self) -> dict[str, Any]: return { @@ -905,6 +916,10 @@ class ProfilePlaytest: else None ), "buttons": _button_mask_to_json(self.button_mask), + "extra_buttons": [ + name for index, name in enumerate(EXTRA_BUTTONS) + if self.extra_buttons & (1 << index) + ], "left_stick": { "x": self.left_stick[0], "y": self.left_stick[1], @@ -1056,7 +1071,7 @@ class TriggerConfig: if ( type(self.output) is not int or self.output != PROFILE_NONE_BUTTON - and not 0 <= self.output < len(LOGICAL_CONTROLS) + and not 0 <= self.output < len(OUTPUT_CONTROLS) ): raise ConfigManagerError("invalid trigger output mapping") @@ -1430,12 +1445,14 @@ class ProfileShortcuts: } @classmethod - def from_json_object(cls, value: Any) -> ProfileShortcuts: + def from_json_object( + cls, value: Any, *, schema_version: int = PROFILE_SCHEMA_VERSION + ) -> ProfileShortcuts: obj = _require_object(value, ("modifier", "profiles"), "profile.shortcuts") if type(obj["profiles"]) is not list: raise ConfigManagerError("profile.shortcuts.profiles must be an array") return cls( - _control_index(obj["modifier"], "profile.shortcuts.modifier"), + _control_index(obj["modifier"], "profile.shortcuts.modifier", schema_version=schema_version), tuple( _button_index(selector, f"profile.shortcuts.profiles[{index}]") for index, selector in enumerate(obj["profiles"]) @@ -1448,6 +1465,7 @@ class ProfileShift: mode: int = 0 modifier: int = PROFILE_NONE_BUTTON button_map: tuple[int, ...] = tuple(range(len(LOGICAL_BUTTONS))) + extra_button_map: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS) def __post_init__(self) -> None: _require_int(self.mode, "Shift mode", 0, len(SHIFT_MODES) - 1) @@ -1462,7 +1480,9 @@ class ProfileShift: LOGICAL_BUTTONS ): raise ConfigManagerError("Shift button map must contain 16 mappings") - for output in self.button_map: + if type(self.extra_button_map) is not tuple or len(self.extra_button_map) != len(EXTRA_BUTTONS): + raise ConfigManagerError("Shift extra button map must contain seven mappings") + for output in (*self.button_map, *self.extra_button_map): if type(output) is not int or ( output != PROFILE_NONE_BUTTON and not 0 <= output < len(LOGICAL_BUTTONS) ): @@ -1476,23 +1496,38 @@ class ProfileShift: name: _button_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, + "extra_button_map": { + name: _button_name(self.extra_button_map[index]) + for index, name in enumerate(EXTRA_BUTTONS) + }, } @classmethod - def from_json_object(cls, value: Any) -> ProfileShift: - obj = _require_object( - value, ("mode", "modifier", "button_map"), "profile.shift" - ) + def from_json_object( + cls, value: Any, *, schema_version: int = PROFILE_SCHEMA_VERSION + ) -> ProfileShift: + fields = ["mode", "modifier", "button_map"] + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.append("extra_button_map") + obj = _require_object(value, fields, "profile.shift") mappings = _require_object( obj["button_map"], LOGICAL_BUTTONS, "profile.shift.button_map" ) + extras = ( + _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.shift.extra_button_map") + if schema_version >= PROFILE_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + ) return cls( _require_enum(obj["mode"], SHIFT_MODES, "profile.shift.mode"), - _control_index(obj["modifier"], "profile.shift.modifier"), + _control_index(obj["modifier"], "profile.shift.modifier", schema_version=schema_version), tuple( _button_index(mappings[name], f"profile.shift.button_map.{name}") for name in LOGICAL_BUTTONS ), + tuple( + _button_index(extras[name], f"profile.shift.extra_button_map.{name}") + for name in EXTRA_BUTTONS + ), ) @@ -1594,25 +1629,25 @@ class ControllerMacro: cls, value: Any, name: str, *, schema_version: int = PROFILE_SCHEMA_VERSION ) -> ControllerMacro: fields = ["trigger", "cancel", "steps"] - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: fields.extend(("playback", "repeat_count")) obj = _require_object(value, fields, name) steps = obj["steps"] if type(steps) is not list or len(steps) > PROFILE_MACRO_STEPS_PER_MACRO: raise ConfigManagerError(f"{name}.steps must contain zero to eight steps") return cls( - _control_mask_from_json(obj["trigger"], f"{name}.trigger"), - _control_index(obj["cancel"], f"{name}.cancel"), + _control_mask_from_json(obj["trigger"], f"{name}.trigger", schema_version=schema_version), + _control_index(obj["cancel"], f"{name}.cancel", schema_version=schema_version), tuple( MacroStep.from_json_object(step, f"{name}.steps[{index}]") for index, step in enumerate(steps) ), ( _require_enum(obj["playback"], MACRO_PLAYBACK_MODES, f"{name}.playback") - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else 0 ), - obj["repeat_count"] if schema_version >= PROFILE_SCHEMA_VERSION else 1, + obj["repeat_count"] if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else 1, ) @@ -1634,16 +1669,19 @@ class ControllerProfile: shift: ProfileShift = ProfileShift() turbo_defaults: TurboSettings = TurboSettings() turbo_overrides: tuple[TurboSettings | None, ...] = (None,) * len(LOGICAL_BUTTONS) + extra_button_map: tuple[int, ...] = (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS) def __post_init__(self) -> None: if type(self.button_map) is not tuple or len(self.button_map) != len( LOGICAL_BUTTONS ): raise ConfigManagerError("button map must contain 16 logical mappings") - for mapping in self.button_map: + if type(self.extra_button_map) is not tuple or len(self.extra_button_map) != len(EXTRA_BUTTONS): + raise ConfigManagerError("extra button map must contain seven mappings") + for mapping in (*self.button_map, *self.extra_button_map): if type(mapping) is not int or ( mapping != PROFILE_NONE_BUTTON - and not 0 <= mapping < len(LOGICAL_CONTROLS) + and not 0 <= mapping < len(OUTPUT_CONTROLS) ): raise ConfigManagerError("invalid logical control mapping") if not isinstance(self.left_stick, StickConfig) or not isinstance( @@ -1657,7 +1695,7 @@ class ControllerProfile: routed_triggers = [ trigger.output for trigger in (self.left_trigger, self.right_trigger) - if len(LOGICAL_BUTTONS) <= trigger.output < len(LOGICAL_CONTROLS) + if len(LOGICAL_BUTTONS) <= trigger.output < len(OUTPUT_CONTROLS) ] if len(routed_triggers) != len(set(routed_triggers)): raise ConfigManagerError( @@ -1767,7 +1805,7 @@ class ControllerProfile: raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from("= PROFILE_SCHEMA_VERSION else PROFILE_LEGACY_SIZE + PROFILE_SIZE if version >= PROFILE_EXPANDED_SCHEMA_VERSION else PROFILE_LEGACY_SIZE ) if ( version < PROFILE_LEGACY_SCHEMA_VERSION @@ -1779,6 +1817,8 @@ class ControllerProfile: has_control_mapping = version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION has_action_controls = version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION sparse_macros = version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION + has_extra_buttons = version >= PROFILE_SCHEMA_VERSION + control_count = len(LOGICAL_CONTROLS) if has_extra_buttons else len(OUTPUT_CONTROLS) if sparse_macros: if payload[75] & 0xCC: raise ConfigManagerError("profile action flags are invalid") @@ -1811,6 +1851,11 @@ class ControllerProfile: motion_toggle_chord = struct.unpack_from("> 4) & 0x03) << 16 ) + if has_extra_buttons: + if any(value & 0x80 for value in payload[358:364]): + raise ConfigManagerError("invalid extra control mask") + switching_chord |= payload[362] << 18 + motion_toggle_chord |= payload[363] << 18 turbo_modes = tuple(payload[80:96]) macros: list[ControllerMacro] = [] stream_offset = 0 @@ -1823,8 +1868,10 @@ class ControllerProfile: trigger_mask = struct.unpack_from("> 2) & 0x1F - if cancel > len(LOGICAL_CONTROLS) - 1 and cancel != 0x1F: + if cancel >= control_count and cancel != 0x1F: raise ConfigManagerError("invalid sparse macro cancel control") step_count = descriptor[4] encoded_size = descriptor[5] @@ -1855,10 +1902,10 @@ class ControllerProfile: PROFILE_NONE_BUTTON if cancel == 0x1F else cancel, tuple(steps), payload[336 + macro_index * 2] - if version >= PROFILE_SCHEMA_VERSION + if version >= PROFILE_EXPANDED_SCHEMA_VERSION else 0, payload[337 + macro_index * 2] - if version >= PROFILE_SCHEMA_VERSION + if version >= PROFILE_EXPANDED_SCHEMA_VERSION else 1, ) ) @@ -1886,6 +1933,8 @@ class ControllerProfile: 0 if legacy_trigger == PROFILE_NONE_BUTTON else 1 << legacy_trigger ) cancel_control = payload[79] + if cancel_control != PROFILE_NONE_BUTTON and cancel_control >= control_count: + raise ConfigManagerError("invalid legacy macro cancel control") if has_action_controls: switching_chord |= (payload[75] & 0x03) << 16 trigger_mask |= ((payload[75] >> 2) & 0x03) << 16 @@ -1918,9 +1967,14 @@ class ControllerProfile: shift = ProfileShift() turbo_defaults = TurboSettings() turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) - if version >= PROFILE_SCHEMA_VERSION: + if version >= PROFILE_EXPANDED_SCHEMA_VERSION: + if any(value != PROFILE_NONE_BUTTON and value >= control_count for value in (payload[256], payload[266])): + raise ConfigManagerError("invalid profile modifier") shortcuts = ProfileShortcuts(payload[256], tuple(payload[257:265])) - shift = ProfileShift(payload[265], payload[266], tuple(payload[267:283])) + shift = ProfileShift( + payload[265], payload[266], tuple(payload[267:283]), + tuple(payload[351:358]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), + ) turbo_defaults = TurboSettings(*payload[283:286]) override_mask = struct.unpack_from(" 2 for mode in turbo_modes): raise ConfigManagerError("invalid legacy Turbo mode") @@ -1954,6 +2009,7 @@ class ControllerProfile: shift=shift, turbo_defaults=turbo_defaults, turbo_overrides=tuple(turbo_overrides), + extra_button_map=tuple(payload[344:351]) if has_extra_buttons else (PROFILE_NONE_BUTTON,) * len(EXTRA_BUTTONS), ) def to_bytes(self) -> bytes: @@ -1999,6 +2055,7 @@ class ControllerProfile: stream.extend(encoded_steps) payload[336 + macro_index * 2] = macro.playback payload[337 + macro_index * 2] = macro.repeat_count + payload[358 + macro_index] = (macro.trigger_mask >> 18) & 0x7F if len(stream) > PROFILE_MACRO_STREAM_SIZE: raise ConfigManagerError("profile macros exceed the 136-byte sparse stream") payload[120 : 120 + len(stream)] = stream @@ -2028,6 +2085,10 @@ class ControllerProfile: ) settings_offset += 3 struct.pack_into("> 18) & 0x7F + payload[363] = (self.motion_toggle_chord >> 18) & 0x7F return bytes(payload) def to_json_object(self) -> dict[str, Any]: @@ -2038,6 +2099,10 @@ class ControllerProfile: name: _control_name(self.button_map[index]) for index, name in enumerate(LOGICAL_BUTTONS) }, + "extra_button_map": { + name: _control_name(self.extra_button_map[index]) + for index, name in enumerate(EXTRA_BUTTONS) + }, "sticks": { "left": self.left_stick.to_json_object(), "right": self.right_stick.to_json_object(), @@ -2105,12 +2170,14 @@ class ControllerProfile: if schema_version >= PROFILE_SPARSE_MACRO_SCHEMA_VERSION else "macro" ) - if schema_version >= PROFILE_SCHEMA_VERSION: + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: fields.extend(("shortcuts", "shift", "turbo_settings")) + if schema_version >= PROFILE_SCHEMA_VERSION: + fields.append("extra_button_map") obj = _require_object(value, fields, "profile") expected_size = ( PROFILE_SIZE - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else PROFILE_LEGACY_SIZE ) if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != expected_size: @@ -2118,6 +2185,10 @@ class ControllerProfile: button_map = _require_object( obj["button_map"], LOGICAL_BUTTONS, "profile.button_map" ) + extras = ( + _require_object(obj["extra_button_map"], EXTRA_BUTTONS, "profile.extra_button_map") + if schema_version >= PROFILE_SCHEMA_VERSION else dict.fromkeys(EXTRA_BUTTONS) + ) sticks = _require_object(obj["sticks"], ("left", "right"), "profile.sticks") triggers = _require_object( obj["triggers"], ("left", "right"), "profile.triggers" @@ -2145,7 +2216,7 @@ class ControllerProfile: right_trigger = _migrate_legacy_trigger_threshold(right_trigger) mask_parser = ( - _control_mask_from_json + (lambda value, name: _control_mask_from_json(value, name, schema_version=schema_version)) if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_mask_from_json ) @@ -2195,7 +2266,7 @@ class ControllerProfile: if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: trigger_mask = mask_parser(macro["trigger"], "profile.macro.trigger") cancel_control = ( - _control_index(macro["cancel"], "profile.macro.cancel") + _control_index(macro["cancel"], "profile.macro.cancel", schema_version=schema_version) if schema_version >= PROFILE_ACTION_CONTROL_SCHEMA_VERSION else _button_index(macro["cancel"], "profile.macro.cancel") ) @@ -2212,9 +2283,9 @@ class ControllerProfile: shift = ProfileShift() turbo_defaults = TurboSettings() turbo_overrides: list[TurboSettings | None] = [None] * len(LOGICAL_BUTTONS) - if schema_version >= PROFILE_SCHEMA_VERSION: - shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"]) - shift = ProfileShift.from_json_object(obj["shift"]) + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION: + shortcuts = ProfileShortcuts.from_json_object(obj["shortcuts"], schema_version=schema_version) + shift = ProfileShift.from_json_object(obj["shift"], schema_version=schema_version) settings = _require_object( obj["turbo_settings"], ("defaults", "overrides"), @@ -2272,7 +2343,7 @@ class ControllerProfile: _require_enum( turbo[name], TURBO_MODES - if schema_version >= PROFILE_SCHEMA_VERSION + if schema_version >= PROFILE_EXPANDED_SCHEMA_VERSION else TURBO_MODES[:3], f"profile.turbo.{name}", ) @@ -2282,6 +2353,10 @@ class ControllerProfile: shift=shift, turbo_defaults=turbo_defaults, turbo_overrides=tuple(turbo_overrides), + extra_button_map=tuple( + _control_index(extras[name], f"profile.extra_button_map.{name}") + for name in EXTRA_BUTTONS + ), ) @classmethod @@ -3367,6 +3442,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_ACTION_CONTROL_SCHEMA_VERSION, PROFILE_SPARSE_MACRO_SCHEMA_VERSION, + PROFILE_EXPANDED_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -3444,6 +3520,7 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_ACTION_CONTROL_SCHEMA_VERSION, PROFILE_SPARSE_MACRO_SCHEMA_VERSION, + PROFILE_EXPANDED_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") @@ -3541,12 +3618,16 @@ def identify_controller(device: UsbDevice, identity: ControllerIdentity) -> None def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: _raise_status(envelope) - if ( - envelope.schema_version != PROFILE_PLAYTEST_SCHEMA_VERSION - or len(envelope.payload) != PROFILE_PLAYTEST_SIZE - ): + expected_size = { + PROFILE_PLAYTEST_LEGACY_SCHEMA_VERSION: PROFILE_PLAYTEST_LEGACY_SIZE, + PROFILE_PLAYTEST_SCHEMA_VERSION: PROFILE_PLAYTEST_SIZE, + }.get(envelope.schema_version) + if len(envelope.payload) != expected_size: raise ConfigManagerError("invalid profile playtest payload") payload = envelope.payload + extra_buttons = payload[54] if envelope.schema_version == PROFILE_PLAYTEST_SCHEMA_VERSION else 0 + if extra_buttons & ~0x7F: + raise ConfigManagerError("invalid playtest extra buttons") flags = payload[0] if flags & ~0x03 or flags != envelope.flags or payload[41] != 0: raise ConfigManagerError("invalid profile playtest flags") @@ -3596,6 +3677,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: battery=payload[39], capabilities=payload[40], motion=motion_values if has_motion else None, + extra_buttons=extra_buttons, ) diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index 57e3ffa..7aeadd1 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -86,6 +86,13 @@ _CONTROL_LABELS = { "system": "Home", "left_stick": "Left Stick", "right_stick": "Right Stick", + "c": "C", + "gl": "GL", + "gr": "GR", + "left_sl": "Left SL", + "left_sr": "Left SR", + "right_sl": "Right SL", + "right_sr": "Right SR", }, "playstation": { "north": "Triangle", @@ -114,6 +121,9 @@ def _controller_presentation( known = { (0x057E, 0x2009): ("Nintendo Switch Pro Controller", "switch"), + (0x057E, 0x2069): ("Nintendo Switch 2 Pro Controller", "switch"), + (0x057E, 0x2067): ("Nintendo Joy-Con 2 (L)", "switch"), + (0x057E, 0x2066): ("Nintendo Joy-Con 2 (R)", "switch"), (0x054C, 0x0CE6): ("Sony DualSense", "playstation"), (0x054C, 0x09CC): ("Sony DualShock 4", "playstation"), } @@ -201,6 +211,8 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): { "buttons": list(config_manager.LOGICAL_BUTTONS), "controls": list(config_manager.LOGICAL_CONTROLS), + "output_controls": list(config_manager.OUTPUT_CONTROLS), + "extra_buttons": list(config_manager.EXTRA_BUTTONS), "default_switching_chord": [ "left_shoulder", "right_shoulder", @@ -445,9 +457,12 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): "alias": entry.alias, "modifier_controls": list( config_manager.LOGICAL_CONTROLS + if entry.identity.vendor_id == 0x057E + and entry.identity.product_id in {0x2069, 0x2067, 0x2066} + else config_manager.OUTPUT_CONTROLS if _controller_presentation(entry.identity)["style"] in {"xbox", "playstation"} - else config_manager.LOGICAL_BUTTONS + else config_manager.LOGICAL_BUTTONS + config_manager.EXTRA_BUTTONS ), } for index, entry in enumerate(entries) diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index b1c9705..6506256 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -308,6 +308,13 @@ h4 { font-size: 1rem; } .controller-canvas[data-style="switch"] .controller-photo-wrap { aspect-ratio: 1.38; } .controller-canvas[data-style="playstation"] .controller-photo-wrap { aspect-ratio: 1.8; } .controller-photo { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; filter: drop-shadow(0 22px 28px rgba(0, 0, 0, 0.46)); } +.extra-controls { display: flex; flex-wrap: wrap; gap: 9px; margin: 10px 0; } +.extra-controls button { min-width: 48px; padding: 9px 12px; color: var(--ink); border: 1px solid var(--sky); border-radius: 9px; background: var(--surface-2); font-size: 0.72rem; font-weight: 800; cursor: pointer; } +.extra-controls button.disabled-map { color: var(--muted); border-color: var(--control-line); } +.extra-controls button.selected, .extra-controls button:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } +.extra-controls button.pressed { color: #090f1d; background: var(--amber); border-color: var(--amber); } +.mapping-playtest { margin-top: 14px; font-size: 0.76rem; } +.mapping-playtest > p { margin: 6px 0; } .controller-hotspots { position: absolute; inset: 0; } .controller-hotspots button { --x: 50%; diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 260ec7a..0334682 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -142,6 +142,9 @@

Select a highlighted control on the controller to remap it.

+

Switch 2 extra inputs

+
+

C, back and rail buttons are source-only controls. Unmapped extra inputs produce no console output.

Image: Controller artwork by Al. Lopez · MIT @@ -261,6 +264,11 @@ +

+

Raw extra inputs: None

+

Draft button output: None

+

Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated.

+
diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index f79114d..7f93387 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -43,6 +43,7 @@ const state = { previewInputConnected: false, playtestRequestActive: false, liveSample: null, + playtestShift: { key: "", held: false, active: false }, playtestTimer: 0, libraryRequestActive: false, libraryTimer: 0, @@ -70,6 +71,7 @@ const elements = { controllerModel: document.querySelector("#controllerModel"), controllerCredit: document.querySelector("#controllerCredit"), controllerHotspots: document.querySelector("#controllerHotspots"), + extraControls: document.querySelector("#extraControls"), selectedMapping: document.querySelector("#selectedMapping"), selectedControlGlyph: document.querySelector("#selectedControlGlyph"), selectedControlName: document.querySelector("#selectedControlName"), @@ -120,6 +122,9 @@ const elements = { playtestTitle: document.querySelector("#playtestTitle"), playtestStatus: document.querySelector("#playtestStatus"), playtestHelp: document.querySelector("#playtestHelp"), + playtestExtraInputs: document.querySelector("#playtestExtraInputs"), + playtestMappedButtons: document.querySelector("#playtestMappedButtons"), + playtestMappingHelp: document.querySelector("#playtestMappingHelp"), playtestLeftValues: document.querySelector("#playtestLeftValues"), playtestRightValues: document.querySelector("#playtestRightValues"), playtestLeftTriggerLabel: document.querySelector("#playtestLeftTriggerLabel"), @@ -162,6 +167,13 @@ const directionalLabels = { dpad_right: "D-pad Right", dpad_down: "D-pad Down", dpad_left: "D-pad Left", + c: "C", + gl: "GL", + gr: "GR", + left_sl: "Left SL", + left_sr: "Left SR", + right_sl: "Right SL", + right_sr: "Right SR", }; function currentControllerStyle() { @@ -245,6 +257,9 @@ function updateCurveMarker(group, side, input) { function clearPlaytest(message, stateName = "waiting") { state.liveSample = null; + state.playtestShift = { key: "", held: false, active: false }; + elements.playtestExtraInputs.textContent = "None"; + elements.playtestMappedButtons.textContent = "None"; if (state.previewInputConnected) stopMacroPreview("Preview stopped: controller disconnected."); state.previewInputConnected = false; state.identifyAvailable = false; @@ -255,7 +270,7 @@ function clearPlaytest(message, stateName = "waiting") { elements.playtestTitle.textContent = stateName === "error" ? "Live input unavailable" : "Waiting for controller input"; elements.playtestHelp.textContent = message; - elements.controllerHotspots.querySelectorAll(".pressed") + elements.controllerCanvas.querySelectorAll(".pressed") .forEach((button) => button.classList.remove("pressed")); document.querySelectorAll(".curve-marker.visible") .forEach((marker) => marker.classList.remove("visible")); @@ -271,16 +286,32 @@ function renderPlaytest(sample) { return; } state.previewInputConnected = true; + const shift = state.profile.shift; + const shiftKey = `${sample.identity_key}:${sample.connection_generation}:${shift.mode}:${shift.modifier}`; + if (state.playtestShift.key !== shiftKey) { + state.playtestShift = { key: shiftKey, held: false, active: false }; + } + const controls = new Set([...sample.buttons, ...(sample.extra_buttons || [])]); + for (const side of ["left", "right"]) { + const value = transformTrigger(sample.triggers[side], state.profile.triggers[side]); + if (value >= state.profile.triggers[side].digital_threshold) { + controls.add(`${side}_trigger`); + } + } + const held = controls.has(shift.modifier); + if (shift.mode === "toggle" && held && !state.playtestShift.held) { + state.playtestShift.active = !state.playtestShift.active; + } else if (shift.mode !== "toggle") { + state.playtestShift.active = shift.mode === "hold" && held; + } + state.playtestShift.held = held; + const mapped = ProfilePlaytestMath.transformMappings(sample, state.profile, state.playtestShift.active); const rawLeft = sample.left_stick; const rawRight = sample.right_stick; const outputLeft = transformStick(rawLeft, state.profile.sticks.left); const outputRight = transformStick(rawRight, state.profile.sticks.right); - const outputLeftTrigger = transformTrigger( - sample.triggers.left, state.profile.triggers.left - ); - const outputRightTrigger = transformTrigger( - sample.triggers.right, state.profile.triggers.right - ); + const outputLeftTrigger = mapped.triggers.left; + const outputRightTrigger = mapped.triggers.right; updateStickPlaytest( "left", rawLeft, outputLeft, state.profile.sticks.left ); @@ -310,15 +341,21 @@ function renderPlaytest(sample) { controlLabel("left_trigger", style); elements.playtestRightTriggerLabel.textContent = controlLabel("right_trigger", style); - const pressed = new Set(sample.buttons); + const pressed = new Set([...sample.buttons, ...(sample.extra_buttons || [])]); if (sample.triggers.left > 512) pressed.add("left_trigger"); if (sample.triggers.right > 512) pressed.add("right_trigger"); - elements.controllerHotspots.querySelectorAll("[data-controller-button]") + elements.controllerCanvas.querySelectorAll("[data-controller-button]") .forEach((button) => { button.classList.toggle( "pressed", pressed.has(button.dataset.controllerButton) ); }); + elements.playtestExtraInputs.textContent = + (sample.extra_buttons || []).map((button) => controlLabel(button, style)).join(", ") || "None"; + elements.playtestMappedButtons.textContent = + mapped.buttons.map((button) => controlLabel(button, style)).join(", ") || "None"; + elements.playtestMappingHelp.textContent = + `${state.playtestShift.active ? "Shift" : "Base"} layer preview; shortcuts, macros and Turbo are not simulated.`; elements.playtestPanel.dataset.state = "live"; elements.playtestStatus.textContent = "Live"; const owner = currentOwner(); @@ -597,7 +634,7 @@ function renderProfileList() { function buttonOptions( selected, includeNone = true, - choices = state.schema.controls, + choices = state.schema.output_controls, style = currentControllerStyle() ) { const none = includeNone @@ -628,7 +665,7 @@ function renderShortcuts() {
-

None clears every shortcut. Analog trigger modifiers require a known Xbox or PlayStation owner; unsupported choices remain unavailable.

+

None clears every shortcut. Switch 2 extra inputs can act as modifiers. Trigger modifiers are available for known Xbox, PlayStation and Switch 2 owners.

`; elements.shortcuts.innerHTML = shortcuts.profiles.map((selector, index) => `
@@ -654,11 +691,14 @@ function renderShift() {

Hold uses the alternate map while pressed. Toggle switches layers on each fresh press and resets when the profile or mode changes. Analog modifiers follow the same availability as shortcuts.

`; - elements.shiftMap.innerHTML = state.schema.buttons.map((button) => ` + elements.shiftMap.innerHTML = [...state.schema.buttons, ...state.schema.extra_buttons].map((button) => { + const map = state.schema.extra_buttons.includes(button) ? shift.extra_button_map : shift.button_map; + return `
- -
`).join(""); + + `; + }).join(""); updateShiftValidity(); } @@ -725,6 +765,8 @@ const fixedControlGlyphs = { start: "+", capture: "▣", system: "⌂", + c: "C", gl: "GL", gr: "GR", + left_sl: "L SL", left_sr: "L SR", right_sl: "R SL", right_sr: "R SR", }; function controlGlyph(button, style) { @@ -734,6 +776,7 @@ function controlGlyph(button, style) { function getControlMapping(button) { if (button === "left_trigger") return state.profile.triggers.left.output; if (button === "right_trigger") return state.profile.triggers.right.output; + if (state.schema.extra_buttons.includes(button)) return state.profile.extra_button_map[button]; return state.profile.button_map[button]; } @@ -752,6 +795,8 @@ function setControlMapping(button, output) { state.profile.triggers[otherSide].output = button; } state.profile.triggers[side].output = output; + } else if (state.schema.extra_buttons.includes(button)) { + state.profile.extra_button_map[button] = output; } else { state.profile.button_map[button] = output; } @@ -772,10 +817,13 @@ function renderButtonMap() { elements.controllerCredit.href = "https://github.com/AL2009man/Gamepad-Asset-Pack"; elements.controllerCredit.textContent = "Controller artwork by Al. Lopez · MIT"; - elements.controllerHotspots.querySelectorAll("[data-controller-button]").forEach((hotspot) => { + elements.extraControls.innerHTML = state.schema.extra_buttons.map((button) => + `` + ).join(""); + elements.controllerCanvas.querySelectorAll("[data-controller-button]").forEach((hotspot) => { const button = hotspot.dataset.controllerButton; const output = getControlMapping(button); - hotspot.textContent = controlGlyph(button, style); + hotspot.textContent = state.schema.extra_buttons.includes(button) ? controlLabel(button, style) : controlGlyph(button, style); hotspot.classList.toggle("selected", button === selected); hotspot.classList.toggle("disabled-map", output === null); hotspot.title = `${controlLabel(button, style)} → ${output === null ? "Disabled" : controlLabel(output, style)}`; @@ -784,6 +832,7 @@ function renderButtonMap() { state.selectedButton = button; stopMacroPreview("Preview stopped: control selection changed."); renderButtonMap(); + elements.controllerCanvas.querySelector(`[data-controller-button="${button}"]`).focus(); }; }); @@ -792,12 +841,13 @@ function renderButtonMap() { elements.selectedControlDescription.textContent = ( `Physical ${controlLabel(selected, style)} currently produces ${mappedOutput === null ? "no output" : controlLabel(mappedOutput, style)}.` ); - elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.controls, style); + elements.selectedMapping.innerHTML = buttonOptions(mappedOutput, true, state.schema.output_controls, style); elements.selectedMapping.onchange = () => { setControlMapping(selected, elements.selectedMapping.value || null); renderButtonMap(); updateDirtyState(); }; + if (state.liveSample) renderPlaytest(state.liveSample); } const analogDefinitions = [ @@ -1756,6 +1806,7 @@ function renderMacroSteps() { } function renderEditor() { + state.playtestShift = { key: "", held: false, active: false }; elements.profileTitle.textContent = state.profileNames[state.profileIndex] || `Profile ${state.profileIndex + 1}`; elements.activeBadge.hidden = !state.active; @@ -1908,7 +1959,9 @@ function handleFormChange(event) { renderShift(); elements.shift.querySelector(`#${kind}`).focus(); } else if (kind === "shift-map") { - state.profile.shift.button_map[target.dataset.name] = target.value || null; + const map = state.schema.extra_buttons.includes(target.dataset.name) + ? state.profile.shift.extra_button_map : state.profile.shift.button_map; + map[target.dataset.name] = target.value || null; } else if (kind === "turbo") { state.profile.turbo[target.dataset.name] = target.value; renderTurbo(); @@ -1996,6 +2049,7 @@ function handleFormChange(event) { updateMacroBudgets(); } updateDirtyState(); + if (state.liveSample) renderPlaytest(state.liveSample); } elements.analog.addEventListener("click", (event) => { @@ -2329,6 +2383,7 @@ document.querySelectorAll("[data-reset-section]").forEach((button) => { const section = button.dataset.resetSection; if (section === "mapping") { state.profile.button_map = clone(defaults.button_map); + state.profile.extra_button_map = clone(defaults.extra_button_map); state.profile.triggers.left.output = defaults.triggers.left.output; state.profile.triggers.right.output = defaults.triggers.right.output; } else if (section === "analog") { diff --git a/src/switch_pico_bridge/web/profile_playtest.js b/src/switch_pico_bridge/web/profile_playtest.js index e9d6357..cb7293b 100644 --- a/src/switch_pico_bridge/web/profile_playtest.js +++ b/src/switch_pico_bridge/web/profile_playtest.js @@ -88,6 +88,41 @@ ); } + function transformMappings(sample, profile, shiftActive = false) { + const buttons = new Set(); + const triggers = { left: 0, right: 0 }; + const consumed = profile.shift.mode !== "off" ? profile.shift.modifier : null; + const map = shiftActive ? profile.shift : profile; + const route = (output, value = 65535) => { + if (output === "left_trigger" || output === "right_trigger") { + const side = output === "left_trigger" ? "left" : "right"; + triggers[side] = Math.max(triggers[side], value); + } else if (output && Object.hasOwn(profile.button_map, output)) { + buttons.add(output); + } + }; + for (const source of sample.buttons) { + if (source !== consumed) route(map.button_map[source]); + } + for (const source of sample.extra_buttons || []) { + if (source !== consumed) route(map.extra_button_map[source]); + } + for (const side of ["left", "right"]) { + if (`${side}_trigger` === consumed) continue; + const config = profile.triggers[side]; + const value = transformTrigger(sample.triggers[side], config); + if (config.output === "left_trigger" || config.output === "right_trigger") { + route(config.output, value); + } else if (value >= config.digital_threshold) { + route(config.output); + } + } + return { + buttons: Object.keys(profile.button_map).filter((button) => buttons.has(button)), + triggers, + }; + } + function stickPosition(value) { return Math.max(3, Math.min(97, 50 + value / 32768 * 47)); } @@ -106,6 +141,7 @@ root.ProfilePlaytestMath = Object.freeze({ transformStick, transformTrigger, + transformMappings, stickCoordinates, triggerPercent, }); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 8a74540..29b0df0 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -9,6 +9,8 @@ #endif #include +#include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_pairing.h" #include "platform/pico/controller_color_config.h" #include "input/switch2_wake.h" @@ -67,6 +69,11 @@ gap_connection_type_t gap_connection_types[256]{}; uint8_t xbox_left_trigger = 0; uint8_t xbox_right_trigger = 0; unsigned xbox_quad_calls = 0; +unsigned ordinary_smp_requests = 0; +bd_addr_t switch2_pairings[UNI_SWITCH2_PAIRING_CAPACITY]{}; +uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{}; +uint8_t switch2_pairing_count = 0; +bool switch2_clear_succeeds = true; struct CoreStopped {}; @@ -145,6 +152,53 @@ void uni_hid_parser_xboxone_play_dual_rumble( } +extern "C" void __real_sm_request_pairing(hci_con_handle_t) { + ++ordinary_smp_requests; +} + +extern "C" bool uni_switch2_pairing_get( + uint8_t index, uint8_t* address_type, uint8_t address[6]) { + if (index >= switch2_pairing_count) { + return false; + } + *address_type = switch2_pairing_types[index]; + memcpy(address, switch2_pairings[index], sizeof(bd_addr_t)); + return true; +} + +extern "C" bool uni_switch2_pairing_clear(void) { + if (!switch2_clear_succeeds) { + return false; + } + switch2_pairing_count = 0; + return true; +} + +extern "C" bool uni_hid_parser_switch2_is_ble_device( + const uni_hid_device_t* device) { + return device != nullptr && + device->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE && + device->vendor_id == UNI_SW2_NINTENDO_VID && + (device->product_id == UNI_SW2_PRO_PID || + device->product_id == UNI_SW2_JOYCON_L_PID || + device->product_id == UNI_SW2_JOYCON_R_PID); +} + +extern "C" uint8_t uni_hid_parser_switch2_extra_buttons( + const uni_hid_device_t* device) { + return uni_hid_parser_switch2_is_ble_device(device) + ? device->switch2_extra_buttons : 0; +} + +extern "C" bool uni_hid_parser_switch2_identity_address_type( + const uni_hid_device_t* device, uint8_t* output) { + if (!uni_hid_parser_switch2_is_ble_device(device) || + !device->switch2_identity_valid || output == nullptr) { + return false; + } + *output = device->switch2_identity_address_type; + return true; +} bool uni_hid_device_is_gamepad(const uni_hid_device_t* device) { return device != nullptr && device->gamepad; } @@ -783,6 +837,377 @@ void tick_backend_timer(int ticks) { } } +uni_hid_device_t switch2_device(int index, uint16_t product) { + uni_hid_device_t result = device(index, true, UNI_BT_CONN_PROTOCOL_BLE); + result.vendor_id = UNI_SW2_NINTENDO_VID; + result.product_id = product; + result.switch2_identity_valid = true; + result.switch2_identity_address_type = BD_ADDR_TYPE_LE_PUBLIC; + result.report_parser.set_player_leds = set_player_leds; + return result; +} + +void ready_switch2(uni_hid_device_t& controller) { + platform_on_device_connected(&controller); + require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, + "Switch2 physical device must become ready"); +} + +Bluepad32SlotSnapshot slot_snapshot(uint8_t index) { + Bluepad32SlotSnapshot result{}; + bluepad32_input_backend_snapshot(index, &result); + return result; +} + +void test_switch2_pair_lifecycle(bool right_first) { + start_pairing_backend(); + uni_hid_device_t left = switch2_device( + right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID); + uni_hid_device_t right = switch2_device( + right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID); + uni_hid_device_t& first = right_first ? right : left; + uni_hid_device_t& second = right_first ? left : right; + ready_switch2(first); + + uni_controller_t left_data{}; + left_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + left_data.gamepad.dpad = DPAD_UP; + left_data.gamepad.axis_x = -512; + left_data.gamepad.axis_y = 100; + left_data.gamepad.buttons = BUTTON_TRIGGER_L; + left_data.gamepad.accel[0] = 8192; + left.switch2_extra_buttons = + UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR; + uni_controller_t right_data{}; + right_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + right_data.gamepad.buttons = BUTTON_B | BUTTON_THUMB_R | BUTTON_TRIGGER_R; + right_data.gamepad.axis_rx = 200; + right_data.gamepad.axis_ry = -512; + right_data.gamepad.accel[2] = 8192; + right.switch2_extra_buttons = + UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GR | + UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR; + platform_on_controller_data(&first, right_first ? &right_data : &left_data); + const Bluepad32SlotSnapshot solo = slot_snapshot(0); + require(solo.active && solo.state.button_left_shoulder && + solo.state.button_right_shoulder && solo.state.button_left_stick == right_first && + solo.state.right_stick_x == 0 && solo.state.right_stick_y == 0 && + (right_first ? solo.state.button_south : solo.state.button_west), + "solo JoyCon must rotate face controls, stick click and rail shoulders"); + require(solo.state.left_stick_x == + (right_first ? INT16_MAX : scale_axis(100)) && + solo.state.left_stick_y == + (right_first ? scale_axis(200) : INT16_MAX), + "solo JoyCon stick must rotate with its physical sideways orientation"); + require(bluepad32_input_backend_capture_start( + 0, solo.connection_generation, CaptureOptions{}), + "solo capture must start on its logical generation"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{31, 41}); + bluepad32_input_backend_queue_profile_feedback( + 0, solo.connection_generation, 8, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + ready_switch2(second); + + Bluepad32SlotSnapshot merged = slot_snapshot(0); + require(merged.active && !slot_snapshot(1).active && + merged.connection_generation != solo.connection_generation && + controller_identity_equal(merged.identity, identity_for_device(&left)), + "either connection order must merge into first output with left profile owner"); + Bluepad32CaptureSnapshot capture{}; + require(bluepad32_input_backend_capture_page(0, 0, &capture) && + capture.state == CaptureState::kDisconnected, + "solo recording must not silently cross into the merged generation"); + require(!slot_snapshot(1).state.extra_buttons && + !slot_snapshot(1).state.button_south && + !slot_snapshot(1).state.button_west && + left.last_rumble_duration_ms == 0 && right.last_rumble_duration_ms == 0, + "pair merge must neutralize ghost output and stop old feedback"); + const int calls_after_merge = left.rumble_calls + right.rumble_calls; + bluepad32_input_backend_queue_profile_feedback( + 0, solo.connection_generation, 8, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(left.rumble_calls + right.rumble_calls == calls_after_merge && + left.player_leds == 1 && right.player_leds == 1, + "old-generation solo feedback must not reach the pair"); + platform_on_controller_data(&left, &left_data); + platform_on_controller_data(&right, &right_data); + merged = slot_snapshot(0); + require(merged.state.dpad_up && merged.state.button_east && + merged.state.button_right_stick && !merged.state.button_left_stick && + !merged.state.button_left_shoulder && !merged.state.button_right_shoulder && + merged.state.left_stick_x == INT16_MIN && + merged.state.right_stick_x == scale_axis(200) && + merged.state.left_trigger == UINT16_MAX && + merged.state.right_trigger == UINT16_MAX && + merged.state.extra_buttons == 0x7f && + merged.state.motion_samples[0].accel_x == -4096 && + merged.state.motion_samples[0].accel_y == 0, + "merged native controls and extras must combine with right-only aim motion"); + bluepad32_input_backend_report_sent(0); + platform_on_controller_data(&left, &left_data); + require(slot_snapshot(0).state.motion_sample_count == 0, + "left reports must not replay the last right motion sample"); + + uni_hid_device_t ordinary = device(2); + platform_on_device_connected(&ordinary); + require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS, + "ordinary device must coexist with a pair"); + uni_controller_t ordinary_data{}; + ordinary_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + ordinary_data.gamepad.buttons = BUTTON_Y; + platform_on_controller_data(&ordinary, &ordinary_data); + const Bluepad32SlotSnapshot ordinary_before = slot_snapshot(2); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{51, 61}); + bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{71, 81}); + process_rumble_timer(&g_rumble_timer); + require(left.last_low == 51 && right.last_low == 51 && + left.last_high == 61 && right.last_high == 61 && + ordinary.last_low == 71 && ordinary.last_high == 81, + "pair feedback must fan out without reaching an ordinary player"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{}); + process_rumble_timer(&g_rumble_timer); + require(left.last_rumble_duration_ms == 0 && + right.last_rumble_duration_ms == 0 && ordinary.last_low == 71, + "pair stop must stop both physical motors only"); + bluepad32_input_backend_queue_profile_feedback( + 0, merged.connection_generation, 2, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(left.player_leds == 3 && right.player_leds == 3 && + left.last_low == UINT8_MAX && right.last_low == UINT8_MAX, + "profile feedback must light and rumble both halves"); + now_ms += 300; + process_rumble_timer(&g_rumble_timer); + require(left.player_leds == 1 && right.player_leds == 1, + "profile completion must restore both halves' player indication"); + + uni_hid_device_t& lost = right_first ? left : right; + uni_hid_device_t& survivor = right_first ? right : left; + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{91, 101}); + platform_on_device_disconnected(&lost); + const Bluepad32SlotSnapshot detached = slot_snapshot(0); + require(detached.active && !slot_snapshot(1).active && + detached.connection_generation != merged.connection_generation && + controller_identity_equal(detached.identity, identity_for_device(&survivor)) && + detached.state.extra_buttons == survivor.switch2_extra_buttons && + detached.state.motion_sample_count == 0 && + !detached.state.dpad_up && !detached.state.button_east && + (right_first ? detached.state.button_south : detached.state.button_west), + "either half detach must immediately publish only the rotated survivor and own profile"); + require(survivor.last_rumble_duration_ms == 0 && + slot_snapshot(2).connection_generation == ordinary_before.connection_generation && + slot_snapshot(2).state.button_north, + "detach must cancel survivor feedback without changing unrelated player state"); + const int survivor_calls = survivor.rumble_calls; + platform_on_controller_data(&lost, right_first ? &left_data : &right_data); + require(platform_on_device_ready(&lost) == UNI_ERROR_NO_SLOTS, + "late ready for a detached half must not resurrect its old generation"); + bluepad32_input_backend_queue_profile_feedback( + 0, merged.connection_generation, 8, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + process_rumble_timer(&g_rumble_timer); + require(survivor.rumble_calls == survivor_calls && + slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons, + "late detached input and generation-bound feedback must be ignored"); + uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id); + ready_switch2(replacement); + platform_on_device_disconnected(&lost); + require(slot_snapshot(0).active && !slot_snapshot(1).active && + slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons && + slot_snapshot(0).connection_generation != detached.connection_generation, + "replacement must re-pair without stale presses or a late old disconnect"); + const uint32_t clear_token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + Bluepad32PairingSnapshot cleared{}; + bluepad32_input_backend_pairing_snapshot(&cleared); + require(bluepad32_input_backend_clear_pairings_completed(cleared, clear_token) && + device_disconnect_calls == 3 && + !slot_snapshot(0).active && !slot_snapshot(2).active && + !switch_pico_switch2_pairing_allowed(), + "clear must disconnect every physical half and ordinary device, not just outputs"); +} + +void test_switch2_multiple_pairs() { + start_pairing_backend(); + uni_hid_device_t right0 = switch2_device(0, UNI_SW2_JOYCON_R_PID); + uni_hid_device_t right1 = switch2_device(1, UNI_SW2_JOYCON_R_PID); + uni_hid_device_t left0 = switch2_device(2, UNI_SW2_JOYCON_L_PID); + uni_hid_device_t left1 = switch2_device(3, UNI_SW2_JOYCON_L_PID); + ready_switch2(right0); + ready_switch2(right1); + ready_switch2(left0); + ready_switch2(left1); + require(slot_snapshot(0).active && slot_snapshot(1).active && + !slot_snapshot(2).active && !slot_snapshot(3).active && + controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) && + controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) && + g_connection_policy_state == ConnectionPolicyState::Paused && + !scanning_enabled && !incoming_connections, + "two deterministic pairs must consume four physical resources but only two outputs"); + uni_controller_t data{}; + data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + data.gamepad.buttons = BUTTON_A; + right0.switch2_extra_buttons = UNI_SW2_BUTTON_C; + platform_on_controller_data(&right0, &data); + data.gamepad.buttons = BUTTON_Y; + right1.switch2_extra_buttons = UNI_SW2_BUTTON_GR; + platform_on_controller_data(&right1, &data); + require(slot_snapshot(0).state.button_south && !slot_snapshot(0).state.button_north && + slot_snapshot(0).state.extra_buttons == UNI_SW2_BUTTON_C && + slot_snapshot(1).state.button_north && !slot_snapshot(1).state.button_south && + slot_snapshot(1).state.extra_buttons == UNI_SW2_BUTTON_GR, + "two pairs must not cross-contaminate normal or extra source input"); + bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{11, 21}); + bluepad32_input_backend_queue_rumble(1, ControllerRumbleOutput{31, 41}); + process_rumble_timer(&g_rumble_timer); + require(left0.last_low == 11 && right0.last_low == 11 && + left1.last_low == 31 && right1.last_low == 31 && + left0.player_leds == 1 && right0.player_leds == 1 && + left1.player_leds == 2 && right1.player_leds == 2, + "multiple pairs must retain isolated rumble and player lighting"); + const Bluepad32SlotSnapshot other_pair = slot_snapshot(1); + platform_on_device_disconnected(&right0); + uni_hid_device_t ordinary = device(0); + platform_on_device_connected(&ordinary); + require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS && + slot_snapshot(0).active && slot_snapshot(2).active && + !slot_snapshot(3).active && + controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)), + "reusing freed physical index must allocate a free output, not evict the surviving half"); + data.gamepad.buttons = BUTTON_B; + platform_on_controller_data(&ordinary, &data); + bluepad32_input_backend_queue_rumble(2, ControllerRumbleOutput{91, 101}); + process_rumble_timer(&g_rumble_timer); + require(slot_snapshot(2).state.button_east && !slot_snapshot(0).state.button_east && + ordinary.last_low == 91 && left1.last_low == 31 && right1.last_low == 31 && + slot_snapshot(1).connection_generation == other_pair.connection_generation && + slot_snapshot(1).state.button_north, + "remapped ordinary physical index must isolate input and feedback from both pairs"); +} + +void test_switch2_admission() { + start_backend(); + require(!switch_pico_switch2_pairing_allowed(), + "idle scanning must not grant fresh proprietary pairing"); + bluepad32_input_backend_open_pairing_window(); + require(!switch_pico_switch2_pairing_allowed(), + "pending Core0 request must not grant pairing before policy consumes it"); + process_rumble_timer(&g_rumble_timer); + require(switch_pico_switch2_pairing_allowed(), + "consumed explicit pairing window must permit proprietary pairing"); + now_ms = g_pairing_window_deadline_ms; + require(!switch_pico_switch2_pairing_allowed(), + "fresh pairing must close at its deadline even before timer processing"); + uni_hid_device_t pro = switch2_device(0, UNI_SW2_PRO_PID); + uni_hid_device_t ordinary = device(1, true, UNI_BT_CONN_PROTOCOL_BLE); + register_lookup_device(&pro); + register_lookup_device(&ordinary); + __wrap_sm_request_pairing(pro.conn.handle); + require(device_disconnect_calls == 1 && last_disconnected_device == &pro && + ordinary_smp_requests == 0 && delete_key_calls == 0, + "Switch2 GATT auth failure must disconnect without SMP or bond deletion"); + __wrap_sm_request_pairing(ordinary.conn.handle); + __wrap_sm_request_pairing(0x99); + require(ordinary_smp_requests == 2 && device_disconnect_calls == 1, + "ordinary and unknown handles must preserve standard SMP behavior"); + require(identity_for_device(&pro).stable && + controller_identity_is_global(identity_for_device(&ordinary)), + "only parser-validated proprietary public identity may bypass SMP resolution"); + pro.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM; + pro.conn.btaddr[0] = 0xc1; + require(identity_for_device(&pro).stable && + identity_for_device(&pro).address_type == BD_ADDR_TYPE_LE_RANDOM, + "parser-validated static random identity must retain its address type"); + pro.switch2_identity_valid = false; + pro.conn.btaddr[0] = 0x41; + require(controller_identity_is_global(identity_for_device(&pro)), + "unvalidated proprietary RPA must remain on the global profile"); + pro.switch2_identity_valid = true; + pro.conn.btaddr[0] = 0xc1; + ready_switch2(pro); + uni_controller_t input{}; + input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + input.gamepad.buttons = BUTTON_B; + input.gamepad.axis_x = 511; + input.gamepad.axis_ry = -512; + pro.switch2_extra_buttons = + UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_GL | UNI_SW2_BUTTON_GR; + platform_on_controller_data(&pro, &input); + require(slot_snapshot(0).state.button_east && + slot_snapshot(0).state.left_stick_x == INT16_MAX && + slot_snapshot(0).state.right_stick_y == INT16_MIN && + slot_snapshot(0).state.extra_buttons == 7, + "Pro2 must preserve vertical normal controls and ingest remappable extras"); +} + +void test_switch2_pairing_inventory() { + start_pairing_backend(); + switch2_pairing_count = 2; + switch2_pairing_types[0] = BD_ADDR_TYPE_LE_PUBLIC; + switch2_pairing_types[1] = BD_ADDR_TYPE_LE_RANDOM; + switch2_pairings[0][5] = 1; + switch2_pairings[1][0] = 0xc1; + switch2_pairings[1][5] = 2; + bluepad32_input_backend_request_pairing_snapshot(); + process_rumble_timer(&g_rumble_timer); + Bluepad32PairingSnapshot snapshot{}; + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.record_count == 2 && !snapshot.overflow && + snapshot.records[0].transport == Bluepad32PairingTransport::kBle && + snapshot.records[0].address_type == BD_ADDR_TYPE_LE_PUBLIC && + snapshot.records[0].address[5] == 1 && + snapshot.records[1].address_type == BD_ADDR_TYPE_LE_RANDOM && + snapshot.records[1].address[0] == 0xc1, + "pairing inventory must include proprietary trust records with real BLE address types"); + switch2_pairing_count = UNI_SWITCH2_PAIRING_CAPACITY; + classic_bond_count = 1; + bluepad32_input_backend_request_pairing_snapshot(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.record_count == BLUEPAD32_PAIRING_RECORD_CAPACITY && + snapshot.overflow, + "combined ordinary and proprietary inventory must preserve bounded overflow reporting"); + const uint32_t successful_token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.record_count == 0 && !snapshot.overflow && + switch2_pairing_count == 0 && + bluepad32_input_backend_clear_pairings_completed(snapshot, successful_token), + "clear completion must follow deletion of proprietary and ordinary pairing records"); + switch2_pairing_count = 1; + switch2_clear_succeeds = false; + const uint32_t failed_token = bluepad32_input_backend_clear_pairings(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed && + snapshot.completed_clear_pairings_token == successful_token && + !bluepad32_input_backend_clear_pairings_completed(snapshot, failed_token) && + !incoming_connections && !scanning_enabled && + !switch_pico_switch2_pairing_allowed(), + "failed trust deletion must never acknowledge clear or admit reconnects"); + bluepad32_input_backend_open_pairing_window(); + bluepad32_input_backend_request_pairing_snapshot(); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.status == Bluepad32PairingSnapshotStatus::kFailed && + snapshot.completed_clear_pairings_token == successful_token && + !switch_pico_switch2_pairing_allowed() && + g_connection_policy_state == ConnectionPolicyState::FailedClosed, + "inventory refresh and pairing requests must not erase failed-clear state"); + switch2_clear_succeeds = true; + const uint32_t retry_token = bluepad32_input_backend_clear_pairings(); + require(retry_token != failed_token, "explicit retry must receive a fresh clear token"); + process_rumble_timer(&g_rumble_timer); + bluepad32_input_backend_pairing_snapshot(&snapshot); + require(snapshot.status == Bluepad32PairingSnapshotStatus::kReady && + snapshot.record_count == 0 && + bluepad32_input_backend_clear_pairings_completed(snapshot, retry_token) && + incoming_connections && scanning_enabled && + !switch_pico_switch2_pairing_allowed(), + "successful explicit retry must restore normal reconnect policy, not fresh pairing"); +} + void test_ready_order(bool reverse) { start_pairing_backend(); uni_hid_device_t devices[kSlotCount] = { @@ -2837,7 +3262,17 @@ int main(int argc, char** argv) { return 0; } #endif - if (scenario == "ready-forward") { + if (scenario == "switch2-forward") { + test_switch2_pair_lifecycle(false); + } else if (scenario == "switch2-reverse") { + test_switch2_pair_lifecycle(true); + } else if (scenario == "switch2-multiple-pairs") { + test_switch2_multiple_pairs(); + } else if (scenario == "switch2-admission") { + test_switch2_admission(); + } else if (scenario == "switch2-pairing-inventory") { + test_switch2_pairing_inventory(); + } else if (scenario == "ready-forward") { test_ready_order(false); } else if (scenario == "ready-reverse") { test_ready_order(true); diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index d64b47f..e4390b1 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -163,6 +163,9 @@ struct uni_hid_device_s { int player_led_calls; uint8_t player_leds; uni_circular_buffer_t outgoing_buffer; + uint8_t switch2_extra_buttons; + bool switch2_identity_valid; + uint8_t switch2_identity_address_type; }; void uni_hid_parser_xboxone_play_dual_rumble( diff --git a/tests/controller_profile_legacy_fixtures.h b/tests/controller_profile_legacy_fixtures.h index 3cc9cd7..83318ea 100644 --- a/tests/controller_profile_legacy_fixtures.h +++ b/tests/controller_profile_legacy_fixtures.h @@ -61,3 +61,32 @@ constexpr uint8_t kLegacyCustomThresholdProfile[CONTROLLER_PROFILE_LEGACY_ENCODE 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; + +// Schema6 fixture with analog tuning, all macro playback modes, shortcuts, +// Shift, and sparse Turbo overrides. Independent of the current encoder. +constexpr uint8_t kLegacySchema6Profile[384] = { + 0x06, 0x00, 0x80, 0x01, 0x11, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0xff, 0x7b, 0x00, 0x38, 0xfe, 0xe8, 0x03, 0x30, 0x75, 0x80, 0x01, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xeb, 0xfc, 0x41, 0x01, 0xd0, 0x07, 0x18, 0x79, 0xc0, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xe8, 0x03, 0x60, 0xea, 0x80, 0x01, 0x34, 0x12, 0x00, 0x00, 0xd0, 0x07, + 0xe8, 0xfd, 0xc0, 0x00, 0xcd, 0xab, 0x10, 0x00, 0x7b, 0xd2, 0x01, 0x21, 0x41, 0x00, 0x80, 0x00, + 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, 0x00, 0x01, 0x02, 0x03, + 0x01, 0x00, 0x41, 0x00, 0x01, 0x11, 0x02, 0x00, 0x46, 0x11, 0x01, 0x11, 0x04, 0x00, 0x24, 0x22, + 0x01, 0x11, 0x08, 0x00, 0x7c, 0x33, 0x01, 0x11, 0x1f, 0x19, 0x00, 0x01, 0x00, 0xfe, 0xff, 0x03, + 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1a, 0x00, 0x02, 0x00, 0xfe, 0xff, + 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1b, 0x00, 0x04, 0x00, 0xfe, + 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x1f, 0x1c, 0x00, 0x08, 0x00, + 0xfe, 0xff, 0x03, 0x00, 0xfc, 0xff, 0x05, 0x00, 0x34, 0x12, 0xcd, 0xab, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02, 0x11, 0xff, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x1e, 0x63, 0xff, 0x04, 0x80, + 0x01, 0x01, 0x01, 0x17, 0x25, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0x01, 0xfe, 0x02, 0xfd, 0x03, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index f105b94..ba3597d 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -1160,6 +1160,61 @@ void test_shortcut_selector_rollover_without_modifier_release() { "swapping shortcut selectors while holding the modifier lost the new action"); } +void test_extra_hotkeys_consume_mappings_and_rearm() { + prepare_profiles(); + auto& profile = rows[0].profiles[0]; + profile.switching_chord = (1u << 18) | (1u << 24); + profile.motion_toggle_chord = (1u << 19) | (1u << 16); + profile.shortcuts.modifier = 20; + profile.shortcuts.selectors[7] = 0; + profile.extra_button_map[0] = 3; + profile.extra_button_map[1] = 1; + profile.extra_button_map[2] = 2; + profile.extra_button_map[6] = 17; + Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9); + (void)runtime_transform(0, snapshot, 0); + snapshot.state.extra_buttons = 1; + auto output = runtime_transform(0, snapshot, 1); + require(activation_attempt_count == 0 && output.state.button_north, + "partial extra switching chord was swallowed or activated"); + snapshot.state.extra_buttons = 0x41; + output = runtime_transform(0, snapshot, 2); + require(activation_attempt_count == 1 && + activation_attempts[0].profile_index == 1 && + controller_profile_extract_button_mask(output.state) == 0 && + output.state.right_trigger == 0 && output.state.extra_buttons == 0, + "extra switching chord did not select the next profile and consume mappings"); + (void)runtime_transform(0, snapshot, 3); + require(activation_attempt_count == 1, "held extra switching chord retriggered"); + snapshot.state = controller_neutral_state(); + (void)runtime_transform(0, snapshot, 4); + snapshot.state.extra_buttons = 2; + snapshot.state.left_trigger = UINT16_MAX; + output = runtime_transform(0, snapshot, 5); + require(motion_toggle_count == 1 && last_motion_toggle_slot == 0 && + last_motion_toggle_connection_generation == 9 && + !output.state.button_east && output.state.left_trigger == 0, + "extra/trigger motion chord lost its logical slot or leaked mapped inputs"); + (void)runtime_transform(0, snapshot, 6); + require(motion_toggle_count == 1, "held extra motion chord retriggered"); + snapshot.state = controller_neutral_state(); + (void)runtime_transform(0, snapshot, 7); + apply_button_mask(1, &snapshot); + snapshot.state.extra_buttons = 4; + output = runtime_transform(0, snapshot, 8); + require(activation_attempt_count == 2 && + activation_attempts[1].profile_index == 7 && + controller_profile_extract_button_mask(output.state) == 0, + "extra shortcut modifier failed to select profile eight or leaked inputs"); + apply_button_mask(0, &snapshot); + (void)runtime_transform(0, snapshot, 9); + snapshot.state.extra_buttons = 0x41; + (void)runtime_transform(0, snapshot, 10); + require(activation_attempt_count == 3 && + activation_attempts[2].profile_index == 1, + "released extra switching chord did not rearm"); +} + } // namespace bool bluepad32_input_backend_toggle_motion( @@ -1232,5 +1287,6 @@ int main() { test_shift_slot_and_context_resets(); test_held_synthetic_sources_and_disconnect_rearming(); test_shortcut_selector_rollover_without_modifier_release(); + test_extra_hotkeys_consume_mappings_and_rearm(); return 0; } diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index ee0a187..b52a3e0 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -45,9 +45,9 @@ void test_profile_wire_schema() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(profile, encoded, sizeof(encoded)), "default profile did not encode"); - require(encoded[0] == 6 && encoded[1] == 0 && + require(encoded[0] == 7 && encoded[1] == 0 && encoded[2] == 0x80 && encoded[3] == 1, - "profile header is not little-endian v6/384"); + "profile header is not little-endian v7/384"); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { require(encoded[4 + index] == index, @@ -135,7 +135,7 @@ void test_profile_wire_schema() { "nonzero reserved profile byte was accepted"); ControllerProfile invalid = profile; - invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; + invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; require(!controller_profile_validate(invalid), "invalid direct output was accepted"); invalid = profile; @@ -225,7 +225,7 @@ void test_legacy_profile_migration() { CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL, - "migrated default profile did not encode as v6"); + "migrated default profile did not encode as the current schema"); require(controller_profile_decode( kLegacyNarrowRawRangeProfile, @@ -300,7 +300,7 @@ void test_legacy_profile_migration() { static_cast( ControllerProfileLogicalButton::kCapture) && migrated.motion_toggle_chord == 0, - "v2 profile controls did not migrate to v6"); + "v2 profile controls did not migrate to the current schema"); previous_encoded[80] = 2; previous_encoded[100] = 0; previous_encoded[101] = kControllerProfileOverrideButtons; @@ -508,6 +508,101 @@ void test_legacy_database_strides() { } } +void test_schema6_migration_preserves_every_setting() { + ControllerProfile profile{}; + uint8_t upgraded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_decode(kLegacySchema6Profile, + sizeof(kLegacySchema6Profile), &profile) && + controller_profile_encode(profile, upgraded, sizeof(upgraded)), + "schema6 profile could not upgrade"); + require(memcmp(upgraded + 2, kLegacySchema6Profile + 2, 342) == 0, + "schema6 migration changed an existing encoded setting"); + for (uint8_t index = 0; index < CONTROLLER_PROFILE_EXTRA_BUTTON_COUNT; ++index) { + require(profile.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON && + profile.shift.extra_button_map[index] == CONTROLLER_PROFILE_NO_BUTTON, + "schema6 padding became an extra-button mapping"); + } + ControllerProfile reloaded{}; + uint8_t round_trip[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_decode(upgraded, sizeof(upgraded), &reloaded) && + controller_profile_encode(reloaded, round_trip, sizeof(round_trip)) && + memcmp(upgraded, round_trip, sizeof(upgraded)) == 0, + "upgraded schema6 profile was not stable after reload"); + + const uint16_t extra_field_offsets[] = {344, 351, 358, 362, 363}; + for (uint16_t offset : extra_field_offsets) { + uint8_t invalid[sizeof(kLegacySchema6Profile)]{}; + memcpy(invalid, kLegacySchema6Profile, sizeof(invalid)); + invalid[offset] = 1; + require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded), + "schema6 interpreted an extra-control field in reserved padding"); + } + const uint16_t source_offsets[] = {256, 266}; + for (uint16_t offset : source_offsets) { + uint8_t invalid[sizeof(kLegacySchema6Profile)]{}; + memcpy(invalid, kLegacySchema6Profile, sizeof(invalid)); + invalid[offset] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; + require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded), + "schema6 admitted an extra-control modifier"); + } + uint8_t invalid[sizeof(kLegacySchema6Profile)]{}; + memcpy(invalid, kLegacySchema6Profile, sizeof(invalid)); + invalid[98] = (CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL << 2) | 1; + require(!controller_profile_decode(invalid, sizeof(invalid), &reloaded), + "schema6 admitted an extra-control macro cancellation"); +} + +void test_extra_control_schema_round_trip_and_output_limits() { + ControllerProfile profile{}; + require(controller_profile_decode(kLegacySchema6Profile, + sizeof(kLegacySchema6Profile), &profile), + "extra-control fixture did not decode"); + const uint8_t extra_map[] = {0, 16, 17, 12, 13, 14, 15}; + const uint8_t shifted_map[] = {15, 14, 13, 12, 3, 2, 0xff}; + memcpy(profile.extra_button_map, extra_map, sizeof(extra_map)); + memcpy(profile.shift.extra_button_map, shifted_map, sizeof(shifted_map)); + profile.shortcuts.modifier = 18; + profile.shift.modifier = 24; + profile.switching_chord |= 0x55u << 18; + profile.motion_toggle_chord |= 0x2au << 18; + for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].trigger_mask |= (1u << index) << 18; + profile.macros[index].cancel_control = 24 - index; + } + uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + ControllerProfile decoded{}; + require(controller_profile_encode(profile, encoded, sizeof(encoded)) && + controller_profile_decode(encoded, sizeof(encoded), &decoded), + "extra-control profile did not round-trip"); + require(memcmp(encoded + 344, extra_map, sizeof(extra_map)) == 0 && + memcmp(encoded + 351, shifted_map, sizeof(shifted_map)) == 0 && + encoded[358] == 1 && encoded[359] == 2 && + encoded[360] == 4 && encoded[361] == 8 && + encoded[362] == 0x55 && encoded[363] == 0x2a, + "extra-control fields do not use the schema7 extension layout"); + uint8_t round_trip[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode(decoded, round_trip, sizeof(round_trip)) && + memcmp(encoded, round_trip, sizeof(encoded)) == 0, + "extra controls lost masks, mappings, or modifiers on reload"); + for (uint16_t offset = 358; offset < 364; ++offset) { + encoded[offset] |= 0x80; + require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), + "out-of-range extra-control mask was accepted"); + encoded[offset] &= 0x7f; + } + profile.extra_button_map[0] = 18; + require(!controller_profile_validate(profile), + "extra input was accepted as a console output destination"); + profile.extra_button_map[0] = 0; + profile.triggers[0].output = 18; + require(!controller_profile_validate(profile), + "analog trigger was allowed to route into a source-only control"); + profile.triggers[0].output = 0; + profile.shift.extra_button_map[0] = 16; + require(!controller_profile_validate(profile), + "Shift extra mapping admitted an analog destination"); +} + } // namespace int main() { test_profile_wire_schema(); @@ -515,5 +610,7 @@ int main() { test_database_round_trip_and_capacity(); test_set_b_sparse_extension_and_migration(); test_legacy_database_strides(); + test_schema6_migration_preserves_every_setting(); + test_extra_control_schema_round_trip_and_output_limits(); return 0; } diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp index 5f012e5..7607070 100644 --- a/tests/controller_profile_transform_test.cpp +++ b/tests/controller_profile_transform_test.cpp @@ -36,6 +36,7 @@ bool states_equal(const ControllerState& left, const ControllerState& right) { left.button_capture == right.button_capture && left.button_left_stick == right.button_left_stick && left.button_right_stick == right.button_right_stick && + left.extra_buttons == right.extra_buttons && left.left_trigger == right.left_trigger && left.right_trigger == right.right_trigger && left.left_stick_x == right.left_stick_x && @@ -109,7 +110,7 @@ void test_button_masks_and_direct_mapping() { "disabled button mapping still produced output"); ControllerProfile invalid = default_profile(); - invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; + invalid.button_map[0] = CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL; require(!controller_profile_validate(invalid), "logical output 18 was accepted"); invalid.button_map[0] = 0xfe; @@ -432,6 +433,48 @@ void test_rumble_scaling_and_confirmation_policy() { "confirmation policy was not exposed unchanged"); } +void test_extra_sources_route_without_creating_output_channels() { + ControllerProfile profile = default_profile(); + ControllerState input{}; + input.extra_buttons = 0x7f; + require(states_equal( + controller_profile_transform(input, profile).state, + controller_neutral_state()), + "unmapped extra inputs leaked into console output"); + require(controller_profile_extract_control_mask(input, profile) == + (0x7fu << CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL), + "extra inputs were omitted or overlapped the trigger controls"); + profile.extra_button_map[0] = 0; + profile.extra_button_map[1] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.extra_button_map[2] = CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + profile.extra_button_map[3] = 12; + profile.extra_button_map[4] = 13; + profile.extra_button_map[5] = 14; + profile.extra_button_map[6] = 15; + input.button_south = true; + input.left_trigger = 12345; + auto output = controller_profile_transform(input, profile); + require(controller_profile_extract_button_mask(output.state) == 0xf001 && + output.state.left_trigger == UINT16_MAX && + output.state.right_trigger == UINT16_MAX && + output.state.extra_buttons == 0, + "extra mappings lost rail buttons, trigger output, or shared contributors"); + controller_profile_remove_control_mask( + (1u << 18) | (1u << 20) | (1u << 24) | (1u << 16), &input); + output = controller_profile_transform(input, profile); + require(controller_profile_extract_button_mask(output.state) == 0x7001 && + output.state.left_trigger == UINT16_MAX && + output.state.right_trigger == 0 && + input.extra_buttons == 0x3a && input.left_trigger == 0, + "consumed extra sources leaked, removed a shared button, or lost another trigger source"); + input.extra_buttons = 0x80; + input.button_south = false; + require(controller_profile_extract_control_mask(input, profile) == 0 && + states_equal(controller_profile_transform(input, profile).state, + controller_neutral_state()), + "reserved extra bit became a control or output"); +} + } // namespace int main() { @@ -440,6 +483,7 @@ int main() { test_stick_curves_and_monotonicity(); test_trigger_boundaries_curves_and_thresholds(); test_trigger_and_button_cross_mapping(); + test_extra_sources_route_without_creating_output_channels(); test_default_whole_state_equivalence(); test_rumble_scaling_and_confirmation_policy(); return 0; diff --git a/tests/controller_synthetic_input_test.cpp b/tests/controller_synthetic_input_test.cpp index 352c6ee..81b6bae 100644 --- a/tests/controller_synthetic_input_test.cpp +++ b/tests/controller_synthetic_input_test.cpp @@ -738,6 +738,81 @@ void test_macro_playback_modes_and_bounded_cycle_skips() { "configured cancel did not kill looping macro output"); } +void test_extra_shift_and_macro_sources_are_consumed() { + ControllerProfile profile = + controller_profile_default(controller_identity_global(), 0); + profile.shift.mode = ControllerProfileShiftMode::kHold; + profile.shift.modifier = 18; + profile.extra_button_map[0] = 3; + profile.shift.extra_button_map[0] = 3; + profile.extra_button_map[1] = CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.shift.extra_button_map[1] = 1; + ControllerSyntheticInputContext context{}; + ControllerState input{}; + input.extra_buttons = 3; + auto output = controller_synthetic_input_apply(&context, input, profile, 0); + require(output.state.button_east && !output.state.button_north && + output.state.left_trigger == 0 && output.state.extra_buttons == 0, + "extra Shift modifier leaked or failed to select the extra mapping layer"); + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 1); + require(!output.state.button_east && output.state.left_trigger == UINT16_MAX, + "extra hold-Shift release did not restore the base mapping"); + profile.shift.mode = ControllerProfileShiftMode::kToggle; + context = {}; + input.extra_buttons = 3; + (void)controller_synthetic_input_apply(&context, input, profile, 2); + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 3); + require(output.state.button_east && output.state.left_trigger == 0, + "extra toggle-Shift did not latch after modifier release"); + input.extra_buttons = 3; + output = controller_synthetic_input_apply(&context, input, profile, 4); + require(!output.state.button_east && !output.state.button_north && + output.state.left_trigger == UINT16_MAX, + "inactive toggle layer leaked its extra modifier"); + + profile.shift.mode = ControllerProfileShiftMode::kOff; + profile.macros[0] = {(1u << 19) | (1u << 24), 20, 0, 1, + ControllerProfileMacroMode::kWhileHeld, 1}; + profile.macro_step_count = 1; + profile.macro_steps[0] = {kControllerProfileOverrideRightTrigger, 100, + 0, 0, 0, 0, 0, 0, 45000}; + for (uint8_t index = 1; index < CONTROLLER_PROFILE_MACRO_COUNT; ++index) { + profile.macros[index].first_step = 1; + } + profile.extra_button_map[2] = 3; + profile.extra_button_map[6] = 0; + context = {}; + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 5); + require(output.state.left_trigger == UINT16_MAX && output.state.right_trigger == 0, + "partial extra macro chord activated"); + input.extra_buttons = 0x42; + output = controller_synthetic_input_apply(&context, input, profile, 6); + require(output.state.left_trigger == 0 && output.state.right_trigger == 45000 && + !output.state.button_south && output.state.extra_buttons == 0, + "extra macro trigger chord leaked its mapped outputs"); + input.extra_buttons = 0x46; + output = controller_synthetic_input_apply(&context, input, profile, 7); + require(output.state.right_trigger == 0 && !output.state.button_north, + "extra macro cancellation did not win or leaked its mapped output"); + input.extra_buttons = 0x42; + output = controller_synthetic_input_apply(&context, input, profile, 8); + require(output.state.right_trigger == 0, + "cancel release retriggered an already-held extra macro chord"); + input.extra_buttons = 0; + (void)controller_synthetic_input_apply(&context, input, profile, 9); + input.extra_buttons = 0x42; + output = controller_synthetic_input_apply(&context, input, profile, 10); + require(output.state.right_trigger == 45000, + "extra macro trigger did not rearm after release"); + input.extra_buttons = 2; + output = controller_synthetic_input_apply(&context, input, profile, 11); + require(output.state.right_trigger == 0, + "while-held macro survived release of an extra trigger source"); +} + } // namespace int main() { @@ -753,5 +828,6 @@ int main() { test_parameterized_turbo_and_finite_burst(); test_shift_maps_consumption_and_physical_bindings(); test_macro_playback_modes_and_bounded_cycle_skips(); + test_extra_shift_and_macro_sources_are_consumed(); return 0; } diff --git a/tests/profile_playtest_test.js b/tests/profile_playtest_test.js index dd7bc49..e861f36 100644 --- a/tests/profile_playtest_test.js +++ b/tests/profile_playtest_test.js @@ -63,3 +63,53 @@ assert.ok(triggerPercent(65000) > 99, "nonzero trigger input must produce a visible bar"); assert.equal(triggerPercent(0), 0); assert.equal(triggerPercent(65535), 100); + +const { transformMappings } = globalThis.ProfilePlaytestMath; +const buttons = [ + "south", "east", "west", "north", "left_shoulder", "right_shoulder", + "select", "start", "system", "capture", "left_stick", "right_stick", + "dpad_up", "dpad_down", "dpad_left", "dpad_right", +]; +const extras = ["c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr"]; +const profile = { + button_map: Object.fromEntries(buttons.map((button) => [button, button])), + extra_button_map: Object.fromEntries(extras.map((button) => [button, null])), + shift: { + mode: "off", modifier: null, + button_map: Object.fromEntries(buttons.map((button) => [button, button])), + extra_button_map: Object.fromEntries(extras.map((button) => [button, null])), + }, + triggers: { + left: { ...defaultTrigger, output: "left_trigger", digital_threshold: 22934 }, + right: { ...defaultTrigger, output: "right_trigger", digital_threshold: 22934 }, + }, +}; +const sample = { buttons: ["south"], extra_buttons: extras, triggers: { left: 123, right: 50000 } }; +assert.deepEqual(transformMappings(sample, profile), { + buttons: ["south"], triggers: { left: 123, right: 50000 }, +}); +for (const [index, extra] of extras.entries()) { + profile.extra_button_map[extra] = buttons[index + 1]; +} +assert.deepEqual(transformMappings(sample, profile).buttons, buttons.slice(0, 8)); +profile.extra_button_map.c = "left_trigger"; +profile.extra_button_map.gl = "south"; +assert.deepEqual(transformMappings(sample, profile), { + buttons: ["south", ...buttons.slice(3, 8)], + triggers: { left: 65535, right: 50000 }, +}); +profile.shift.mode = "hold"; +profile.shift.modifier = "c"; +profile.shift.extra_button_map.gr = "system"; +assert.deepEqual(transformMappings(sample, profile, true), { + buttons: ["south", "system"], triggers: { left: 123, right: 50000 }, +}); +assert.equal(transformMappings(sample, profile, false).triggers.left, 123, + "the Shift modifier is consumed even on the base layer"); +assert.deepEqual(transformMappings({ ...sample, extra_buttons: [] }, profile), { + buttons: ["south"], triggers: { left: 123, right: 50000 }, +}); +profile.shift.mode = "off"; +profile.triggers.right.output = "north"; +assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22933 } }, profile).buttons, []); +assert.deepEqual(transformMappings({ ...sample, buttons: [], extra_buttons: [], triggers: { left: 0, right: 22934 } }, profile).buttons, ["north"]); diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 304b196..1ca5917 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -277,7 +277,7 @@ void test_profile_bounds_and_transaction_namespace() { "transaction namespaces were not enforced"); } -void test_schema6_validation_and_atomic_selection() { +void test_schema7_validation_and_atomic_selection() { const ControllerIdentity id = stable_identity(); require(profile_service_select(id, 6) == ConfigurationTransactionStatus::kCommitted, @@ -285,10 +285,13 @@ void test_schema6_validation_and_atomic_selection() { ProfileServiceSelectedSnapshot old_selection{}; profile_service_selected_snapshot(&old_selection); ControllerProfile updated = old_selection.profile; - updated.shortcuts.modifier = 5; + updated.shortcuts.modifier = 18; updated.shortcuts.selectors[6] = 14; updated.turbo_defaults = {30, 1, 255}; - updated.macros[0].trigger_mask = 1u << 10; + updated.extra_button_map[6] = 16; + updated.shift.extra_button_map[0] = 3; + updated.macros[0].trigger_mask = 1u << 24; + updated.macros[0].cancel_control = 19; updated.macros[0].step_count = 1; updated.macros[0].mode = ControllerProfileMacroMode::kRepeat; updated.macros[0].repeat_count = 255; @@ -299,25 +302,25 @@ void test_schema6_validation_and_atomic_selection() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(updated, encoded, sizeof(encoded)), "extended service profile did not encode"); - require(profile_service_begin(20, id, 6, 5, 256, 0) == + require(profile_service_begin(20, id, 6, 6, 384, 0) == ConfigurationTransactionStatus::kUnsupportedSchema && - profile_service_begin(21, id, 6, 6, 256, 0) == + profile_service_begin(21, id, 6, 7, 256, 0) == ConfigurationTransactionStatus::kMalformed && - profile_service_begin(22, id, 6, 6, 385, 0) == + profile_service_begin(22, id, 6, 7, 385, 0) == ConfigurationTransactionStatus::kTooLarge, "service admitted old-schema or incorrectly-sized writes"); // A valid transport CRC cannot authorize an invalid extension. encoded[283] = 0; require(profile_service_begin( - 23, id, 6, 6, sizeof(encoded), + 23, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), profile_storage_crc32(encoded, sizeof(encoded))) == ConfigurationTransactionStatus::kReceiving && profile_service_append(23, 0, encoded, sizeof(encoded)) == ConfigurationTransactionStatus::kReceiving && profile_service_commit(23) == ConfigurationTransactionStatus::kMalformed, - "service admitted invalid schema6 turbo settings"); + "service admitted invalid turbo settings"); ProfileServiceSelectedSnapshot selected{}; profile_service_selected_snapshot(&selected); require(selected.valid && @@ -329,7 +332,7 @@ void test_schema6_validation_and_atomic_selection() { require(controller_profile_encode(updated, encoded, sizeof(encoded)), "valid replacement did not encode"); require(profile_service_begin( - 24, id, 6, 6, sizeof(encoded), + 24, id, 6, CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), profile_storage_crc32(encoded, sizeof(encoded))) == ConfigurationTransactionStatus::kReceiving && profile_service_append(24, 0, encoded, 256) == @@ -338,7 +341,7 @@ void test_schema6_validation_and_atomic_selection() { ConfigurationTransactionStatus::kReceiving && profile_service_commit(24) == ConfigurationTransactionStatus::kPending, - "service did not receive both parts of the schema6 payload"); + "service did not receive both parts of the profile payload"); profile_service_selected_snapshot(&selected); require(selected.metadata.generation == old_selection.metadata.generation && active_snapshot(id).profile.turbo_defaults.rate_hz == @@ -350,8 +353,12 @@ void test_schema6_validation_and_atomic_selection() { ConfigurationTransactionStatus::kCommitted && selected.valid && selected.profile.shortcuts.selectors[6] == 14 && selected.profile.macros[0].repeat_count == 255 && + selected.profile.extra_button_map[6] == 16 && + selected.profile.shift.extra_button_map[0] == 3 && + active_snapshot(id).profile.macros[0].trigger_mask == (1u << 24) && + active_snapshot(id).profile.macros[0].cancel_control == 19 && active_snapshot(id).profile.turbo_defaults.rate_hz == 30, - "committed schema6 profile did not atomically refresh snapshots"); + "committed schema7 profile did not atomically refresh snapshots"); } void test_catalog1_selected_and_active_snapshots_migrate() { @@ -426,7 +433,7 @@ ProfileStorageIo pico_profile_storage_io() { return fake_io(); } int main() { test_eight_profile_transactions_and_active_cache(); test_profile_bounds_and_transaction_namespace(); - test_schema6_validation_and_atomic_selection(); + test_schema7_validation_and_atomic_selection(); test_catalog1_selected_and_active_snapshots_migrate(); std::cout << "profile service tests passed\n"; return 0; diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index 4e01713..8436436 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -259,7 +259,9 @@ void install_catalog_record(uint16_t version, size_t offset, uint8_t type, record[7] = slot; write_u32(record + 8, generation); write_u16(record + 12, static_cast(size)); - write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0); + write_u16(record + 14, type == 1 + ? static_cast(payload[0] | (payload[1] << 8)) + : 0); write_u32(record + 16, profile_storage_crc32(payload, size)); require(controller_identity_encode(identity_value, record + 20, CONTROLLER_IDENTITY_ENCODED_SIZE), @@ -284,6 +286,9 @@ void install_populated_catalog(uint16_t version, bool fill_arena = false) { } else { require(controller_profile_encode(profile, payload, sizeof(payload)), "catalog2 fixture profile did not encode"); + // Keep catalog2 fixtures on the deployed schema6 wire format. + write_u16(payload, CONTROLLER_PROFILE_EXPANDED_SCHEMA_VERSION); + memset(payload + 344, 0, sizeof(payload) - 344); } install_catalog_record( version, offset, 1, id, slot, ++generation, payload, @@ -494,6 +499,8 @@ void test_compaction_preserves_latest_records() { storage.set_profile_name(global, 0, "Compacted", 9) == ProfileStorageResult::kOk, "compaction metadata did not append"); + profile.extra_button_map[0] = 16; + profile.shift.extra_button_map[6] = 15; for (uint16_t write = 1; write <= 260; ++write) { profile.weak_rumble_scale = static_cast(write); require(storage.set(global, 0, profile) == ProfileStorageResult::kOk, @@ -508,6 +515,8 @@ void test_compaction_preserves_latest_records() { reloaded.get(global, 0, &recovered) == ProfileStorageResult::kOk && recovered.weak_rumble_scale == static_cast(260) && + recovered.extra_button_map[0] == 16 && + recovered.shift.extra_button_map[6] == 15 && reloaded.get_alias(global, metadata, sizeof(metadata)) == ProfileStorageResult::kOk && strcmp(metadata, "Fallback") == 0 && @@ -727,6 +736,49 @@ void test_legacy_high_generation_remains_mutable() { "high-generation update was lost after reload"); } +void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() { + erase_all(); + install_populated_catalog(2); + ProfileStorage storage; + require(storage.initialize(fake_io()), "schema6 catalog did not load"); + require_populated_catalog(storage, true); + require(flash.programs == 0 && flash.erases == 0, + "reading schema6 profiles rewrote the published flash arena"); + const auto id = catalog_identity(7); + ControllerProfile profile{}; + uint8_t before[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(storage.get(id, 7, &profile) == ProfileStorageResult::kOk && + controller_profile_encode(profile, before, sizeof(before)) && + storage.set(id, 7, profile) == ProfileStorageResult::kUnchanged && + flash.programs == 0 && flash.erases == 0, + "unchanged migrated profile caused an unnecessary flash write"); + profile.extra_button_map[0] = 16; + profile.extra_button_map[6] = 15; + profile.shift.extra_button_map[1] = 2; + require(storage.set(id, 7, profile) == ProfileStorageResult::kOk, + "schema6 profile could not add extra-control mappings"); + ProfileStorage reloaded; + ControllerProfile recovered{}; + uint8_t after[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(reloaded.initialize(fake_io()) && + reloaded.get(id, 7, &recovered) == ProfileStorageResult::kOk && + controller_profile_encode(recovered, after, sizeof(after)) && + memcmp(before + 2, after + 2, 342) == 0 && + recovered.extra_button_map[0] == 16 && + recovered.extra_button_map[6] == 15 && + recovered.shift.extra_button_map[1] == 2, + "editing migrated extras lost existing settings or the new mappings"); + char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + require(reloaded.find(id)->active_profile == 7 && + reloaded.get_alias(id, metadata, sizeof(metadata)) == + ProfileStorageResult::kOk && + strcmp(metadata, "H") == 0 && + reloaded.get_profile_name(id, 7, metadata, sizeof(metadata)) == + ProfileStorageResult::kOk && + strcmp(metadata, "H7") == 0, + "editing a schema6 profile lost its active index, alias, or name"); +} + } // namespace int main() { @@ -742,6 +794,7 @@ int main() { test_retired_bank_migration_power_loss(); test_late_second_page_program_is_not_reused(); test_unreadable_legacy_data_is_not_erased(); + test_schema6_read_migration_is_lazy_and_edit_preserves_metadata(); std::cout << "profile storage tests passed\n"; return 0; } diff --git a/tests/switch2_pairing_native_stubs/btstack_tlv.h b/tests/switch2_pairing_native_stubs/btstack_tlv.h new file mode 100644 index 0000000..fd57a65 --- /dev/null +++ b/tests/switch2_pairing_native_stubs/btstack_tlv.h @@ -0,0 +1,8 @@ +#pragma once +#include +typedef struct { + int (*get_tag)(void*, uint32_t, uint8_t*, uint32_t); + int (*store_tag)(void*, uint32_t, const uint8_t*, uint32_t); + void (*delete_tag)(void*, uint32_t); +} btstack_tlv_t; +void btstack_tlv_get_instance(const btstack_tlv_t**, void**); diff --git a/tests/switch2_pairing_test.c b/tests/switch2_pairing_test.c new file mode 100644 index 0000000..04a028d --- /dev/null +++ b/tests/switch2_pairing_test.c @@ -0,0 +1,90 @@ +#include +#include +#include +#include +#include +#include "parser/uni_switch2_pairing.h" + +static struct { + uint32_t tag; + uint8_t data[256]; + uint32_t size; + bool fail_write; +} storage; + +static int get_tag(void* context, uint32_t tag, uint8_t* data, uint32_t capacity) { + (void)context; + if (tag != storage.tag) return 0; + uint32_t copied = storage.size < capacity ? storage.size : capacity; + memcpy(data, storage.data, copied); + return (int)storage.size; +} +static int store_tag(void* context, uint32_t tag, const uint8_t* data, uint32_t size) { + (void)context; + if (storage.fail_write) return -1; + assert(size <= sizeof(storage.data)); + storage.tag = tag; + memcpy(storage.data, data, size); + storage.size = size; + return 0; +} +static const btstack_tlv_t tlv = {get_tag, store_tag, NULL}; +void btstack_tlv_get_instance(const btstack_tlv_t** implementation, void** context) { + *implementation = &tlv; + *context = &storage; +} + +int main(void) { + uint8_t public_address[6] = {0x10, 2, 3, 4, 5, 6}; + uint8_t static_address[6] = {0xc1, 2, 3, 4, 5, 6}; + uint8_t private_address[6] = {0x41, 2, 3, 4, 5, 6}; + assert(!uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_remember(1, private_address)); + assert(!uni_switch2_pairing_remember(2, public_address)); + assert(uni_switch2_pairing_remember(1, static_address)); + assert(uni_switch2_pairing_remember(0, public_address)); + assert(uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_known(1, public_address)); + assert(uni_switch2_pairing_known(1, static_address)); + + // Duplicate authorization remains valid without requiring another flash write. + storage.fail_write = true; + assert(uni_switch2_pairing_remember(0, public_address)); + assert(!uni_switch2_pairing_clear()); + assert(uni_switch2_pairing_known(0, public_address)); + public_address[5]++; + assert(!uni_switch2_pairing_remember(0, public_address)); + assert(!uni_switch2_pairing_known(0, public_address)); + public_address[5]--; + storage.fail_write = false; + + uint8_t type, observed[6]; + assert(uni_switch2_pairing_get(0, &type, observed)); + assert(type == 0 && memcmp(observed, public_address, 6) == 0); + assert(uni_switch2_pairing_get(1, &type, observed)); + assert(type == 1 && memcmp(observed, static_address, 6) == 0); + assert(!uni_switch2_pairing_get(2, &type, observed)); + assert(uni_switch2_pairing_clear()); + assert(!uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_known(1, static_address)); + + // Filling the bounded store never silently forgets an earlier controller. + for (uint8_t i = 0; i < UNI_SWITCH2_PAIRING_CAPACITY; ++i) { + public_address[5] = i; + assert(uni_switch2_pairing_remember(0, public_address)); + } + public_address[5] = UNI_SWITCH2_PAIRING_CAPACITY; + assert(!uni_switch2_pairing_remember(0, public_address)); + public_address[5] = 0; + assert(uni_switch2_pairing_known(0, public_address)); + + // Corrupt persisted data is not an authorization and can be explicitly cleared. + storage.data[1] = 255; + assert(!uni_switch2_pairing_known(0, public_address)); + assert(!uni_switch2_pairing_remember(0, public_address)); + assert(uni_switch2_pairing_clear()); + assert(uni_switch2_pairing_remember(0, public_address)); + storage.size++; + assert(!uni_switch2_pairing_known(0, public_address)); + return 0; +} diff --git a/tests/switch2_parser_native_stubs/protocol_fixture.h b/tests/switch2_parser_native_stubs/protocol_fixture.h new file mode 100644 index 0000000..1995458 --- /dev/null +++ b/tests/switch2_parser_native_stubs/protocol_fixture.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include "uni_hid_device.h" + +// The parser uses the SDK's actual GATT types/accessors. Only the asynchronous +// radio, run loop, platform admission and persistence endpoints are simulated. +enum fixture_query { QUERY_NONE, QUERY_SERVICE, QUERY_CHARACTERISTICS, QUERY_DESCRIPTORS, QUERY_CCCD, QUERY_WRITE }; +struct fixture_peer { + uni_hid_device_t device; + bool used, link_alive; + enum fixture_query query; + btstack_packet_handler_t callback; + uint16_t descriptor_value, cccd_handle; + uint8_t command[32]; + uint16_t command_length; + const uint8_t* pending_write; + uint16_t pending_length; + uint8_t rumble[33]; + uint16_t rumble_length; + unsigned commands, rumbles; +}; diff --git a/tests/switch2_parser_native_test.c b/tests/switch2_parser_native_test.c new file mode 100644 index 0000000..e6c2aab --- /dev/null +++ b/tests/switch2_parser_native_test.c @@ -0,0 +1,678 @@ +#include +#include +#include + +#include "protocol_fixture.h" +#include "parser/uni_hid_parser_switch2.h" +#include "parser/uni_switch2_pairing.h" + +#define PEERS 4 +#define SERVICE_START 0x100 +#define INPUT_HANDLE 0x104 +#define RESPONSE_HANDLE 0x114 +#define COMMAND_HANDLE 0x124 +#define RUMBLE_HANDLE 0x134 + +static struct fixture_peer peers[PEERS]; +static btstack_timer_source_t* timers[16]; +static unsigned timer_count, connected, ready, disconnected, emitted, remembered, listeners; +static unsigned connected_events, disconnected_events; +static uint32_t now_ms; +static bool pairing_allowed, trusted, storage_ok, request_writes, admit, reject_connected; +static uint8_t next_write_error; +static const bd_addr_t host_address = {0x10, 0x21, 0x32, 0x43, 0x54, 0x65}; +static const bd_addr_t controller_address = {0xc0, 0x22, 0x33, 0x44, 0x55, 0x66}; +static const uint8_t service_uuid[16] = {0xab,0x7d,0xe9,0xbe,0x89,0xfe,0x49,0xad,0x82,0x8f,0x11,0x8f,0x09,0xdf,0x7f,0xd0}; +static const uint8_t input_uuid[16] = {0xab,0x7d,0xe9,0xbe,0x89,0xfe,0x49,0xad,0x82,0x8f,0x11,0x8f,0x09,0xdf,0x7f,0xd2}; +static const uint8_t response_uuid[16] = {0xc7,0x65,0xa9,0x61,0xd9,0xd8,0x4d,0x36,0xa2,0x0a,0x53,0x15,0xb1,0x11,0x83,0x6a}; +static const uint8_t command_uuid[16] = {0x64,0x9d,0x4a,0xc9,0x8e,0xb7,0x4e,0x6c,0xaf,0x44,0x1e,0xa5,0x4f,0xe5,0xf0,0x05}; +static const uint8_t rumble_uuids[3][16] = { + {0xcc,0x48,0x3f,0x51,0x92,0x58,0x42,0x7d,0xa9,0x39,0x63,0x0c,0x31,0xf7,0x2b,0x05}, + {0x28,0x93,0x26,0xcb,0xa4,0x71,0x48,0x5d,0xa8,0xf4,0x24,0x0c,0x14,0xf1,0x82,0x41}, + {0xfa,0x19,0xb0,0xfb,0xcd,0x1f,0x46,0xa7,0x84,0xa1,0xbb,0xb0,0x9e,0x00,0xc1,0x49}, +}; + +static struct fixture_peer* peer_for_handle(hci_con_handle_t handle) { + for (unsigned i = 0; i < PEERS; ++i) + if (peers[i].used && peers[i].device.conn.handle == handle) + return &peers[i]; + return NULL; +} + +static void advance(uint32_t milliseconds) { + uint32_t target = now_ms + milliseconds; + for (;;) { + int earliest = -1; + for (unsigned i = 0; i < timer_count; ++i) { + if ((int32_t)(timers[i]->timeout - target) <= 0 && + (earliest < 0 || (int32_t)(timers[i]->timeout - timers[earliest]->timeout) < 0)) + earliest = (int)i; + } + if (earliest < 0) + break; + btstack_timer_source_t* timer = timers[earliest]; + now_ms = timer->timeout; + btstack_run_loop_remove_timer(timer); + timer->process(timer); + } + now_ms = target; +} + +void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) { timer->timeout = now_ms + ms; } +void btstack_run_loop_add_timer(btstack_timer_source_t* timer) { + for (unsigned i = 0; i < timer_count; ++i) + assert(timers[i] != timer); + assert(timer_count < 16); + timers[timer_count++] = timer; +} +int btstack_run_loop_remove_timer(btstack_timer_source_t* timer) { + for (unsigned i = 0; i < timer_count; ++i) { + if (timers[i] == timer) { + timers[i] = timers[--timer_count]; + return true; + } + } + return false; +} +void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context) { timer->context = context; } +void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*)) { timer->process = handler; } +void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer) { return timer->context; } +uint32_t btstack_run_loop_get_time_ms(void) { return now_ms; } +void uni_log(const char* format, ...) { (void)format; } +bool switch_pico_switch2_pairing_allowed(void) { return pairing_allowed; } +bool uni_switch2_pairing_known(uint8_t type, const uint8_t address[6]) { (void)type; (void)address; return trusted; } +bool uni_switch2_pairing_remember(uint8_t type, const uint8_t address[6]) { + assert(type <= 1 && memcmp(address, controller_address, 6) == 0); + ++remembered; + return storage_ok; +} +void gap_local_bd_addr(bd_addr_t address) { memcpy(address, host_address, 6); } +void gap_stop_scan(void) {} +uint8_t gap_connect(const bd_addr_t address, bd_addr_type_t type) { (void)address; (void)type; ++connected; return 0; } +int gap_update_connection_parameters(hci_con_handle_t handle, uint16_t min, uint16_t max, uint16_t latency, uint16_t timeout) { + (void)handle; (void)min; (void)max; (void)latency; (void)timeout; return 0; +} +gap_connection_type_t gap_get_connection_type(hci_con_handle_t handle) { + for (unsigned i = 0; i < PEERS; ++i) + if (peers[i].link_alive && peers[i].device.conn.handle == handle) + return GAP_CONNECTION_LE; + return GAP_CONNECTION_INVALID; +} +uni_hid_device_t* uni_hid_device_create(bd_addr_t address) { + for (unsigned i = 0; i < PEERS; ++i) { + if (!peers[i].used) { + memset(&peers[i], 0, sizeof(peers[i])); + peers[i].used = true; + peers[i].device.conn.handle = UNI_BT_CONN_HANDLE_INVALID; + memcpy(peers[i].device.conn.btaddr, address, 6); + return &peers[i].device; + } + } + return NULL; +} +uni_hid_device_t* uni_hid_device_get_instance_for_address(bd_addr_t address) { + for (unsigned i = 0; i < PEERS; ++i) + if (peers[i].used && memcmp(peers[i].device.conn.btaddr, address, 6) == 0) + return &peers[i].device; + return NULL; +} +uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle(hci_con_handle_t handle) { + struct fixture_peer* peer = peer_for_handle(handle); + return peer ? &peer->device : NULL; +} +uni_error_t uni_hid_device_on_device_discovered(bd_addr_t address, const char* name, uint16_t cod, uint8_t rssi) { + (void)address; (void)name; (void)cod; (void)rssi; + return admit ? UNI_ERROR_SUCCESS : (uni_error_t)1; +} +void uni_hid_device_set_vendor_id(uni_hid_device_t* d, uint16_t value) { d->vendor_id = value; } +void uni_hid_device_set_product_id(uni_hid_device_t* d, uint16_t value) { d->product_id = value; } +void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t value) { d->cod = value; } +void uni_hid_device_set_name(uni_hid_device_t* d, const char* value) { (void)d; (void)value; } +void uni_hid_device_guess_controller_type_from_pid_vid(uni_hid_device_t* d) { (void)d; } +void uni_bt_conn_set_protocol(uni_bt_conn_t* conn, uni_bt_conn_protocol_t value) { conn->protocol = value; } +void uni_bt_conn_set_state(uni_bt_conn_t* conn, uni_bt_conn_state_t value) { conn->state = value; } +void uni_hid_device_connect(uni_hid_device_t* d) { + assert(!d->conn.connected); + d->conn.connected = true; + ++connected_events; + if (reject_connected) { + uni_hid_device_disconnect(d); + uni_hid_device_delete(d); + } +} +void uni_hid_device_set_ready(uni_hid_device_t* d) { + d->conn.state = UNI_BT_CONN_STATE_DEVICE_PENDING_READY; + uni_hid_parser_switch2_setup(d); +} +bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) { + assert(d->conn.connected && connected_events == 1); + ++ready; + d->conn.state = UNI_BT_CONN_STATE_DEVICE_READY; + return true; +} +void uni_hid_device_disconnect(uni_hid_device_t* d) { + ++disconnected; + if (d->conn.connected) + ++disconnected_events; + d->conn.connected = false; + uni_hid_parser_switch2_teardown(d); + struct fixture_peer* peer = peer_for_handle(d->conn.handle); + if (peer) + peer->link_alive = false; +} +void uni_hid_device_delete(uni_hid_device_t* d) { + uni_hid_parser_switch2_teardown(d); + for (unsigned i = 0; i < PEERS; ++i) + if (&peers[i].device == d) + peers[i].used = false; +} +void uni_hid_device_process_controller(uni_hid_device_t* d) { (void)d; ++emitted; } + +static uint8_t begin_query(btstack_packet_handler_t callback, hci_con_handle_t handle, enum fixture_query query) { + struct fixture_peer* peer = peer_for_handle(handle); + assert(peer && peer->query == QUERY_NONE); + peer->callback = callback; + peer->query = query; + return 0; +} +uint8_t gatt_client_discover_primary_services_by_uuid128(btstack_packet_handler_t callback, hci_con_handle_t handle, const uint8_t* uuid) { + assert(memcmp(uuid, service_uuid, 16) == 0); + return begin_query(callback, handle, QUERY_SERVICE); +} +uint8_t gatt_client_discover_characteristics_for_service(btstack_packet_handler_t callback, hci_con_handle_t handle, gatt_client_service_t* service) { + assert(service->start_group_handle == SERVICE_START); + return begin_query(callback, handle, QUERY_CHARACTERISTICS); +} +uint8_t gatt_client_discover_characteristic_descriptors(btstack_packet_handler_t callback, hci_con_handle_t handle, gatt_client_characteristic_t* ch) { + peer_for_handle(handle)->descriptor_value = ch->value_handle; + return begin_query(callback, handle, QUERY_DESCRIPTORS); +} +uint8_t gatt_client_write_characteristic_descriptor_using_descriptor_handle(btstack_packet_handler_t callback, hci_con_handle_t handle, + uint16_t descriptor, uint16_t length, uint8_t* value) { + struct fixture_peer* peer = peer_for_handle(handle); + peer->cccd_handle = descriptor; + peer->pending_write = value; + peer->pending_length = length; + return begin_query(callback, handle, QUERY_CCCD); +} +void gatt_client_listen_for_characteristic_value_updates(gatt_client_notification_t* registration, btstack_packet_handler_t callback, + hci_con_handle_t handle, gatt_client_characteristic_t* ch) { + registration->callback = callback; + registration->con_handle = handle; + registration->attribute_handle = ch->value_handle; + ++listeners; +} +void gatt_client_stop_listening_for_characteristic_value_updates(gatt_client_notification_t* registration) { (void)registration; assert(listeners); --listeners; } +static uint8_t capture_write(hci_con_handle_t handle, uint16_t value_handle, uint16_t length, uint8_t* value) { + if (next_write_error) { + uint8_t error = next_write_error; + next_write_error = 0; + return error; + } + struct fixture_peer* peer = peer_for_handle(handle); + assert(peer); + if (value_handle == COMMAND_HANDLE) { + assert(length <= sizeof(peer->command)); + memcpy(peer->command, value, length); + peer->command_length = length; + ++peer->commands; + } else { + assert(value_handle == RUMBLE_HANDLE && length <= sizeof(peer->rumble)); + memcpy(peer->rumble, value, length); + peer->rumble_length = length; + ++peer->rumbles; + } + return 0; +} +uint8_t gatt_client_write_value_of_characteristic_without_response(hci_con_handle_t handle, uint16_t value_handle, uint16_t length, uint8_t* value) { + return capture_write(handle, value_handle, length, value); +} +uint8_t gatt_client_write_value_of_characteristic(btstack_packet_handler_t callback, hci_con_handle_t handle, + uint16_t value_handle, uint16_t length, uint8_t* value) { + uint8_t status = capture_write(handle, value_handle, length, value); + if (status) + return status; + struct fixture_peer* peer = peer_for_handle(handle); + peer->pending_write = value; + peer->pending_length = length; + return begin_query(callback, handle, QUERY_WRITE); +} + +// Serialization here models BTstack-generated events; production accessors and +// types come directly from the SDK rather than a parallel mock btstack.h ABI. +void gatt_client_deserialize_service(const uint8_t* data, int offset, gatt_client_service_t* service) { + service->start_group_handle = little_endian_read_16(data, offset); + service->end_group_handle = little_endian_read_16(data, offset + 2); + reverse_128(data + offset + 4, service->uuid128); + service->uuid16 = 0; +} +void gatt_client_deserialize_characteristic(const uint8_t* data, int offset, gatt_client_characteristic_t* ch) { + ch->start_handle = little_endian_read_16(data, offset); + ch->value_handle = little_endian_read_16(data, offset + 2); + ch->end_handle = little_endian_read_16(data, offset + 4); + ch->properties = little_endian_read_16(data, offset + 6); + reverse_128(data + offset + 8, ch->uuid128); + ch->uuid16 = 0; +} +void gatt_client_deserialize_characteristic_descriptor(const uint8_t* data, int offset, gatt_client_characteristic_descriptor_t* descriptor) { + descriptor->handle = little_endian_read_16(data, offset); + reverse_128(data + offset + 2, descriptor->uuid128); + descriptor->uuid16 = (uint16_t)big_endian_read_32(descriptor->uuid128, 0); +} + +static void event(struct fixture_peer* peer, uint8_t* data, uint16_t size) { + data[1] = (uint8_t)(size - 2); + little_endian_store_16(data, 2, peer->device.conn.handle); + peer->callback(HCI_EVENT_PACKET, 0, data, size); +} +static void query_done(struct fixture_peer* peer, uint8_t status) { + uint8_t data[9] = {GATT_EVENT_QUERY_COMPLETE}; + if (peer->query == QUERY_CCCD) + assert(peer->pending_length == 2 && peer->pending_write[0] == 1 && peer->pending_write[1] == 0); + peer->query = QUERY_NONE; + data[8] = status; + event(peer, data, sizeof(data)); +} +static void characteristic(struct fixture_peer* peer, uint16_t handle, const uint8_t* uuid, uint16_t properties) { + uint8_t data[32] = {GATT_EVENT_CHARACTERISTIC_QUERY_RESULT}; + little_endian_store_16(data, 8, handle - 1); + little_endian_store_16(data, 10, handle); + little_endian_store_16(data, 12, handle + 3); + little_endian_store_16(data, 14, properties); + reverse_128(uuid, data + 16); + event(peer, data, sizeof(data)); +} +static void descriptor(struct fixture_peer* peer, uint16_t handle) { + const uint8_t cccd_uuid[16] = {0,0,0x29,0x02,0,0,0x10,0,0x80,0,0,0x80,0x5f,0x9b,0x34,0xfb}; + uint8_t data[26] = {GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT}; + little_endian_store_16(data, 8, handle); + reverse_128(cccd_uuid, data + 10); + event(peer, data, sizeof(data)); +} +static void discover(struct fixture_peer* peer) { + uint8_t service[28] = {GATT_EVENT_SERVICE_QUERY_RESULT}; + little_endian_store_16(service, 8, SERVICE_START); + little_endian_store_16(service, 10, SERVICE_START + 0x60); + reverse_128(service_uuid, service + 12); + event(peer, service, sizeof(service)); + query_done(peer, 0); + characteristic(peer, INPUT_HANDLE, input_uuid, ATT_PROPERTY_NOTIFY); + characteristic(peer, RESPONSE_HANDLE, response_uuid, ATT_PROPERTY_NOTIFY); + characteristic(peer, COMMAND_HANDLE, command_uuid, request_writes ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE); + unsigned kind = peer->device.product_id == UNI_SW2_PRO_PID ? 0 : peer->device.product_id == UNI_SW2_JOYCON_L_PID ? 1 : 2; + characteristic(peer, RUMBLE_HANDLE, rumble_uuids[kind], request_writes ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE); + query_done(peer, 0); +} +static void subscribe_response(struct fixture_peer* peer) { + // Deliberately not value_handle+1: parser must discover, not guess CCCDs. + descriptor(peer, RESPONSE_HANDLE + 2); + query_done(peer, 0); + descriptor(peer, INPUT_HANDLE + 2); + query_done(peer, 0); + assert(peer->cccd_handle == RESPONSE_HANDLE + 2); + query_done(peer, 0); +} +static void notify(struct fixture_peer* peer, uint16_t handle, const uint8_t* value, uint16_t length) { + uint8_t data[112] = {GATT_EVENT_NOTIFICATION}; + assert(length <= sizeof(data) - 12); + little_endian_store_16(data, 8, handle); + little_endian_store_16(data, 10, length); + memcpy(data + 12, value, length); + event(peer, data, 12 + length); +} +static void packed(uint8_t* data, uint16_t x, uint16_t y) { + data[0] = (uint8_t)x; + data[1] = (uint8_t)((x >> 8) | (y << 4)); + data[2] = (uint8_t)(y >> 4); +} +static unsigned response_data(struct fixture_peer* peer, uint8_t* out, bool erased) { + memset(out, 0, 96); + out[0] = peer->command[0]; + out[1] = out[2] = 1; + out[3] = peer->command[3]; + out[5] = 0x78; + if (out[0] == 2) { + uint8_t length = peer->command[8]; + uint32_t address = little_endian_read_32(peer->command, 12); + out[8] = length; + little_endian_store_32(out, 12, address); + if (address == 0x13000) { + little_endian_store_16(out, 16 + 18, UNI_SW2_NINTENDO_VID); + little_endian_store_16(out, 16 + 20, peer->device.product_id); + } else if (address != 0x13044) { + if (erased) + memset(out + 16, 0xff, length); + else { + packed(out + 16, 1900, 2100); + packed(out + 19, 1200, 1400); + packed(out + 22, 1500, 1700); + } + } + return 16 + length; + } + if (out[0] == 0x15) { + out[8] = 1; + return out[3] == 1 ? 17 : out[3] == 3 ? 9 : 25; + } + return 8; +} +static void acknowledge(struct fixture_peer* peer, bool erased) { + uint8_t response[96]; + unsigned length = response_data(peer, response, erased); + if (peer->query == QUERY_WRITE) + query_done(peer, 0); + notify(peer, RESPONSE_HANDLE, response, length); +} +static void finish_setup(struct fixture_peer* peer) { + for (unsigned limit = 0; limit < 24 && !ready && !disconnected; ++limit) { + if (peer->query == QUERY_CCCD) { + assert(peer->cccd_handle == INPUT_HANDLE + 2); + query_done(peer, 0); + } else { + acknowledge(peer, false); + } + } + assert(ready == 1 && disconnected == 0); +} +static size_t advertisement(uint8_t* packet, uint16_t pid, bool fresh) { + memset(packet, 0, 64); + packet[0] = GAP_EVENT_ADVERTISING_REPORT; + packet[2] = 0; + packet[3] = BD_ADDR_TYPE_LE_PUBLIC; + reverse_bytes(controller_address, packet + 4, 6); + packet[10] = (uint8_t)-35; + packet[11] = 20; + packet[12] = 19; + packet[13] = 0xff; + uint8_t* mfg = packet + 14; + little_endian_store_16(mfg, 0, 0x0553); + little_endian_store_16(mfg, 5, UNI_SW2_NINTENDO_VID); + little_endian_store_16(mfg, 7, pid); + if (!fresh) + reverse_bytes(host_address, mfg + 12, 6); + packet[1] = 30; + return 32; +} +static struct fixture_peer* connect_peer(uint16_t pid, bool fresh) { + uint8_t packet[64]; + size_t size = advertisement(packet, pid, fresh); + assert(uni_bt_le_switch2_handle_advertisement(packet, size)); + assert(connected == 1); + struct fixture_peer* peer = &peers[0]; + peer->device.conn.handle = 0; // Handle zero is valid, including on retirement. + peer->link_alive = true; + uni_hid_parser_switch2_on_le_connected(&peer->device); + return peer; +} +static void reset(void) { + for (unsigned i = 0; i < PEERS; ++i) { + if (peers[i].used) + uni_hid_parser_switch2_teardown(&peers[i].device); + } + assert(timer_count == 0 && listeners == 0); + memset(peers, 0, sizeof(peers)); + connected = ready = disconnected = emitted = remembered = 0; + connected_events = disconnected_events = 0; + now_ms = 0; + next_write_error = 0; + pairing_allowed = trusted = storage_ok = admit = true; + request_writes = false; + reject_connected = false; +} + +static void test_connected_callback_rejection(void) { + reset(); + reject_connected = true; + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false); + assert(connected_events == 1 && disconnected_events == 1 && ready == 0); + assert(peer->query == QUERY_NONE && timer_count == 0 && listeners == 0); + advance(3000); + assert(ready == 0 && peer->commands == 0); +} + +static void test_advertisement_bounds_and_admission(void) { + reset(); + uint8_t packet[64]; + size_t size = advertisement(packet, UNI_SW2_PRO_PID, true); + for (size_t n = 0; n < size; ++n) + assert(!uni_bt_le_switch2_handle_advertisement(packet, (uint16_t)n)); + packet[12] = 20; + assert(!uni_bt_le_switch2_handle_advertisement(packet, size)); + packet[12] = 19; + pairing_allowed = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + advertisement(packet, UNI_SW2_PRO_PID, false); + trusted = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + trusted = true; + memcpy(packet + 26, host_address, 6); // Wrong byte order must not reconnect. + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + advertisement(packet, UNI_SW2_PRO_PID, false); + packet[3] = BD_ADDR_TYPE_LE_RANDOM; + packet[9] = 0x40; // Resolving private address, not static identity. + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + advertisement(packet, UNI_SW2_PRO_PID, false); + admit = false; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 0); + admit = true; + assert(uni_bt_le_switch2_handle_advertisement(packet, size) && connected == 1); + uint8_t type = 0xff; + assert(uni_hid_parser_switch2_identity_address_type(&peers[0].device, &type) && type == BD_ADDR_TYPE_LE_PUBLIC); +} + +static void test_missing_descriptor_and_setup_timeout(void) { + reset(); + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + descriptor(peer, RESPONSE_HANDLE); // CCCD outside characteristic's descriptor range. + assert(disconnected == 1 && ready == 0 && timer_count == 0 && listeners == 0); + reset(); + peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + subscribe_response(peer); + uint8_t response[96]; + unsigned length = response_data(peer, response, false); + response[3] ^= 1; // Matching command but wrong subcommand. + notify(peer, RESPONSE_HANDLE, response, length); + response[3] ^= 1; + response[12] ^= 1; // Matching command/subcommand but stale flash address. + notify(peer, RESPONSE_HANDLE, response, length); + uint8_t truncated[12] = {GATT_EVENT_NOTIFICATION}; + little_endian_store_16(truncated, 8, RESPONSE_HANDLE); + little_endian_store_16(truncated, 10, 0xffff); + event(peer, truncated, sizeof(truncated)); + assert(peer->commands == 1 && ready == 0); + advance(2000); + assert(disconnected == 1 && ready == 0 && timer_count == 0 && listeners == 0); + advance(5000); + assert(peer->commands == 1); +} + +static void test_pairing_gate_and_write_ack_order(void) { + reset(); + request_writes = true; + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, true); + discover(peer); + subscribe_response(peer); + uint8_t response[96]; + unsigned length = response_data(peer, response, false); + notify(peer, RESPONSE_HANDLE, response, length); // App ACK arrives before ATT write result. + assert(peer->commands == 1 && !remembered && !ready); + query_done(peer, 0); + assert(peer->command[0] == 0x15 && peer->command[3] == 1); + for (unsigned i = 0; i < 6; ++i) + assert(peer->command[10 + i] == host_address[5 - i] && peer->command[16 + i] == host_address[5 - i]); + pairing_allowed = false; + acknowledge(peer, false); + assert(disconnected == 1 && remembered == 0 && ready == 0); + assert(connected_events == 1 && disconnected_events == 1); + reset(); + peer = connect_peer(UNI_SW2_PRO_PID, true); + discover(peer); + subscribe_response(peer); + storage_ok = false; + for (unsigned i = 0; i < 5; ++i) + acknowledge(peer, false); // Info and four app pairing commands. + assert(remembered == 1 && disconnected == 1 && ready == 0 && timer_count == 0); + reset(); + peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + subscribe_response(peer); + length = response_data(peer, response, false); + response[5] = 0x81; + notify(peer, RESPONSE_HANDLE, response, length); + assert(disconnected == 1 && ready == 0); + reset(); + request_writes = true; + peer = connect_peer(UNI_SW2_PRO_PID, false); + discover(peer); + subscribe_response(peer); + length = response_data(peer, response, false); + notify(peer, RESPONSE_HANDLE, response, length); + query_done(peer, ATT_ERROR_INSUFFICIENT_AUTHENTICATION); + assert(disconnected == 1 && ready == 0); +} + +static void test_calibration_physical_inputs_and_sensor_units(void) { + reset(); + struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_R_PID, false); + discover(peer); + subscribe_response(peer); + acknowledge(peer, false); // Info. + acknowledge(peer, true); // Missing user calibration falls back to factory. + assert(little_endian_read_32(peer->command, 12) == 0x130a8); + finish_setup(peer); + uint8_t report[63] = {0}; + little_endian_store_32(report, 4, 0x4000 | 0x20 | 0x10 | 0x04); + packed(report + 10, 4095, 4095); // Nonexistent left stick must stay neutral. + packed(report + 13, 3100, 400); // Calibrated +X/-Y full travel. + notify(peer, INPUT_HANDLE, report, sizeof(report)); + uni_gamepad_t* gp = &peer->device.controller.gamepad; + assert(gp->axis_x == 0 && gp->axis_y == 0 && gp->axis_rx == 511 && gp->axis_ry == 511); + assert(gp->buttons == BUTTON_A && gp->misc_buttons == 0); + assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_C | UNI_SW2_BUTTON_RIGHT_SL | UNI_SW2_BUTTON_RIGHT_SR)); + unsigned previous = emitted; + notify(peer, INPUT_HANDLE, report, 62); + assert(emitted == previous && gp->axis_rx == 511); + little_endian_store_16(report, 48, 4096); + little_endian_store_16(report, 50, 8192); + little_endian_store_16(report, 52, (uint16_t)-4096); + little_endian_store_16(report, 54, 32767); + little_endian_store_16(report, 56, 0); + little_endian_store_16(report, 58, 0); + for (unsigned sample = 0; sample < 46; ++sample) { + little_endian_store_32(report, 42, 0xffff0000u + sample * 10000u); + notify(peer, INPUT_HANDLE, report, sizeof(report)); + advance(10); + } + assert(gp->accel[0] == 8192 && gp->accel[1] == -8192 && gp->accel[2] == -16384); + assert(gp->gyro[0] >= 2041740 && gp->gyro[0] <= 2041750); + notify(peer, INPUT_HANDLE, report, sizeof(report)); // Repeated sensor sample must not become zero. + assert(gp->gyro[0] >= 2041740); + reset(); + peer = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + memset(report, 0, sizeof(report)); + little_endian_store_32(report, 4, 0x300000); + packed(report + 10, 1900, 2100); + notify(peer, INPUT_HANDLE, report, sizeof(report)); + assert(peer->device.controller.gamepad.misc_buttons == 0); // Rail buttons are NOT Home/Capture. + assert(uni_hid_parser_switch2_extra_buttons(&peer->device) == (UNI_SW2_BUTTON_LEFT_SL | UNI_SW2_BUTTON_LEFT_SR)); +} + +static uint64_t rumble_frame(const struct fixture_peer* peer) { + uint64_t value = 0; + for (unsigned i = 0; i < 5; ++i) + value |= (uint64_t)peer->rumble[2 + i] << (8 * i); + return value; +} +static bool rumble_active(const struct fixture_peer* peer) { + uint64_t frame = rumble_frame(peer); + return ((frame >> 10) & 1023) != 0 || ((frame >> 30) & 1023) != 0; +} +static void test_rumble_delay_expiry_retry_and_teardown(void) { + reset(); + struct fixture_peer* peer = connect_peer(UNI_SW2_PRO_PID, true); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + assert(remembered == 1); + uni_hid_parser_switch2_set_player_leds(&peer->device, 0x0a); + assert(peer->command[8] == 0x0a); + acknowledge(peer, false); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 20, 50, 40, 80); + advance(19); + assert(peer->rumble_length == 33 && !rumble_active(peer)); + advance(1); + assert(rumble_active(peer)); + assert(((rumble_frame(peer) >> 10) & 1023) == 320 && ((rumble_frame(peer) >> 30) & 1023) == 160); + uint8_t successful_id = peer->rumble[1]; + next_write_error = BTSTACK_ACL_BUFFERS_FULL; + advance(13); + assert(peer->rumble[1] == successful_id); + advance(13); + assert(peer->rumble[1] == (uint8_t)(0x50 | ((successful_id + 1) & 15)) && rumble_active(peer)); + advance(24); + assert(!rumble_active(peer)); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 12, 34); + advance(70000); // Stateful host rumble does not expire at 65.535 seconds. + assert(((rumble_frame(peer) >> 10) & 1023) == 136 && ((rumble_frame(peer) >> 30) & 1023) == 48); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 255, 255); + advance(1); + assert(rumble_active(peer)); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0); + advance(1); + assert(!rumble_active(peer)); + unsigned writes = peer->rumbles; + uni_hid_device_disconnect(&peer->device); + assert(disconnected_events == 1); + uni_hid_parser_switch2_teardown(&peer->device); + assert(timer_count == 0 && listeners == 0); + advance(5000); + assert(peer->rumbles == writes); + assert(!uni_hid_parser_switch2_identity_address_type(&peer->device, &(uint8_t){0})); +} + +static void test_write_request_buffers_and_failed_completion(void) { + reset(); + request_writes = true; + struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false); + discover(peer); + subscribe_response(peer); + finish_setup(peer); + advance(1); + assert(peer->rumble_length == 17 && peer->query == QUERY_WRITE); + uint8_t pending[33]; + uint16_t length = peer->pending_length; + const uint8_t* pending_buffer = peer->pending_write; + memcpy(pending, pending_buffer, length); + uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 20, 30); + uni_hid_parser_switch2_set_player_leds(&peer->device, 0x05); + advance(13); + assert(memcmp(pending, pending_buffer, length) == 0); // ATT still borrows this buffer. + query_done(peer, 0); + assert(peer->command[0] == 9 && peer->command[8] == 0x05); + acknowledge(peer, false); + advance(13); + assert(rumble_active(peer) && peer->rumble[1] == 0x51); + query_done(peer, ATT_ERROR_UNLIKELY_ERROR); + assert(disconnected_events == 1 && timer_count == 0 && listeners == 0); + unsigned writes = peer->rumbles; + advance(3000); + assert(peer->rumbles == writes); +} + +int main(void) { + test_connected_callback_rejection(); + test_advertisement_bounds_and_admission(); + test_missing_descriptor_and_setup_timeout(); + test_pairing_gate_and_write_ack_order(); + test_calibration_physical_inputs_and_sensor_units(); + test_rumble_delay_expiry_retry_and_teardown(); + test_write_request_buffers_and_failed_completion(); + reset(); + puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed"); + return 0; +} diff --git a/tests/switch_parser_native_test.c b/tests/switch_parser_native_test.c index 0e812ae..6edd911 100644 --- a/tests/switch_parser_native_test.c +++ b/tests/switch_parser_native_test.c @@ -5,6 +5,7 @@ #include "bt/uni_bt_service.h" #include "parser/uni_hid_parser_switch.h" +#include "parser/uni_hid_parser_switch2.h" #include "platform/uni_platform.h" #include "uni_hid_device.h" @@ -22,6 +23,15 @@ static struct { uint16_t cid, len; uint8_t bytes[128]; } sent[256]; static uint32_t now_ms; static struct { btstack_timer_source_t* timer; uint32_t deadline; bool active; } timers[32]; +bool uni_hid_parser_switch2_is_ble_device(const uni_hid_device_t* d) { + (void)d; + return false; // This fixture exercises only Classic Switch devices. +} +void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { + (void)d; + assert(!"Switch 2 teardown reached a Classic Switch fixture"); +} + static unsigned timer_index(btstack_timer_source_t* timer) { for (unsigned i = 0; i < 32; ++i) { if (timers[i].timer == timer) return i; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 8dc9342..e84653e 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -81,6 +81,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: [ f"-I{root / 'tests' / 'bluepad32_native_stubs'}", f"-I{root / 'src' / 'firmware'}", + f"-I{root / 'bluepad32_config'}", str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"), str( root / "src" / "firmware" / "input" / "controller_macro_capture.cpp" @@ -91,6 +92,14 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: ) subprocess.run(command, check=True, cwd=root) subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root) + for scenario in ( + "switch2-forward", + "switch2-reverse", + "switch2-multiple-pairs", + "switch2-admission", + "switch2-pairing-inventory", + ): + subprocess.run([str(executable), scenario], check=True, cwd=root) if native: subprocess.run([str(executable), "native-stateful"], check=True, cwd=root) subprocess.run( diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 46591f2..a2c5776 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -136,6 +136,7 @@ class FakeDevice: self.playtest_connection_generation = 17 self.playtest_state_generation = 93 self.playtest_button_mask = 0x9001 + self.playtest_extra_buttons = 0 self.playtest_sticks = (-1234, 2345, -30000, 30000) self.playtest_triggers = (123, 65000) self.playtest_motion = (1, -2, 3, -4, 5, -6) @@ -232,6 +233,7 @@ class FakeDevice: payload[38] = 1 if self.playtest_motion is not None else 0 payload[39] = self.playtest_battery payload[40] = self.playtest_capabilities + payload[54] = self.playtest_extra_buttons if self.playtest_motion is not None: struct.pack_into(" None: legacy_json_object = default_profile.to_json_object() legacy_json_object["schema_version"] = config_manager.PROFILE_LEGACY_SCHEMA_VERSION legacy_json_object["size"] = config_manager.PROFILE_LEGACY_SIZE - for field in ("shortcuts", "shift", "turbo_settings"): + for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map"): del legacy_json_object[field] del legacy_json_object["motion_toggle_chord"] del legacy_json_object["triggers"]["left"]["output"] @@ -1639,7 +1641,7 @@ def test_schema5_full_macro_stream_migrates_bytes_and_json(monkeypatch) -> None: obj["schema_version"] = 5 obj["size"] = 256 - for field in ("shortcuts", "shift", "turbo_settings"): + for field in ("shortcuts", "shift", "turbo_settings", "extra_button_map"): del obj[field] for macro in obj["macros"]: del macro["playback"] @@ -1678,10 +1680,133 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None: assert encoded[283:294] == bytes((30, 99, 255, 4, 128, 1, 1, 1, 23, 37, 17)) assert encoded[294:336] == bytes(42) assert encoded[336:344] == bytes((3, 255, 0, 1, 1, 1, 2, 1)) - assert encoded[344:] == bytes(40) + assert encoded[344:358] == bytes([255]) * 14 + assert encoded[358:] == bytes(26) assert config_manager.ControllerProfile.from_bytes(encoded) == profile assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile + legacy_wire = bytearray(encoded) + struct.pack_into(" None: + obj = custom_profile().to_json_object() + obj["extra_button_map"] = dict(zip( + config_manager.EXTRA_BUTTONS, + ("south", "right_trigger", "left_trigger", None, "dpad_left", "start", "capture"), + )) + obj["shift"]["mode"] = "hold" + obj["shift"]["modifier"] = "gl" + obj["shift"]["extra_button_map"] = dict(zip( + config_manager.EXTRA_BUTTONS, + ("east", None, "west", "north", "system", "dpad_up", "dpad_right"), + )) + obj["shortcuts"]["modifier"] = "left_sr" + obj["shortcuts"]["profiles"][0] = "south" + obj["switching_chord"] = ["left_trigger", "c", "right_sr"] + obj["motion_toggle_chord"] = ["right_trigger", "gl", "left_sl"] + for index, names in enumerate((["c", "gl"], ["gr"], ["left_sl", "left_sr"], ["right_sl", "right_sr"])): + obj["macros"][index]["trigger"] = names + obj["macros"][index]["cancel"] = config_manager.EXTRA_BUTTONS[index + 3] + profile = config_manager.ControllerProfile.from_json_object(obj) + encoded = profile.to_bytes() + assert encoded[:4] == struct.pack(" None: + obj = config_manager.ControllerProfile.default().to_json_object() + target = obj + for key in path[:-1]: + target = target[key] + target[path[-1]] = "right_sr" + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_json_object(obj) + + +@pytest.mark.parametrize("offset,value", [ + (98, 18 << 2), (256, 18), (266, 24), (344, 1), (358, 1), (363, 1), +]) +def test_schema6_rejects_schema7_controls_in_old_fields(offset: int, value: int) -> None: + payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) + struct.pack_into(" None: + payload = bytearray(config_manager.ControllerProfile.default().to_bytes()) + payload[offset] = value + with pytest.raises(config_manager.ConfigManagerError): + config_manager.ControllerProfile.from_bytes(payload) + + +@pytest.mark.parametrize("version", [3, 4]) +def test_legacy_control_profiles_preserve_custom_actions(version: int) -> None: + payload = legacy_profile_wire(version, macro_trigger=3, macro_cancel=2) + payload[4] = 17 + payload[72:75] = bytes((71, 202, 1)) + struct.pack_into(" None: config_manager.parse_profile_playtest(envelope) +def test_playtest_extra_inputs_and_legacy_firmware_are_distinct() -> None: + device = FakeDevice() + device.playtest_extra_buttons = 0x55 + payload, flags = device._profile_playtest_payload() + def parse(data: bytes, schema: int) -> config_manager.ProfilePlaytest: + return config_manager.parse_profile_playtest(config_manager.parse_response( + make_response(config_manager.OP_PROFILE_PLAYTEST, data, flags=flags, schema=schema), + config_manager.OP_PROFILE_PLAYTEST, + )) + current = parse(payload, 3) + assert current.to_json_object()["extra_buttons"] == ["c", "gr", "left_sr", "right_sr"] + assert current.to_json_object()["buttons"] == ["south", "dpad_up", "dpad_right"] + legacy = parse(payload[:54], 2) + assert legacy == replace(current, extra_buttons=0) + assert legacy.to_json_object()["extra_buttons"] == [] + with pytest.raises(config_manager.ConfigManagerError): + parse(payload[:54] + b"\x80", 3) + with pytest.raises(config_manager.ConfigManagerError): + parse(payload, 2) + + def test_profile_reset_and_activate_wait_for_correlated_transactions( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index 2eee9c9..df50789 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -63,35 +63,26 @@ def test_editor_serves_assets_and_complete_schema( ) -> None: with running_server(monkeypatch, FakeDevice()) as (base_url, _): with urllib.request.urlopen(f"{base_url}/", timeout=2) as response: - page = response.read().decode("utf-8") + response.read() assert response.headers["Content-Security-Policy"] with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response: - script_size = len(response.read()) + response.read() assert response.headers["Content-Type"].startswith("text/javascript") with urllib.request.urlopen( f"{base_url}/assets/controller-switch-pro.svg", timeout=2 ) as response: - artwork_size = len(response.read()) + response.read() assert response.headers["Content-Type"] == "image/svg+xml" status, schema = request_json(f"{base_url}/api/schema") assert status == 200 - assert "Profile Studio" in page - assert script_size > 1000 - assert artwork_size > 10000 assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS) assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS) assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES) assert schema["turbo_modes"] == list(config_manager.TURBO_MODES) assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES) assert schema["profile_capacity"] == 8 - assert schema["control_labels"]["generic"]["south"] == "A" - assert schema["control_labels"]["xbox"]["left_shoulder"] == "LB" - assert schema["control_labels"]["switch"]["east"] == "A" - assert schema["control_labels"]["switch"]["left_trigger"] == "ZL" - assert schema["control_labels"]["playstation"]["south"] == "Cross" - assert schema["control_labels"]["playstation"]["select"] == "Create" assert ( config_manager.ControllerProfile.from_json( json.dumps(schema["default_profile"]) @@ -100,6 +91,75 @@ def test_editor_serves_assets_and_complete_schema( ) +@pytest.mark.parametrize("product_id", [0x2069, 0x2067, 0x2066]) +def test_switch2_input_choices_are_never_output_targets( + monkeypatch: pytest.MonkeyPatch, product_id: int, +) -> None: + device = FakeDevice() + identity = replace(device.stable_identity, vendor_id=0x057E, product_id=product_id) + device.profile_identities = [device.global_identity, identity] + device.active_profiles[identity.to_bytes()] = 0 + with running_server(monkeypatch, device) as (base_url, _): + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + status, schema = request_json(f"{base_url}/api/schema") + assert status == 200 + owner = listing["identities"][1] + assert owner["controller"]["style"] == "switch" + assert set(config_manager.EXTRA_BUTTONS) <= set(owner["modifier_controls"]) + assert set(schema["extra_buttons"]).isdisjoint(schema["output_controls"]) + assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS) + + +def test_editor_migrates_schema6_and_saves_extra_mappings_without_metadata_loss( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + key = (device.stable_identity.to_bytes(), 1) + profile = custom_profile() + legacy_wire = bytearray(profile.to_bytes()) + legacy_wire[:2] = b"\x06\x00" + legacy_wire[344:] = bytes(40) + device.profiles[key] = bytes(legacy_wire) + device.profile_aliases[key[0]] = "Living room" + device.profile_names[key] = "Racing" + device.playtest_extra_buttons = 0x7F + with running_server(monkeypatch, device) as (base_url, token): + status, migrated = request_json(f"{base_url}/api/profiles/1/2") + assert status == 200 + assert config_manager.ControllerProfile.from_json_object(migrated["profile"]) == profile + draft = migrated["profile"] + draft["extra_button_map"] = dict(zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7])) + draft["shift"]["mode"] = "hold" + draft["shift"]["modifier"] = "c" + draft["shift"]["extra_button_map"]["right_sr"] = "dpad_right" + draft["macros"][0]["trigger"] = ["gl", "right_sr"] + draft["macros"][0]["cancel"] = "gr" + draft["switching_chord"] = ["left_sl", "left_sr"] + draft["motion_toggle_chord"] = ["right_sl", "c"] + status, validated = request_json( + f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token, + ) + assert status == 200 + expected_profile = config_manager.ControllerProfile.from_json_object(draft) + assert config_manager.ControllerProfile.from_json_object(validated["profile"]) == expected_profile + assert device.profiles[key] == bytes(legacy_wire) + status, _ = request_json( + f"{base_url}/api/profiles/1/2", method="PUT", value=draft, token=token, + ) + assert status == 200 + status, stored = request_json(f"{base_url}/api/profiles/1/2") + assert status == 200 + assert config_manager.ControllerProfile.from_json_object(stored["profile"]) == expected_profile + assert stored["alias"] == "Living room" + assert stored["name"] == "Racing" + assert stored["active"] is True + status, sample = request_json(f"{base_url}/api/profiles/1/2/playtest") + assert status == 200 + assert sample["extra_buttons"] == list(config_manager.EXTRA_BUTTONS) + assert sample["buttons"] == ["south", "dpad_up", "dpad_right"] + + def test_editor_identifies_connected_controller_artwork( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_switch2_pairing_native.py b/tests/test_switch2_pairing_native.py new file mode 100644 index 0000000..e6b008f --- /dev/null +++ b/tests/test_switch2_pairing_native.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_switch2_pairing_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + executable = tmp_path / "switch2_pairing" + subprocess.run( + [ + compiler, + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'tests' / 'switch2_pairing_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + str(root / "bluepad32_config" / "parser" / "uni_switch2_pairing.c"), + str(root / "tests" / "switch2_pairing_test.c"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch2_parser_native.py b/tests/test_switch2_parser_native.py new file mode 100644 index 0000000..6d4407f --- /dev/null +++ b/tests/test_switch2_parser_native.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("cc") or shutil.which("gcc") + assert compiler is not None, "a host C compiler is required" + sdk_candidates = [ + root / "build" / "_deps" / "pico_sdk-src", + root / "external" / "pico-sdk", + ] + if sdk_path := os.environ.get("PICO_SDK_PATH"): + sdk_candidates.insert(0, Path(sdk_path)) + btstack = next( + (sdk / "lib" / "btstack" / "src" for sdk in sdk_candidates + if (sdk / "lib" / "btstack" / "src" / "ble" / "gatt_client.h").is_file()), + None, + ) + if btstack is None: + pytest.skip("Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH") + executable = tmp_path / "switch2_parser_native_test" + subprocess.run( + [ + compiler, + "-std=gnu11", + "-O1", + "-Wall", + "-Wextra", + "-ffunction-sections", + "-fdata-sections", + "-DENABLE_BLE", + "-DENABLE_CLASSIC", + f"-I{root / 'tests' / 'switch2_parser_native_stubs'}", + f"-I{root / 'bluepad32_config'}", + f"-I{root / 'external' / 'bluepad32' / 'src' / 'components' / 'bluepad32' / 'include'}", + f"-I{btstack}", + f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}", + f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}", + f"-I{btstack.parent / '3rd-party' / 'yxml'}", + str(root / "tests" / "switch2_parser_native_test.c"), + str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"), + str(btstack / "btstack_util.c"), + "-Wl,--gc-sections", + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 0265f95..d803eb0 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -188,6 +188,10 @@ void test_pairing_encoding() { memcmp(&payload[kResponseHeaderSize + 6], classic_address, 6) == 0, "pairings were not migrated into the versioned envelope"); + snapshot.status = Bluepad32PairingSnapshotStatus::kFailed; + require(encode_pairing_snapshot(snapshot, payload, sizeof(payload)) == size && + payload[6] == static_cast(Status::kStorageError), + "failed persistent pairing clear must report storage failure, not success or pending"); } void perform_out(UsbConfigurationManagement::Operation operation, @@ -511,6 +515,7 @@ void test_profile_vendor_requests() { current_playtest[2].state_generation = 0x55667788; current_playtest[2].identity = expected_identity; current_playtest[2].physical_button_mask = 0x8001; + current_playtest[2].state.extra_buttons = 0x7f; current_playtest[2].state.left_stick_x = -1234; current_playtest[2].state.left_stick_y = 2345; current_playtest[2].state.right_stick_x = INT16_MIN; @@ -550,13 +555,15 @@ void test_profile_vendor_requests() { static_cast(read_u16( control_payload, kResponseHeaderSize + 52)) == -6 && control_payload[kResponseHeaderSize + 39] == 201 && - control_payload[kResponseHeaderSize + 40] == 0x0f, + control_payload[kResponseHeaderSize + 40] == 0x0f && + control_payload[kResponseHeaderSize + 54] == 0x7f, "profile playtest response lost live controller state"); current_playtest[2].active = false; require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload[kResponseHeaderSize] == 0 && - control_payload[kResponseHeaderSize + 1] == 0xff, + control_payload[kResponseHeaderSize + 1] == 0xff && + control_payload[kResponseHeaderSize + 54] == 0, "disconnected profile playtest was not encoded"); current_profile_metadata = {}; current_profile_metadata.metadata.state = ProfileServiceState::kReady;