feat: support Switch 2 controllers and expanded profiles
This commit is contained in:
parent
4e48b7a9e7
commit
a87917bf0b
51 changed files with 4371 additions and 365 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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=$<IF:$<BOOL:${SWITCH_PICO_HAPTICS_EXPERIMENT}>,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)
|
||||
|
|
|
|||
41
README.md
41
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.
|
||||
|
|
|
|||
977
bluepad32_config/parser/uni_hid_parser_switch2.c
Normal file
977
bluepad32_config/parser/uni_hid_parser_switch2.c
Normal file
|
|
@ -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 <math.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <btstack.h>
|
||||
#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);
|
||||
}
|
||||
47
bluepad32_config/parser/uni_hid_parser_switch2.h
Normal file
47
bluepad32_config/parser/uni_hid_parser_switch2.h
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
110
bluepad32_config/parser/uni_switch2_pairing.c
Normal file
110
bluepad32_config/parser/uni_switch2_pairing.c
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <btstack_tlv.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
21
bluepad32_config/parser/uni_switch2_pairing.h
Normal file
21
bluepad32_config/parser/uni_switch2_pairing.h
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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 <string.h>
|
||||
|
||||
@@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@
|
|||
#include <pico/multicore.h>
|
||||
#include <pico/stdlib.h>
|
||||
#include <uni.h>
|
||||
#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<uint8_t>(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<uint8_t>(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<uint8_t>(slot_index),
|
||||
lighting_generation, device, connection_identity);
|
||||
if (!paired) {
|
||||
switch_native_output_attach(static_cast<uint8_t>(slot_index),
|
||||
lighting_generation, device, connection_identity);
|
||||
}
|
||||
#endif
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
haptics_experiment_attach(
|
||||
static_cast<uint8_t>(slot_index), lighting_generation, device);
|
||||
if (!paired && !uni_hid_parser_switch2_is_ble_device(device)) {
|
||||
haptics_experiment_attach(
|
||||
static_cast<uint8_t>(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<uint8_t>(slot_index),
|
||||
lighting_generation, device)) {
|
||||
apply_slot_lighting(
|
||||
static_cast<uint8_t>(slot_index), device);
|
||||
static_cast<uint8_t>(slot_index), lighting_generation, owner)) {
|
||||
apply_slot_lighting(static_cast<uint8_t>(slot_index), owner);
|
||||
if (companion != nullptr) {
|
||||
apply_slot_lighting(static_cast<uint8_t>(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<uint8_t>(slot_index), device,
|
||||
pre_hotkey_button_mask)) {
|
||||
static_cast<uint8_t>(slot_index), owner, pre_hotkey_button_mask)) {
|
||||
switch2_wake_request();
|
||||
}
|
||||
const HotkeyDecision hotkeys = update_controller_hotkeys(
|
||||
static_cast<uint8_t>(slot_index), device);
|
||||
const uint16_t output_button_mask = pre_hotkey_button_mask;
|
||||
static_cast<uint8_t>(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<uint8_t>(slot_index), device,
|
||||
pre_hotkey_button_mask,
|
||||
map_gamepad(
|
||||
gamepad, hotkeys.motion_enabled, output_button_mask));
|
||||
static_cast<uint8_t>(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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(profile.shift.mode) >
|
||||
static_cast<uint8_t>(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<uint8_t>(macro.mode) >
|
||||
static_cast<uint8_t>(ControllerProfileMacroMode::kRepeat) ||
|
||||
macro.repeat_count == 0 ||
|
||||
|
|
@ -503,6 +518,8 @@ bool controller_profile_encode(const ControllerProfile& profile,
|
|||
static_cast<uint8_t>(stream_offset - macro_start);
|
||||
output[336 + macro_index * 2] = static_cast<uint8_t>(macro.mode);
|
||||
output[337 + macro_index * 2] = macro.repeat_count;
|
||||
output[358 + macro_index] = static_cast<uint8_t>(
|
||||
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<uint8_t>(
|
||||
profile.switching_chord >> CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL);
|
||||
output[363] = static_cast<uint8_t>(
|
||||
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<uint32_t>(descriptor[2] & 0x03u) << 16);
|
||||
const uint8_t cancel =
|
||||
static_cast<uint8_t>((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<ControllerProfileMacroMode>(
|
||||
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<ControllerProfileTurboMode>(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<uint32_t>(input[358 + index])
|
||||
<< CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
}
|
||||
profile.switching_chord |= static_cast<uint32_t>(input[362])
|
||||
<< CONTROLLER_PROFILE_FIRST_EXTRA_CONTROL;
|
||||
profile.motion_toggle_chord |= static_cast<uint32_t>(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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(
|
||||
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<uint32_t>(
|
||||
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<uint8_t>(
|
||||
~(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<uint16_t>(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]),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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("<HH", payload)
|
||||
expected_size = (
|
||||
PROFILE_SIZE if version >= 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("<H", payload, 78)[0] | (
|
||||
((payload[75] >> 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("<H", descriptor)[0] | (
|
||||
(descriptor[2] & 0x03) << 16
|
||||
)
|
||||
if has_extra_buttons:
|
||||
trigger_mask |= payload[358 + macro_index] << 18
|
||||
cancel = (descriptor[2] >> 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("<H", payload, 286)[0]
|
||||
settings_offset = 288
|
||||
|
|
@ -1932,7 +1986,8 @@ class ControllerProfile:
|
|||
settings_offset += 3
|
||||
if payload[settings_offset:336] != bytes(336 - settings_offset):
|
||||
raise ConfigManagerError("nonzero Turbo override padding")
|
||||
if payload[344:] != bytes(40):
|
||||
reserved_offset = 364 if has_extra_buttons else 344
|
||||
if any(payload[reserved_offset:]):
|
||||
raise ConfigManagerError("profile reserved fields must be zero")
|
||||
elif any(mode > 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("<H", payload, 286, override_mask)
|
||||
payload[344:351] = bytes(self.extra_button_map)
|
||||
payload[351:358] = bytes(self.shift.extra_button_map)
|
||||
payload[362] = (self.switching_chord >> 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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="controller-hint">Select a highlighted control on the controller to remap it.</p>
|
||||
<p class="field-label" id="extraControlsLabel">Switch 2 extra inputs</p>
|
||||
<div class="extra-controls" id="extraControls" role="group" aria-labelledby="extraControlsLabel"></div>
|
||||
<p class="field-help">C, back and rail buttons are source-only controls. Unmapped extra inputs produce no console output.</p>
|
||||
<p class="controller-credit">
|
||||
Image:
|
||||
<a id="controllerCredit" href="https://github.com/AL2009man/Gamepad-Asset-Pack" target="_blank" rel="noreferrer">Controller artwork by Al. Lopez · MIT</a>
|
||||
|
|
@ -261,6 +264,11 @@
|
|||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="mapping-playtest">
|
||||
<p><strong>Raw extra inputs:</strong> <span id="playtestExtraInputs">None</span></p>
|
||||
<p><strong>Draft button output:</strong> <span id="playtestMappedButtons">None</span></p>
|
||||
<p class="field-help" id="playtestMappingHelp">Base and Shift mapping preview only; shortcuts, macros and Turbo are not simulated.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analog-grid" id="analogFields"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div class="control-card">
|
||||
<label for="shortcut-modifier">Shortcut modifier</label>
|
||||
<select class="select" id="shortcut-modifier" data-kind="shortcut-modifier" aria-describedby="shortcut-modifier-help">${modifierOptions(shortcuts.modifier, shortcuts.profiles)}</select>
|
||||
<p class="field-help" id="shortcut-modifier-help">None clears every shortcut. Analog trigger modifiers require a known Xbox or PlayStation owner; unsupported choices remain unavailable.</p>
|
||||
<p class="field-help" id="shortcut-modifier-help">None clears every shortcut. Switch 2 extra inputs can act as modifiers. Trigger modifiers are available for known Xbox, PlayStation and Switch 2 owners.</p>
|
||||
</div>`;
|
||||
elements.shortcuts.innerHTML = shortcuts.profiles.map((selector, index) => `
|
||||
<div class="control-card">
|
||||
|
|
@ -654,11 +691,14 @@ function renderShift() {
|
|||
<select class="select" id="shift-modifier" data-kind="shift-modifier"${shift.mode === "off" ? " disabled" : ""} aria-describedby="shift-modifier-help">${modifierOptions(shift.modifier)}</select>
|
||||
<p class="field-help" id="shift-modifier-help">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.</p>
|
||||
</div>`;
|
||||
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 `
|
||||
<div class="control-card">
|
||||
<label for="shift-map-${button}">${escapeHtml(controlLabel(button))} → alternate output</label>
|
||||
<select class="select" id="shift-map-${button}" data-kind="shift-map" data-name="${button}"${shift.mode === "off" || shift.modifier === null ? " disabled" : ""}>${buttonOptions(shift.button_map[button], true, state.schema.buttons)}</select>
|
||||
</div>`).join("");
|
||||
<select class="select" id="shift-map-${button}" data-kind="shift-map" data-name="${button}"${shift.mode === "off" || shift.modifier === null ? " disabled" : ""}>${buttonOptions(map[button], true, state.schema.buttons)}</select>
|
||||
</div>`;
|
||||
}).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) =>
|
||||
`<button type="button" data-controller-button="${button}">${escapeHtml(controlLabel(button, style))}</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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
#endif
|
||||
|
||||
#include <uni.h>
|
||||
#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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<uint16_t>(size));
|
||||
write_u16(record + 14, type == 1 ? (version == 1 ? 5 : 6) : 0);
|
||||
write_u16(record + 14, type == 1
|
||||
? static_cast<uint16_t>(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<uint8_t>(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<uint8_t>(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;
|
||||
}
|
||||
|
|
|
|||
8
tests/switch2_pairing_native_stubs/btstack_tlv.h
Normal file
8
tests/switch2_pairing_native_stubs/btstack_tlv.h
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
#include <stdint.h>
|
||||
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**);
|
||||
90
tests/switch2_pairing_test.c
Normal file
90
tests/switch2_pairing_test.c
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <btstack_tlv.h>
|
||||
#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;
|
||||
}
|
||||
22
tests/switch2_parser_native_stubs/protocol_fixture.h
Normal file
22
tests/switch2_parser_native_stubs/protocol_fixture.h
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#pragma once
|
||||
|
||||
#include <btstack.h>
|
||||
#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;
|
||||
};
|
||||
678
tests/switch2_parser_native_test.c
Normal file
678
tests/switch2_parser_native_test.c
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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("<hhhhhh", payload, 42, *self.playtest_motion)
|
||||
return bytes(payload), flags
|
||||
|
|
@ -370,7 +372,7 @@ class FakeDevice:
|
|||
make_response(
|
||||
request,
|
||||
self.profiles[self.selected_profile],
|
||||
schema=config_manager.PROFILE_SCHEMA_VERSION,
|
||||
schema=struct.unpack_from("<H", self.profiles[self.selected_profile])[0],
|
||||
generation=self.profile_generation,
|
||||
)
|
||||
)
|
||||
|
|
@ -1576,7 +1578,7 @@ def test_identity_and_profile_binary_json_round_trip() -> 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("<H", legacy_wire, 0, 6)
|
||||
legacy_wire[344:] = bytes(40)
|
||||
legacy_json = json.loads(profile.to_json())
|
||||
legacy_json["schema_version"] = 6
|
||||
del legacy_json["extra_button_map"]
|
||||
del legacy_json["shift"]["extra_button_map"]
|
||||
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
|
||||
assert config_manager.ControllerProfile.from_json_object(legacy_json) == profile
|
||||
device = FakeDevice()
|
||||
device.profiles[(device.stable_identity.to_bytes(), 1)] = bytes(legacy_wire)
|
||||
assert config_manager.read_profile(device, device.stable_identity, 1) == profile
|
||||
device.profile_aliases[device.stable_identity.to_bytes()] = "Custom controller"
|
||||
old_listing = config_manager.parse_profile_list(config_manager.parse_response(
|
||||
make_response(config_manager.OP_PROFILE_LIST, device._profile_list_payload(), schema=6),
|
||||
config_manager.OP_PROFILE_LIST,
|
||||
))
|
||||
assert old_listing[1] == config_manager.ProfileListEntry(device.stable_identity, 1, "Custom controller")
|
||||
|
||||
|
||||
def test_schema7_extra_controls_keep_output_channels_and_wire_layout() -> 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("<HH", 7, 384)
|
||||
assert encoded[344:351] == bytes((0, 17, 16, 255, 14, 7, 9))
|
||||
assert encoded[351:358] == bytes((1, 255, 2, 3, 8, 12, 15))
|
||||
assert encoded[358:364] == bytes((3, 4, 24, 96, 65, 10))
|
||||
assert encoded[364:] == bytes(20)
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
assert config_manager.ControllerProfile.from_json(profile.to_json()) == profile
|
||||
assert len(profile.button_map) == len(profile.shift.button_map) == len(profile.turbo_modes) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
("button_map", "south"),
|
||||
("extra_button_map", "c"),
|
||||
("triggers", "left", "output"),
|
||||
("shift", "extra_button_map", "c"),
|
||||
])
|
||||
def test_extra_controls_cannot_be_output_destinations(path: tuple[str, ...]) -> 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("<H", payload, 0, 6)
|
||||
payload[344:] = bytes(40)
|
||||
payload[offset] = value
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerProfile.from_bytes(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offset,value", [
|
||||
(344, 18), (351, 16), (358, 128), (362, 128), (363, 128), (364, 1),
|
||||
])
|
||||
def test_schema7_rejects_extra_map_and_mask_overflow(offset: int, value: int) -> 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("<H", payload, 76, 1 << 6)
|
||||
struct.pack_into("<H", payload, 98, 1 << 5)
|
||||
if version == 4:
|
||||
payload[75] = 0x21
|
||||
payload[81] = 17
|
||||
profile = config_manager.ControllerProfile.from_bytes(payload)
|
||||
assert profile.button_map[0] == 17
|
||||
assert profile.weak_rumble_scale == 71
|
||||
assert profile.strong_rumble_scale == 202
|
||||
assert profile.confirmation_policy == 1
|
||||
assert profile.switching_chord == (1 << 6) | ((1 << 16) if version == 4 else 0)
|
||||
assert profile.motion_toggle_chord == (1 << 5) | ((1 << 17) if version == 4 else 0)
|
||||
assert profile.macros[0].trigger_mask == 3
|
||||
assert profile.macros[0].cancel_control == (17 if version == 4 else 2)
|
||||
assert profile.extra_button_map == (255,) * 7
|
||||
obj = profile.to_json_object()
|
||||
obj["schema_version"] = version
|
||||
obj["size"] = 256
|
||||
macro = obj.pop("macros")[0]
|
||||
macro.pop("playback")
|
||||
macro.pop("repeat_count")
|
||||
macro["steps"].append(config_manager.MacroStep.end().to_json_object())
|
||||
obj["macro"] = macro
|
||||
for key in ("shortcuts", "shift", "turbo_settings", "extra_button_map"):
|
||||
del obj[key]
|
||||
assert config_manager.ControllerProfile.from_json_object(obj) == profile
|
||||
assert config_manager.ControllerProfile.from_bytes(profile.to_bytes()) == profile
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
|
|
@ -1963,6 +2088,27 @@ def test_profile_playtest_decodes_raw_controller_state() -> 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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
31
tests/test_switch2_pairing_native.py
Normal file
31
tests/test_switch2_pairing_native.py
Normal file
|
|
@ -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)
|
||||
57
tests/test_switch2_parser_native.py
Normal file
57
tests/test_switch2_parser_native.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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<uint8_t>(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<int16_t>(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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue