Compare commits
4 commits
81f6ac1236
...
fd23842327
| Author | SHA1 | Date | |
|---|---|---|---|
| fd23842327 | |||
| 41c7021813 | |||
| 74a62f035b | |||
| 146c377311 |
23 changed files with 1514 additions and 422 deletions
|
|
@ -93,6 +93,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
|||
target_sources(switch-pico PRIVATE
|
||||
bluepad32_input_backend.cpp
|
||||
bootsel_pairing_button.cpp
|
||||
usb_pairing_management.cpp
|
||||
)
|
||||
target_compile_definitions(switch-pico PRIVATE
|
||||
SWITCH_PICO_BLUEPAD32=1
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -50,34 +50,51 @@ Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad3
|
|||
|
||||
1. Flash and connect the Pico 2 W to the Switch.
|
||||
2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`.
|
||||
3. Hold the Pico's BOOTSEL button for about two seconds, until the onboard LED starts double-blinking. This opens a 60-second pairing window.
|
||||
3. Hold BOOTSEL for about two seconds until the onboard LED starts double-blinking. This enables new Bluetooth authentication for 60 seconds.
|
||||
4. Put a controller into Bluetooth pairing mode:
|
||||
- DualSense: hold Create + PS.
|
||||
- DualShock 4: hold Share + PS.
|
||||
- Switch Pro: press its sync button.
|
||||
- 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 window by 60 seconds from that point.
|
||||
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.
|
||||
|
||||
Outside the BOOTSEL-open window, Bluetooth discovery and incoming connections are disabled. The Pico does not scan for or reconnect disconnected controllers while locked. Pairing keys still persist, but reconnecting a previously paired controller also requires opening the BOOTSEL window before pressing its normal power button.
|
||||
While a slot is free, the Pico continuously runs Bluepad32's normal Bluetooth discovery and autoconnect path. Pairing keys persist across Pico power cycles, so reconnect a previously paired controller by pressing its normal Home, PS, or Xbox power button; BOOTSEL is not required. Outside the BOOTSEL window, BTstack remains non-bondable, rejects new Classic SSP or legacy PIN authentication, and disables every BLE STK generation method. A controller in explicit pairing mode therefore cannot create a new Classic or BLE bond while the window is closed.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
### LED meanings and device state
|
||||
|
||||
The Pico 2 W onboard LED reports the overall Bluetooth state:
|
||||
- **Double blink**: the bounded pairing window is open.
|
||||
- **Double blink**: new controller authentication is enabled for the bounded pairing window.
|
||||
- **Rapid blink for two seconds**: all stored pairings were cleared.
|
||||
- **Fast blink**: a controller connection is still completing its handshake.
|
||||
- **Solid**: at least one controller is active.
|
||||
- **Slow blink**: no controller is active and pairing is locked.
|
||||
- **Slow blink**: no controller is active; Bluetooth discovery and autoconnect are running.
|
||||
- **Solid immediately after boot that never transitions**: Bluepad32 initialization did not complete; check firmware flashing and UART logs.
|
||||
|
||||
### Managing controller disconnect and reconnect
|
||||
|
||||
- **Disconnect a controller**: its slot immediately publishes neutral buttons, sticks, and motion. Other connected controllers are unaffected.
|
||||
- **Reconnect a paired controller**: hold BOOTSEL until the LED double-blinks, then power on the controller normally.
|
||||
- **Reconnect a paired controller**: power it on normally with its Home, PS, or Xbox button.
|
||||
- **8BitDo Ultimate Bluetooth reconnect**: leave its selector in Bluetooth mode, press Home once, then shake it. After an abrupt controller power-off, the Pico can remain solid for up to four seconds while Bluetooth link supervision confirms the disconnect; scanning restarts immediately afterward.
|
||||
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
|
||||
- **Pairing window expires**: scanning and incoming connections stop; already connected controllers remain connected.
|
||||
- **Pairing window expires**: new authentication is disabled; discovery and remembered-controller autoconnect continue while a slot is free.
|
||||
- **Clear all pairings**: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again.
|
||||
|
||||
### Managing pairings from a PC
|
||||
|
||||
Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. The management command uses private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes.
|
||||
|
||||
```sh
|
||||
uv run switch-pico-pairings list
|
||||
uv run switch-pico-pairings clear --yes
|
||||
```
|
||||
|
||||
`list` refreshes and prints stored Bluetooth Classic and BLE addresses. `clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and leaves autoconnect scanning active. The destructive command requires `--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.
|
||||
|
||||
### Per-controller ABXY layout
|
||||
|
||||
|
|
@ -141,11 +158,11 @@ To reproduce the validation:
|
|||
2. **Verify Bluetooth pairing**: Hold BOOTSEL until the LED double-blinks, put a controller into explicit pairing mode, and confirm its player light settles.
|
||||
3. **Verify input on one controller**: Move sticks and press buttons; confirm only its assigned Switch slot changes.
|
||||
4. **Verify input on two controllers**: Move the second controller independently and confirm the first controller's slot is unaffected.
|
||||
5. **Verify the pairing gate**: Disconnect a controller and confirm it does not reconnect while locked. Open the BOOTSEL window, power it on, and confirm it can connect.
|
||||
5. **Verify the pairing gate**: Power-cycle the Pico and confirm a paired controller reconnects with its normal Home/PS/Xbox button without BOOTSEL. Put an unpaired controller into explicit pairing mode and confirm it remains blocked until the BOOTSEL window opens.
|
||||
6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates.
|
||||
7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output).
|
||||
|
||||
On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware.
|
||||
On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware. The pairing CLI uses vendor control transfers on endpoint 0 and does not depend on those hidraw nodes.
|
||||
|
||||
Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license.
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ constexpr uint16_t kRumbleDurationMs = 50;
|
|||
constexpr uint32_t kRumblePollIntervalMs = 5;
|
||||
constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT;
|
||||
constexpr uint32_t kPairingWindowDurationMs = 60000;
|
||||
constexpr uint32_t kPairingResetFeedbackDurationMs = 2000;
|
||||
// Bluetooth Classic units are 0.625 ms: 0x1900 = 4 seconds.
|
||||
constexpr uint16_t kClassicLinkSupervisionTimeout = 0x1900;
|
||||
constexpr uint8_t kAllBlePairingMethods =
|
||||
SM_STK_GENERATION_METHOD_JUST_WORKS |
|
||||
SM_STK_GENERATION_METHOD_OOB |
|
||||
SM_STK_GENERATION_METHOD_PASSKEY |
|
||||
SM_STK_GENERATION_METHOD_NUMERIC_COMPARISON;
|
||||
constexpr uint32_t kAbxyHotkeyButtonMask =
|
||||
SWITCH_ABXY_HOTKEY_BUTTON_MASK;
|
||||
constexpr uint32_t kAbxyHotkeyMiscMask = SWITCH_ABXY_HOTKEY_MISC_MASK;
|
||||
|
|
@ -77,7 +85,6 @@ enum class ConnectionStatus {
|
|||
enum class ConnectionPolicyState {
|
||||
Uninitialized,
|
||||
Open,
|
||||
Locked,
|
||||
Paused,
|
||||
FailedClosed,
|
||||
};
|
||||
|
|
@ -121,18 +128,23 @@ BackendSlot g_slots[kSlotCount];
|
|||
uint32_t g_consumed_generation[kSlotCount]{};
|
||||
uint32_t g_last_snapshot_generation[kSlotCount]{};
|
||||
bool g_pairing_window_requested = false;
|
||||
bool g_clear_pairings_requested = false;
|
||||
bool g_pairing_snapshot_requested = false;
|
||||
bool g_initialized = false;
|
||||
bool g_started = false;
|
||||
|
||||
// These fields are only read or written by Core 1 / BTstack.
|
||||
btstack_timer_source_t g_rumble_timer{};
|
||||
ConnectionStatus g_connection_status = ConnectionStatus::Initializing;
|
||||
btstack_packet_callback_registration_t g_pairing_event_callback{};
|
||||
ConnectionPolicyState g_connection_policy_state =
|
||||
ConnectionPolicyState::Uninitialized;
|
||||
uint32_t g_pairing_window_deadline_ms = 0;
|
||||
uint32_t g_pairing_reset_feedback_deadline_ms = 0;
|
||||
uint16_t g_status_led_tick = 0;
|
||||
bool g_pairing_window_open = false;
|
||||
bool g_status_led_on = false;
|
||||
Bluepad32PairingSnapshot g_pairing_snapshot{};
|
||||
|
||||
SwitchInputState make_neutral_state() {
|
||||
SwitchInputState state{};
|
||||
|
|
@ -447,6 +459,38 @@ bool pairing_window_active_at(uint32_t now_ms) {
|
|||
static_cast<int32_t>(now_ms - g_pairing_window_deadline_ms) < 0;
|
||||
}
|
||||
|
||||
void handle_pairing_hci_event(uint8_t packet_type, uint16_t channel,
|
||||
uint8_t* packet, uint16_t size) {
|
||||
(void)channel;
|
||||
if (packet_type != HCI_EVENT_PACKET || packet == nullptr || size < 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
bd_addr_t address{};
|
||||
const bool pairing_open =
|
||||
pairing_window_active_at(btstack_run_loop_get_time_ms());
|
||||
switch (hci_event_packet_get_type(packet)) {
|
||||
case HCI_EVENT_USER_CONFIRMATION_REQUEST:
|
||||
hci_event_user_confirmation_request_get_bd_addr(packet, address);
|
||||
if (pairing_open) {
|
||||
gap_ssp_confirmation_response(address);
|
||||
} else {
|
||||
gap_ssp_confirmation_negative(address);
|
||||
}
|
||||
break;
|
||||
case HCI_EVENT_USER_PASSKEY_REQUEST:
|
||||
hci_event_user_passkey_request_get_bd_addr(packet, address);
|
||||
if (pairing_open) {
|
||||
gap_ssp_passkey_response(address, 0);
|
||||
} else {
|
||||
gap_ssp_passkey_negative(address);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool update_pairing_window(uint32_t now_ms) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const bool requested = g_pairing_window_requested;
|
||||
|
|
@ -456,26 +500,134 @@ bool update_pairing_window(uint32_t now_ms) {
|
|||
if (requested) {
|
||||
g_pairing_window_open = true;
|
||||
g_pairing_window_deadline_ms = now_ms + kPairingWindowDurationMs;
|
||||
gap_set_bondable_mode(true);
|
||||
sm_set_accepted_stk_generation_methods(kAllBlePairingMethods);
|
||||
g_status_led_tick = 0;
|
||||
return true;
|
||||
}
|
||||
if (g_pairing_window_open && !pairing_window_active_at(now_ms)) {
|
||||
g_pairing_window_open = false;
|
||||
sm_set_accepted_stk_generation_methods(0);
|
||||
g_status_led_tick = 0;
|
||||
gap_set_bondable_mode(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void append_pairing_record(
|
||||
Bluepad32PairingSnapshot& snapshot,
|
||||
Bluepad32PairingTransport transport, uint8_t address_type,
|
||||
const bd_addr_t address) {
|
||||
if (snapshot.record_count >= BLUEPAD32_PAIRING_RECORD_CAPACITY) {
|
||||
snapshot.overflow = true;
|
||||
return;
|
||||
}
|
||||
Bluepad32PairingRecord& record =
|
||||
snapshot.records[snapshot.record_count++];
|
||||
record.transport = transport;
|
||||
record.address_type = address_type;
|
||||
memcpy(record.address, address, sizeof(record.address));
|
||||
}
|
||||
|
||||
void apply_connection_policy(uint32_t now_ms) {
|
||||
void refresh_pairing_snapshot() {
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
|
||||
btstack_link_key_iterator_t iterator{};
|
||||
if (gap_link_key_iterator_init(&iterator)) {
|
||||
bd_addr_t address{};
|
||||
link_key_t link_key{};
|
||||
link_key_type_t link_key_type{};
|
||||
while (gap_link_key_iterator_get_next(
|
||||
&iterator, address, link_key, &link_key_type)) {
|
||||
append_pairing_record(
|
||||
snapshot, Bluepad32PairingTransport::kClassic,
|
||||
BD_ADDR_TYPE_UNKNOWN, address);
|
||||
}
|
||||
gap_link_key_iterator_done(&iterator);
|
||||
}
|
||||
|
||||
for (int index = 0; index < le_device_db_max_count(); ++index) {
|
||||
int address_type = BD_ADDR_TYPE_UNKNOWN;
|
||||
bd_addr_t address{};
|
||||
le_device_db_info(index, &address_type, address, nullptr);
|
||||
if (address_type == BD_ADDR_TYPE_UNKNOWN) {
|
||||
continue;
|
||||
}
|
||||
append_pairing_record(
|
||||
snapshot, Bluepad32PairingTransport::kBle,
|
||||
static_cast<uint8_t>(address_type), address);
|
||||
}
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
snapshot.generation = g_pairing_snapshot.generation + 1;
|
||||
g_pairing_snapshot = snapshot;
|
||||
g_pairing_snapshot_requested = false;
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
||||
void process_pairing_snapshot_request() {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const bool requested = g_pairing_snapshot_requested;
|
||||
critical_section_exit(&g_state_lock);
|
||||
if (requested) {
|
||||
refresh_pairing_snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
void apply_connection_policy();
|
||||
|
||||
void process_clear_pairings(uint32_t now_ms) {
|
||||
uni_hid_device_t* devices[kSlotCount]{};
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const bool requested = g_clear_pairings_requested;
|
||||
g_clear_pairings_requested = false;
|
||||
if (requested) {
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
if (!requested) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_pairing_window_open = false;
|
||||
gap_set_bondable_mode(false);
|
||||
sm_set_accepted_stk_generation_methods(0);
|
||||
uni_bt_del_keys_unsafe();
|
||||
for (uni_hid_device_t* device : devices) {
|
||||
if (device != nullptr) {
|
||||
uni_hid_device_disconnect(device);
|
||||
}
|
||||
}
|
||||
refresh_pairing_snapshot();
|
||||
|
||||
g_connection_status = ConnectionStatus::Scanning;
|
||||
g_status_led_tick = 0;
|
||||
g_pairing_reset_feedback_deadline_ms =
|
||||
now_ms + kPairingResetFeedbackDurationMs;
|
||||
apply_connection_policy();
|
||||
}
|
||||
|
||||
|
||||
void apply_connection_policy() {
|
||||
const bool free_slot = has_free_slot();
|
||||
const bool pairing_open = pairing_window_active_at(now_ms);
|
||||
if ((!free_slot &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Paused) ||
|
||||
(free_slot && pairing_open &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Open) ||
|
||||
(free_slot && !pairing_open &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Locked)) {
|
||||
(free_slot &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Open)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -487,13 +639,8 @@ void apply_connection_policy(uint32_t now_ms) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!pairing_open) {
|
||||
g_connection_policy_state = ConnectionPolicyState::Locked;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use Bluepad32's normal pairing/autoconnect path while the physical
|
||||
// BOOTSEL gesture has explicitly opened the pairing window.
|
||||
// Bluepad32's normal scan/autoconnect path handles both remembered
|
||||
// controllers powering on and controllers in explicit pairing mode.
|
||||
uni_bt_allow_incoming_connections(true);
|
||||
uni_bt_start_scanning_and_autoconnect_unsafe();
|
||||
g_connection_policy_state = ConnectionPolicyState::Open;
|
||||
|
|
@ -501,9 +648,13 @@ void apply_connection_policy(uint32_t now_ms) {
|
|||
|
||||
void update_status_led() {
|
||||
++g_status_led_tick;
|
||||
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
bool led_on = false;
|
||||
|
||||
if (pairing_window_active_at(btstack_run_loop_get_time_ms())) {
|
||||
if (static_cast<int32_t>(
|
||||
now_ms - g_pairing_reset_feedback_deadline_ms) < 0) {
|
||||
led_on = (g_status_led_tick % 20) < 10;
|
||||
} else if (pairing_window_active_at(now_ms)) {
|
||||
const uint16_t phase = g_status_led_tick % 200;
|
||||
led_on = phase < 20 || (phase >= 40 && phase < 60);
|
||||
} else if (g_connection_status == ConnectionStatus::Connecting) {
|
||||
|
|
@ -523,9 +674,9 @@ void update_status_led() {
|
|||
|
||||
void process_rumble_timer(btstack_timer_source_t* timer) {
|
||||
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
if (update_pairing_window(now_ms)) {
|
||||
apply_connection_policy(now_ms);
|
||||
}
|
||||
process_clear_pairings(now_ms);
|
||||
process_pairing_snapshot_request();
|
||||
update_pairing_window(now_ms);
|
||||
|
||||
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
|
||||
RumbleEnvelope envelope{};
|
||||
|
|
@ -591,7 +742,7 @@ void recompute_connection_status() {
|
|||
update_pairing_window(now_ms);
|
||||
g_connection_status = compute_connection_status();
|
||||
g_status_led_tick = 0;
|
||||
apply_connection_policy(now_ms);
|
||||
apply_connection_policy();
|
||||
}
|
||||
|
||||
void platform_init(int argc, const char** argv) {
|
||||
|
|
@ -600,8 +751,14 @@ void platform_init(int argc, const char** argv) {
|
|||
}
|
||||
|
||||
void platform_on_init_complete() {
|
||||
// Discovery and incoming connections remain disabled until BOOTSEL opens
|
||||
// the bounded pairing window.
|
||||
gap_set_link_supervision_timeout(kClassicLinkSupervisionTimeout);
|
||||
gap_set_bondable_mode(false);
|
||||
sm_set_accepted_stk_generation_methods(0);
|
||||
gap_ssp_set_auto_accept(false);
|
||||
g_pairing_event_callback.callback = handle_pairing_hci_event;
|
||||
hci_add_event_handler(&g_pairing_event_callback);
|
||||
refresh_pairing_snapshot();
|
||||
// Keep Bluepad32 autoconnect active whenever at least one slot is free.
|
||||
btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer);
|
||||
btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs);
|
||||
btstack_run_loop_add_timer(&g_rumble_timer);
|
||||
|
|
@ -676,6 +833,10 @@ void platform_on_device_disconnected(uni_hid_device_t* device) {
|
|||
critical_section_exit(&g_state_lock);
|
||||
|
||||
if (disconnected_tracked_device) {
|
||||
// A controller can disconnect while the policy is already Open.
|
||||
// Restart both scans so host-initiated reconnect controllers such as
|
||||
// 8BitDo Ultimate become reachable without rebooting the Pico.
|
||||
g_connection_policy_state = ConnectionPolicyState::Uninitialized;
|
||||
recompute_connection_status();
|
||||
}
|
||||
}
|
||||
|
|
@ -815,9 +976,15 @@ void bluepad32_input_backend_init() {
|
|||
g_last_snapshot_generation[slot_index] = 0;
|
||||
}
|
||||
g_pairing_window_requested = false;
|
||||
g_pairing_snapshot_requested = false;
|
||||
g_pairing_snapshot = {};
|
||||
g_pairing_snapshot.status =
|
||||
Bluepad32PairingSnapshotStatus::kPending;
|
||||
g_clear_pairings_requested = false;
|
||||
g_connection_status = ConnectionStatus::Initializing;
|
||||
g_connection_policy_state = ConnectionPolicyState::Uninitialized;
|
||||
g_pairing_window_deadline_ms = 0;
|
||||
g_pairing_reset_feedback_deadline_ms = 0;
|
||||
g_pairing_window_open = false;
|
||||
g_initialized = true;
|
||||
}
|
||||
|
|
@ -850,6 +1017,44 @@ void bluepad32_input_backend_open_pairing_window() {
|
|||
g_pairing_window_requested = true;
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
void bluepad32_input_backend_clear_pairings() {
|
||||
if (!g_initialized) {
|
||||
bluepad32_input_backend_init();
|
||||
}
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
g_clear_pairings_requested = true;
|
||||
g_pairing_snapshot.status =
|
||||
Bluepad32PairingSnapshotStatus::kPending;
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_request_pairing_snapshot() {
|
||||
if (!g_initialized) {
|
||||
bluepad32_input_backend_init();
|
||||
}
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
g_pairing_snapshot_requested = true;
|
||||
g_pairing_snapshot.status =
|
||||
Bluepad32PairingSnapshotStatus::kPending;
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out) {
|
||||
if (out == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (!g_initialized) {
|
||||
bluepad32_input_backend_init();
|
||||
}
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
*out = g_pairing_snapshot;
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
||||
|
||||
bool bluepad32_input_backend_snapshot(uint8_t slot_index, SwitchInputState* out) {
|
||||
if (out == nullptr || !valid_slot(slot_index)) {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,41 @@
|
|||
#include "switch_pro_driver.h"
|
||||
|
||||
constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4;
|
||||
constexpr uint8_t BLUEPAD32_PAIRING_RECORD_CAPACITY = 16;
|
||||
|
||||
enum class Bluepad32PairingTransport : uint8_t {
|
||||
kClassic = 1,
|
||||
kBle = 2,
|
||||
};
|
||||
|
||||
enum class Bluepad32PairingSnapshotStatus : uint8_t {
|
||||
kReady = 0,
|
||||
kPending = 1,
|
||||
};
|
||||
|
||||
struct Bluepad32PairingRecord {
|
||||
Bluepad32PairingTransport transport;
|
||||
uint8_t address_type;
|
||||
uint8_t address[6];
|
||||
};
|
||||
|
||||
struct Bluepad32PairingSnapshot {
|
||||
uint32_t generation;
|
||||
Bluepad32PairingSnapshotStatus status;
|
||||
uint8_t record_count;
|
||||
bool overflow;
|
||||
Bluepad32PairingRecord records[BLUEPAD32_PAIRING_RECORD_CAPACITY];
|
||||
};
|
||||
|
||||
|
||||
void bluepad32_input_backend_init();
|
||||
void bluepad32_input_backend_start();
|
||||
void bluepad32_input_backend_open_pairing_window();
|
||||
void bluepad32_input_backend_clear_pairings();
|
||||
bool bluepad32_input_backend_snapshot(uint8_t slot, SwitchInputState* out);
|
||||
void bluepad32_input_backend_request_pairing_snapshot();
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out);
|
||||
void bluepad32_input_backend_report_sent(uint8_t slot);
|
||||
void bluepad32_input_backend_queue_rumble(uint8_t slot,
|
||||
const SwitchRumbleOutput& rumble);
|
||||
|
|
|
|||
|
|
@ -62,36 +62,38 @@ BootselPairingButtonSample sample_bootsel() {
|
|||
|
||||
} // namespace
|
||||
|
||||
bool BootselPairingButtonHoldFsm::update(
|
||||
BootselPairingButtonEvent BootselPairingButtonHoldFsm::update(
|
||||
BootselPairingButtonSample sample) {
|
||||
if (sample == BootselPairingButtonSample::kUnread) {
|
||||
return false;
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
|
||||
if (sample == BootselPairingButtonSample::kReleased) {
|
||||
pressed_samples_ = 0;
|
||||
hold_reported_ = false;
|
||||
return false;
|
||||
pairing_reported_ = false;
|
||||
clear_reported_ = false;
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
|
||||
if (hold_reported_) {
|
||||
return false;
|
||||
if (pressed_samples_ < kClearHoldSamples) {
|
||||
++pressed_samples_;
|
||||
}
|
||||
|
||||
++pressed_samples_;
|
||||
if (pressed_samples_ < kHoldSamples) {
|
||||
return false;
|
||||
if (pressed_samples_ >= kClearHoldSamples && !clear_reported_) {
|
||||
clear_reported_ = true;
|
||||
return BootselPairingButtonEvent::kClearPairings;
|
||||
}
|
||||
|
||||
hold_reported_ = true;
|
||||
return true;
|
||||
if (pressed_samples_ >= kPairingHoldSamples && !pairing_reported_) {
|
||||
pairing_reported_ = true;
|
||||
return BootselPairingButtonEvent::kOpenPairing;
|
||||
}
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
|
||||
bool bootsel_pairing_button_task() {
|
||||
BootselPairingButtonEvent bootsel_pairing_button_task() {
|
||||
const uint32_t now_ms =
|
||||
static_cast<uint32_t>(to_ms_since_boot(get_absolute_time()));
|
||||
if (now_ms - g_last_sample_ms < kPollIntervalMs) {
|
||||
return false;
|
||||
return BootselPairingButtonEvent::kNone;
|
||||
}
|
||||
g_last_sample_ms = now_ms;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,26 @@ enum class BootselPairingButtonSample : uint8_t {
|
|||
kReleased,
|
||||
kPressed,
|
||||
};
|
||||
enum class BootselPairingButtonEvent : uint8_t {
|
||||
kNone,
|
||||
kOpenPairing,
|
||||
kClearPairings,
|
||||
};
|
||||
|
||||
|
||||
class BootselPairingButtonHoldFsm {
|
||||
public:
|
||||
static constexpr uint8_t kHoldSamples = 20;
|
||||
static constexpr uint8_t kPairingHoldSamples = 20;
|
||||
static constexpr uint8_t kClearHoldSamples = 100;
|
||||
|
||||
bool update(BootselPairingButtonSample sample);
|
||||
BootselPairingButtonEvent update(BootselPairingButtonSample sample);
|
||||
|
||||
private:
|
||||
uint8_t pressed_samples_ = 0;
|
||||
bool hold_reported_ = false;
|
||||
bool pairing_reported_ = false;
|
||||
bool clear_reported_ = false;
|
||||
};
|
||||
|
||||
// Polls BOOTSEL at 10 Hz. Returns true once when a 20-sample hold completes.
|
||||
bool bootsel_pairing_button_task();
|
||||
// Polls BOOTSEL at 10 Hz. Reports pairing at 2 seconds and clearing at
|
||||
// 10 seconds; each event fires once per continuous hold.
|
||||
BootselPairingButtonEvent bootsel_pairing_button_task();
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -1,287 +1,26 @@
|
|||
diff --git a/src/components/bluepad32/include/parser/uni_hid_parser_imu.h b/src/components/bluepad32/include/parser/uni_hid_parser_imu.h
|
||||
new file mode 100644
|
||||
index 0000000..dbd3024
|
||||
--- /dev/null
|
||||
+++ b/src/components/bluepad32/include/parser/uni_hid_parser_imu.h
|
||||
@@ -0,0 +1,278 @@
|
||||
+// SPDX-License-Identifier: Apache-2.0
|
||||
+// Fixed-point IMU normalization helpers shared by controller parsers.
|
||||
+
|
||||
+#ifndef UNI_HID_PARSER_IMU_H
|
||||
+#define UNI_HID_PARSER_IMU_H
|
||||
+
|
||||
+#include <stdbool.h>
|
||||
+#include <stdint.h>
|
||||
+#include <string.h>
|
||||
+
|
||||
+#define UNI_IMU_ACCEL_RES_PER_G 8192
|
||||
+#define UNI_IMU_GYRO_RES_PER_DEG_S 1024
|
||||
+
|
||||
+typedef struct {
|
||||
+ int32_t accel[3];
|
||||
+ int32_t gyro[3];
|
||||
+} uni_imu_fixed_sample_t;
|
||||
+
|
||||
+static inline void uni_imu_normalize_wii_accel(int16_t x,
|
||||
+ int16_t y,
|
||||
+ int16_t z,
|
||||
+ int32_t out[3]) {
|
||||
+ // SDL's Wii convention is (-X, Z, Y), with about 100 raw counts per g.
|
||||
+ out[0] = -(int32_t)x * UNI_IMU_ACCEL_RES_PER_G / 100;
|
||||
+ out[1] = (int32_t)z * UNI_IMU_ACCEL_RES_PER_G / 100;
|
||||
+ out[2] = (int32_t)y * UNI_IMU_ACCEL_RES_PER_G / 100;
|
||||
+}
|
||||
+
|
||||
+typedef enum {
|
||||
+ UNI_PSMOVE_IMU_MODEL_ZCM1,
|
||||
+ UNI_PSMOVE_IMU_MODEL_ZCM2,
|
||||
+} uni_psmove_imu_model_t;
|
||||
+
|
||||
+typedef enum {
|
||||
+ UNI_PSMOVE_CALIBRATION_IGNORED,
|
||||
+ UNI_PSMOVE_CALIBRATION_INCOMPLETE,
|
||||
+ UNI_PSMOVE_CALIBRATION_COMPLETE,
|
||||
+ UNI_PSMOVE_CALIBRATION_INVALID,
|
||||
+} uni_psmove_calibration_result_t;
|
||||
+
|
||||
+#define UNI_PSMOVE_CALIBRATION_REPORT_SIZE 49
|
||||
+#define UNI_PSMOVE_ZCM1_CALIBRATION_SIZE 143
|
||||
+#define UNI_PSMOVE_ZCM2_CALIBRATION_SIZE 96
|
||||
+
|
||||
+typedef struct {
|
||||
+ uint8_t blob[UNI_PSMOVE_ZCM1_CALIBRATION_SIZE];
|
||||
+ uint8_t received_blocks;
|
||||
+ bool valid;
|
||||
+} uni_psmove_imu_calibration_t;
|
||||
+
|
||||
+static inline int32_t uni_psmove_decode_calibration_value(
|
||||
+ const uni_psmove_imu_calibration_t* calibration,
|
||||
+ uni_psmove_imu_model_t model,
|
||||
+ uint8_t offset) {
|
||||
+ const uint16_t value =
|
||||
+ (uint16_t)calibration->blob[offset] |
|
||||
+ ((uint16_t)calibration->blob[offset + 1] << 8u);
|
||||
+ return model == UNI_PSMOVE_IMU_MODEL_ZCM1
|
||||
+ ? (int32_t)value - 0x8000
|
||||
+ : (int32_t)(int16_t)value;
|
||||
+}
|
||||
+
|
||||
+static inline int32_t uni_psmove_decode_input_value(
|
||||
+ uni_psmove_imu_model_t model,
|
||||
+ uint16_t value) {
|
||||
+ return model == UNI_PSMOVE_IMU_MODEL_ZCM1
|
||||
+ ? (int32_t)value - 0x8000
|
||||
+ : (int32_t)(int16_t)value;
|
||||
+}
|
||||
+
|
||||
+static inline void uni_psmove_get_accel_bounds(
|
||||
+ const uni_psmove_imu_calibration_t* calibration,
|
||||
+ uni_psmove_imu_model_t model,
|
||||
+ uint8_t axis,
|
||||
+ int32_t* low,
|
||||
+ int32_t* high) {
|
||||
+ static const uint8_t zcm1_low_offsets[3] = {0x0a, 0x24, 0x14};
|
||||
+ static const uint8_t zcm1_high_offsets[3] = {0x16, 0x1e, 0x08};
|
||||
+ static const uint8_t zcm2_low_offsets[3] = {0x08, 0x16, 0x24};
|
||||
+ static const uint8_t zcm2_high_offsets[3] = {0x02, 0x10, 0x1e};
|
||||
+ const uint8_t* low_offsets =
|
||||
+ model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_low_offsets
|
||||
+ : zcm2_low_offsets;
|
||||
+ const uint8_t* high_offsets =
|
||||
+ model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? zcm1_high_offsets
|
||||
+ : zcm2_high_offsets;
|
||||
+ *low = uni_psmove_decode_calibration_value(calibration, model,
|
||||
+ low_offsets[axis]);
|
||||
+ *high = uni_psmove_decode_calibration_value(calibration, model,
|
||||
+ high_offsets[axis]);
|
||||
+}
|
||||
+
|
||||
+static inline void uni_psmove_get_gyro_calibration(
|
||||
+ const uni_psmove_imu_calibration_t* calibration,
|
||||
+ uni_psmove_imu_model_t model,
|
||||
+ uint8_t axis,
|
||||
+ int32_t* offset,
|
||||
+ int32_t* divisor,
|
||||
+ int32_t* full_scale) {
|
||||
+ static const uint8_t zcm1_bias_offsets[3] = {0x2a, 0x2c, 0x2e};
|
||||
+ static const uint8_t zcm1_high_offsets[3] = {0x46, 0x50, 0x5a};
|
||||
+ static const uint8_t zcm2_bias_offsets[3] = {0x26, 0x28, 0x2a};
|
||||
+ static const uint8_t zcm2_low_offsets[3] = {0x42, 0x4a, 0x52};
|
||||
+ static const uint8_t zcm2_high_offsets[3] = {0x30, 0x38, 0x40};
|
||||
+
|
||||
+ if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
|
||||
+ *offset = uni_psmove_decode_calibration_value(
|
||||
+ calibration, model, zcm1_bias_offsets[axis]);
|
||||
+ const int32_t high = uni_psmove_decode_calibration_value(
|
||||
+ calibration, model, zcm1_high_offsets[axis]);
|
||||
+ *divisor = high - *offset;
|
||||
+ // ZCM1 gyro points are measured at +80 RPM = +480 degrees/s.
|
||||
+ *full_scale = 480 * UNI_IMU_GYRO_RES_PER_DEG_S;
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ *offset = uni_psmove_decode_calibration_value(
|
||||
+ calibration, model, zcm2_bias_offsets[axis]);
|
||||
+ const int32_t low = uni_psmove_decode_calibration_value(
|
||||
+ calibration, model, zcm2_low_offsets[axis]);
|
||||
+ const int32_t high = uni_psmove_decode_calibration_value(
|
||||
+ calibration, model, zcm2_high_offsets[axis]);
|
||||
+ *divisor = high - low;
|
||||
+ // ZCM2 points span -90 to +90 RPM = 1080 degrees/s total.
|
||||
+ *full_scale = 1080 * UNI_IMU_GYRO_RES_PER_DEG_S;
|
||||
+}
|
||||
+
|
||||
+static inline bool uni_psmove_validate_calibration(
|
||||
+ const uni_psmove_imu_calibration_t* calibration,
|
||||
+ uni_psmove_imu_model_t model) {
|
||||
+ for (uint8_t axis = 0; axis < 3; ++axis) {
|
||||
+ int32_t low;
|
||||
+ int32_t high;
|
||||
+ uni_psmove_get_accel_bounds(calibration, model, axis, &low, &high);
|
||||
+ if (high <= low) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ int32_t offset;
|
||||
+ int32_t divisor;
|
||||
+ int32_t full_scale;
|
||||
+ uni_psmove_get_gyro_calibration(calibration, model, axis, &offset,
|
||||
+ &divisor, &full_scale);
|
||||
+ (void)offset;
|
||||
+ (void)full_scale;
|
||||
+ if (divisor <= 0) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ }
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
+static inline uni_psmove_calibration_result_t
|
||||
+uni_psmove_add_calibration_report(
|
||||
+ uni_psmove_imu_calibration_t* calibration,
|
||||
+ uni_psmove_imu_model_t model,
|
||||
+ const uint8_t* report,
|
||||
+ uint16_t len) {
|
||||
+ if (calibration == NULL || report == NULL ||
|
||||
+ len < UNI_PSMOVE_CALIBRATION_REPORT_SIZE || report[0] != 0x10) {
|
||||
+ return UNI_PSMOVE_CALIBRATION_IGNORED;
|
||||
+ }
|
||||
+
|
||||
+ size_t destination;
|
||||
+ size_t source;
|
||||
+ uint8_t block_mask;
|
||||
+ if (report[1] == 0x00) {
|
||||
+ destination = 0;
|
||||
+ source = 0;
|
||||
+ block_mask = 0x01;
|
||||
+ } else if (model == UNI_PSMOVE_IMU_MODEL_ZCM1 && report[1] == 0x01) {
|
||||
+ destination = UNI_PSMOVE_CALIBRATION_REPORT_SIZE;
|
||||
+ source = 2;
|
||||
+ block_mask = 0x02;
|
||||
+ } else if (model == UNI_PSMOVE_IMU_MODEL_ZCM1 && report[1] == 0x82) {
|
||||
+ destination = 2 * UNI_PSMOVE_CALIBRATION_REPORT_SIZE - 2;
|
||||
+ source = 2;
|
||||
+ block_mask = 0x04;
|
||||
+ } else if (model == UNI_PSMOVE_IMU_MODEL_ZCM2 && report[1] == 0x81) {
|
||||
+ destination = UNI_PSMOVE_CALIBRATION_REPORT_SIZE;
|
||||
+ source = 2;
|
||||
+ block_mask = 0x02;
|
||||
+ } else {
|
||||
+ calibration->valid = false;
|
||||
+ return UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
+ }
|
||||
+
|
||||
+ memcpy(calibration->blob + destination, report + source,
|
||||
+ UNI_PSMOVE_CALIBRATION_REPORT_SIZE - source);
|
||||
+ calibration->received_blocks |= block_mask;
|
||||
+
|
||||
+ const uint8_t required_blocks =
|
||||
+ model == UNI_PSMOVE_IMU_MODEL_ZCM1 ? 0x07 : 0x03;
|
||||
+ if ((calibration->received_blocks & required_blocks) != required_blocks) {
|
||||
+ return UNI_PSMOVE_CALIBRATION_INCOMPLETE;
|
||||
+ }
|
||||
+
|
||||
+ calibration->valid =
|
||||
+ uni_psmove_validate_calibration(calibration, model);
|
||||
+ return calibration->valid ? UNI_PSMOVE_CALIBRATION_COMPLETE
|
||||
+ : UNI_PSMOVE_CALIBRATION_INVALID;
|
||||
+}
|
||||
+
|
||||
+static inline int32_t uni_psmove_scale_accel(int32_t raw,
|
||||
+ int32_t low,
|
||||
+ int32_t high) {
|
||||
+ const int64_t numerator =
|
||||
+ (int64_t)(raw - low) * (2 * UNI_IMU_ACCEL_RES_PER_G);
|
||||
+ return (int32_t)(numerator / (high - low)) - UNI_IMU_ACCEL_RES_PER_G;
|
||||
+}
|
||||
+
|
||||
+static inline int32_t uni_imu_clamp_i64(int64_t value) {
|
||||
+ if (value > INT32_MAX) {
|
||||
+ return INT32_MAX;
|
||||
+ }
|
||||
+ if (value < INT32_MIN) {
|
||||
+ return INT32_MIN;
|
||||
+ }
|
||||
+ return (int32_t)value;
|
||||
+}
|
||||
+
|
||||
+static inline int32_t uni_psmove_scale_gyro(int32_t raw,
|
||||
+ int32_t offset,
|
||||
+ int32_t divisor,
|
||||
+ int32_t full_scale) {
|
||||
+ const int64_t scaled =
|
||||
+ ((int64_t)(raw - offset) * full_scale) / divisor;
|
||||
+ return uni_imu_clamp_i64(scaled);
|
||||
+}
|
||||
+
|
||||
+static inline bool uni_psmove_normalize_imu(
|
||||
+ uni_psmove_imu_model_t model,
|
||||
+ const uni_psmove_imu_calibration_t* calibration,
|
||||
+ const uint16_t accel_first[3],
|
||||
+ const uint16_t accel_second[3],
|
||||
+ const uint16_t gyro_first[3],
|
||||
+ const uint16_t gyro_second[3],
|
||||
+ uni_imu_fixed_sample_t* out) {
|
||||
+ if (out == NULL) {
|
||||
+ return false;
|
||||
+ }
|
||||
+ memset(out, 0, sizeof(*out));
|
||||
+ if (calibration == NULL || !calibration->valid || accel_first == NULL ||
|
||||
+ accel_second == NULL || gyro_first == NULL || gyro_second == NULL) {
|
||||
+ return false;
|
||||
+ }
|
||||
+
|
||||
+ for (uint8_t axis = 0; axis < 3; ++axis) {
|
||||
+ int32_t accel = uni_psmove_decode_input_value(model,
|
||||
+ accel_first[axis]);
|
||||
+ int32_t gyro = uni_psmove_decode_input_value(model,
|
||||
+ gyro_first[axis]);
|
||||
+ if (model == UNI_PSMOVE_IMU_MODEL_ZCM1) {
|
||||
+ accel = (accel + uni_psmove_decode_input_value(
|
||||
+ model, accel_second[axis])) /
|
||||
+ 2;
|
||||
+ gyro = (gyro + uni_psmove_decode_input_value(
|
||||
+ model, gyro_second[axis])) /
|
||||
+ 2;
|
||||
+ }
|
||||
+
|
||||
+ int32_t low;
|
||||
+ int32_t high;
|
||||
+ uni_psmove_get_accel_bounds(calibration, model, axis, &low, &high);
|
||||
+ out->accel[axis] = uni_psmove_scale_accel(accel, low, high);
|
||||
+
|
||||
+ int32_t offset;
|
||||
+ int32_t divisor;
|
||||
+ int32_t full_scale;
|
||||
+ uni_psmove_get_gyro_calibration(calibration, model, axis, &offset,
|
||||
+ &divisor, &full_scale);
|
||||
+ out->gyro[axis] = uni_psmove_scale_gyro(
|
||||
+ gyro, offset, divisor, full_scale);
|
||||
+ }
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
+#endif // UNI_HID_PARSER_IMU_H
|
||||
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
|
||||
status = l2cap_event_channel_opened_get_status(packet);
|
||||
if (status) {
|
||||
logi("L2CAP Connection failed: 0x%02x.\n", status);
|
||||
- // Practice showed that if the connection fails, just disconnect/remove
|
||||
- // so that the connection can start again.
|
||||
+ // Channel-open failures also include transient page timeouts when a
|
||||
+ // paired controller powers down or is temporarily unreachable. Keep
|
||||
+ // the persistent key so the controller can reconnect later. Users can
|
||||
+ // remove genuinely stale keys through the explicit pairing reset.
|
||||
if (status == L2CAP_CONNECTION_RESPONSE_RESULT_REFUSED_SECURITY) {
|
||||
logi("Probably GAP-security-related issues. Set GAP security to 2\n");
|
||||
}
|
||||
- logi("Removing key for device: %s.\n", bd_addr_to_str(address));
|
||||
- gap_drop_link_key_for_bd_addr(device->conn.btaddr);
|
||||
+ logi("Removing failed device instance for: %s; preserving link key.\n", bd_addr_to_str(address));
|
||||
uni_hid_device_disconnect(device);
|
||||
uni_hid_device_delete(device);
|
||||
/* 'device' is destroyed, don't use */
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ dependencies = [
|
|||
"PySDL3",
|
||||
"rich",
|
||||
"hidapi",
|
||||
"pyusb",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
controller-uart-bridge = "switch_pico_bridge.controller_uart_bridge:main"
|
||||
host-uart-logger = "switch_pico_bridge.host_uart_logger:main"
|
||||
switch-pico-pairings = "switch_pico_bridge.pairing_manager:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
|
|
|||
292
src/switch_pico_bridge/pairing_manager.py
Executable file
292
src/switch_pico_bridge/pairing_manager.py
Executable file
|
|
@ -0,0 +1,292 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Manage Pico 2 W Bluetooth pairings over vendor requests on USB EP0."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Iterable, Sequence
|
||||
from typing import Any, Protocol
|
||||
|
||||
import usb.core
|
||||
|
||||
USB_VENDOR_ID = 0x057E
|
||||
USB_PRODUCT_ID = 0x2009
|
||||
REQUEST_CLEAR = 0x50
|
||||
REQUEST_GET = 0x51
|
||||
REQUEST_REFRESH = 0x52
|
||||
REQUEST_VALUE = 0x5350
|
||||
REQUEST_INDEX = 0x4D47
|
||||
PROTOCOL_VERSION = 1
|
||||
RESPONSE_HEADER_SIZE = 12
|
||||
RECORD_SIZE = 8
|
||||
RECORD_CAPACITY = 16
|
||||
MAXIMUM_RESPONSE_SIZE = RESPONSE_HEADER_SIZE + RECORD_CAPACITY * RECORD_SIZE
|
||||
STATUS_READY = 0
|
||||
STATUS_PENDING = 1
|
||||
TRANSPORT_CLASSIC = 1
|
||||
TRANSPORT_BLE = 2
|
||||
USB_TIMEOUT_MS = 1000
|
||||
|
||||
|
||||
class PairingManagerError(RuntimeError):
|
||||
"""Expected discovery, USB transport, or protocol failure."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairingRecord:
|
||||
transport: int
|
||||
address_type: int
|
||||
address: bytes
|
||||
|
||||
@property
|
||||
def address_text(self) -> str:
|
||||
return ":".join(f"{octet:02X}" for octet in self.address)
|
||||
|
||||
@property
|
||||
def transport_text(self) -> str:
|
||||
if self.transport == TRANSPORT_CLASSIC:
|
||||
return "Classic"
|
||||
if self.transport == TRANSPORT_BLE:
|
||||
address_types = {
|
||||
0: "public",
|
||||
1: "random",
|
||||
2: "public identity",
|
||||
3: "random identity",
|
||||
}
|
||||
suffix = address_types.get(
|
||||
self.address_type, f"type {self.address_type}"
|
||||
)
|
||||
return f"BLE ({suffix})"
|
||||
return f"unknown transport {self.transport}"
|
||||
|
||||
|
||||
class UsbDevice(Protocol):
|
||||
bus: int | None
|
||||
address: int | None
|
||||
|
||||
def ctrl_transfer(
|
||||
self,
|
||||
bm_request_type: int,
|
||||
request: int,
|
||||
value: int = 0,
|
||||
index: int = 0,
|
||||
data_or_w_length: Any = None,
|
||||
timeout: int | None = None,
|
||||
) -> Any:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PairingSnapshot:
|
||||
generation: int
|
||||
status: int
|
||||
overflow: bool
|
||||
records: tuple[PairingRecord, ...]
|
||||
|
||||
|
||||
def parse_snapshot(payload: bytes) -> PairingSnapshot:
|
||||
if len(payload) < RESPONSE_HEADER_SIZE:
|
||||
raise PairingManagerError("short pairing-management response")
|
||||
if payload[:4] != b"SPPM":
|
||||
raise PairingManagerError("device does not implement pairing management")
|
||||
if payload[4] != PROTOCOL_VERSION:
|
||||
raise PairingManagerError(
|
||||
f"unsupported pairing protocol version {payload[4]}"
|
||||
)
|
||||
|
||||
status = payload[5]
|
||||
record_count = payload[6]
|
||||
required = RESPONSE_HEADER_SIZE + record_count * RECORD_SIZE
|
||||
if record_count > RECORD_CAPACITY or len(payload) < required:
|
||||
raise PairingManagerError("invalid pairing record count")
|
||||
|
||||
generation = int(struct.unpack_from("<I", payload, 8)[0])
|
||||
records: list[PairingRecord] = []
|
||||
offset = RESPONSE_HEADER_SIZE
|
||||
for _ in range(record_count):
|
||||
records.append(
|
||||
PairingRecord(
|
||||
transport=payload[offset],
|
||||
address_type=payload[offset + 1],
|
||||
address=bytes(payload[offset + 2 : offset + 8]),
|
||||
)
|
||||
)
|
||||
offset += RECORD_SIZE
|
||||
return PairingSnapshot(
|
||||
generation=generation,
|
||||
status=status,
|
||||
overflow=bool(payload[7] & 1),
|
||||
records=tuple(records),
|
||||
)
|
||||
|
||||
|
||||
def _control_in(device: UsbDevice) -> bytes:
|
||||
payload = device.ctrl_transfer(
|
||||
0xC0,
|
||||
REQUEST_GET,
|
||||
REQUEST_VALUE,
|
||||
REQUEST_INDEX,
|
||||
MAXIMUM_RESPONSE_SIZE,
|
||||
timeout=USB_TIMEOUT_MS,
|
||||
)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
def _control_out(device: UsbDevice, request: int) -> None:
|
||||
device.ctrl_transfer(
|
||||
0x40,
|
||||
request,
|
||||
REQUEST_VALUE,
|
||||
REQUEST_INDEX,
|
||||
None,
|
||||
timeout=USB_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
def read_snapshot(device: UsbDevice) -> PairingSnapshot:
|
||||
return parse_snapshot(_control_in(device))
|
||||
|
||||
|
||||
def wait_for_snapshot(
|
||||
device: UsbDevice, previous_generation: int, timeout: float
|
||||
) -> PairingSnapshot:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = read_snapshot(device)
|
||||
if (
|
||||
snapshot.status == STATUS_READY
|
||||
and snapshot.generation != previous_generation
|
||||
):
|
||||
return snapshot
|
||||
time.sleep(0.05)
|
||||
raise PairingManagerError("Pico did not finish the pairing operation")
|
||||
|
||||
|
||||
def refresh_snapshot(device: UsbDevice, timeout: float) -> PairingSnapshot:
|
||||
initial = read_snapshot(device)
|
||||
_control_out(device, REQUEST_REFRESH)
|
||||
return wait_for_snapshot(device, initial.generation, timeout)
|
||||
|
||||
|
||||
def clear_pairings(device: UsbDevice, timeout: float) -> PairingSnapshot:
|
||||
initial = read_snapshot(device)
|
||||
_control_out(device, REQUEST_CLEAR)
|
||||
snapshot = wait_for_snapshot(device, initial.generation, timeout)
|
||||
if snapshot.records:
|
||||
raise PairingManagerError("Pico reported pairings after clear completed")
|
||||
return snapshot
|
||||
|
||||
|
||||
def _candidate_devices() -> Iterable[UsbDevice]:
|
||||
devices = usb.core.find(
|
||||
find_all=True,
|
||||
idVendor=USB_VENDOR_ID,
|
||||
idProduct=USB_PRODUCT_ID,
|
||||
)
|
||||
return () if devices is None else devices
|
||||
|
||||
|
||||
def find_pico(
|
||||
bus: int | None, address: int | None, timeout: float = 3.0
|
||||
) -> UsbDevice:
|
||||
deadline = time.monotonic() + timeout
|
||||
failures: list[Exception] = []
|
||||
while True:
|
||||
matches: list[UsbDevice] = []
|
||||
for device in _candidate_devices():
|
||||
if bus is not None and getattr(device, "bus", None) != bus:
|
||||
continue
|
||||
if address is not None and getattr(device, "address", None) != address:
|
||||
continue
|
||||
try:
|
||||
_ = read_snapshot(device)
|
||||
except (PairingManagerError, usb.core.USBError) as exc:
|
||||
failures.append(exc)
|
||||
continue
|
||||
matches.append(device)
|
||||
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
locations = ", ".join(
|
||||
f"{device.bus}:{device.address}" for device in matches
|
||||
)
|
||||
raise PairingManagerError(
|
||||
f"multiple switch-pico devices found ({locations}); "
|
||||
"select one with --bus and --address"
|
||||
)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if failures:
|
||||
raise PairingManagerError(
|
||||
"matching USB devices were found, but none accepted the "
|
||||
f"management request; last error: {failures[-1]}"
|
||||
) from failures[-1]
|
||||
raise PairingManagerError("no USB-connected switch-pico AIO firmware found")
|
||||
|
||||
|
||||
def _print_snapshot(snapshot: PairingSnapshot) -> None:
|
||||
if not snapshot.records:
|
||||
print("No stored pairings.")
|
||||
return
|
||||
for index, record in enumerate(snapshot.records, start=1):
|
||||
print(f"{index}: {record.transport_text} {record.address_text}")
|
||||
if snapshot.overflow:
|
||||
print("Warning: additional pairings did not fit in the response.")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="switch-pico-pairings",
|
||||
description="List or clear switch-pico AIO Bluetooth pairings.",
|
||||
)
|
||||
parser.add_argument("--bus", type=int, help="USB bus number")
|
||||
parser.add_argument("--address", type=int, help="USB device address")
|
||||
parser.add_argument(
|
||||
"--timeout", type=float, default=3.0,
|
||||
help="operation timeout in seconds (default: 3)",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
subparsers.add_parser("list", help="list stored Classic and BLE pairings")
|
||||
clear_parser = subparsers.add_parser("clear", help="clear all pairings")
|
||||
clear_parser.add_argument(
|
||||
"--yes", action="store_true",
|
||||
help="confirm destructive clearing without prompting",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.timeout <= 0:
|
||||
print("error: --timeout must be positive", file=sys.stderr)
|
||||
return 2
|
||||
if args.command == "clear" and not args.yes:
|
||||
print("error: clear requires --yes", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
device = find_pico(args.bus, args.address, args.timeout)
|
||||
if args.command == "list":
|
||||
_print_snapshot(refresh_snapshot(device, args.timeout))
|
||||
else:
|
||||
before = refresh_snapshot(device, args.timeout)
|
||||
clear_pairings(device, args.timeout)
|
||||
print(f"Cleared {len(before.records)} stored pairing(s).")
|
||||
except PairingManagerError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except usb.core.USBError as exc:
|
||||
print(f"error: USB access failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -233,8 +233,15 @@ int main() {
|
|||
while (true) {
|
||||
tud_task(); // USB device tasks
|
||||
#ifdef SWITCH_PICO_BLUEPAD32
|
||||
if (bootsel_pairing_button_task()) {
|
||||
bluepad32_input_backend_open_pairing_window();
|
||||
switch (bootsel_pairing_button_task()) {
|
||||
case BootselPairingButtonEvent::kOpenPairing:
|
||||
bluepad32_input_backend_open_pairing_window();
|
||||
break;
|
||||
case BootselPairingButtonEvent::kClearPairings:
|
||||
bluepad32_input_backend_clear_pairings();
|
||||
break;
|
||||
case BootselPairingButtonEvent::kNone:
|
||||
break;
|
||||
}
|
||||
for (uint8_t instance = 0;
|
||||
instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,21 @@ uni_platform* installed_platform = nullptr;
|
|||
bool observed_status_led_on = false;
|
||||
int observed_status_led_writes = 0;
|
||||
uint32_t now_ms = 0;
|
||||
bool bondable = true;
|
||||
bool ssp_auto_accept = true;
|
||||
uint8_t accepted_stk_methods = 0xff;
|
||||
uint16_t link_supervision_timeout = 0;
|
||||
btstack_packet_handler_t pairing_event_handler = nullptr;
|
||||
int confirmation_accepts = 0;
|
||||
int confirmation_rejections = 0;
|
||||
int passkey_accepts = 0;
|
||||
int passkey_rejections = 0;
|
||||
int delete_key_calls = 0;
|
||||
bd_addr_t classic_bonds[4]{};
|
||||
int classic_bond_count = 0;
|
||||
bd_addr_t ble_bonds[4]{};
|
||||
int ble_bond_types[4]{};
|
||||
int ble_bond_count = 0;
|
||||
|
||||
bool flash_core_init_result = true;
|
||||
int flash_core_init_calls = 0;
|
||||
|
|
@ -126,6 +141,111 @@ void uni_bt_stop_scanning_unsafe() {
|
|||
uni_bt_bredr_scan_stop();
|
||||
uni_bt_le_scan_stop();
|
||||
}
|
||||
void uni_bt_del_keys_unsafe() {
|
||||
++delete_key_calls;
|
||||
classic_bond_count = 0;
|
||||
ble_bond_count = 0;
|
||||
}
|
||||
|
||||
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator) {
|
||||
iterator->index = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int gap_link_key_iterator_get_next(
|
||||
btstack_link_key_iterator_t* iterator, bd_addr_t address,
|
||||
link_key_t link_key, link_key_type_t* type) {
|
||||
if (iterator->index >= classic_bond_count) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(address, classic_bonds[iterator->index], sizeof(bd_addr_t));
|
||||
memset(link_key, iterator->index + 1, sizeof(link_key_t));
|
||||
*type = 0;
|
||||
++iterator->index;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void gap_link_key_iterator_done(btstack_link_key_iterator_t*) {
|
||||
}
|
||||
|
||||
int le_device_db_max_count() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
void le_device_db_info(
|
||||
int index, int* address_type, bd_addr_t address, sm_key_t irk) {
|
||||
if (index < ble_bond_count) {
|
||||
*address_type = ble_bond_types[index];
|
||||
memcpy(address, ble_bonds[index], sizeof(bd_addr_t));
|
||||
if (irk != nullptr) {
|
||||
memset(irk, index + 1, sizeof(sm_key_t));
|
||||
}
|
||||
return;
|
||||
}
|
||||
*address_type = BD_ADDR_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
void gap_set_bondable_mode(int enabled) {
|
||||
bondable = enabled != 0;
|
||||
}
|
||||
|
||||
void gap_set_link_supervision_timeout(uint16_t timeout) {
|
||||
link_supervision_timeout = timeout;
|
||||
}
|
||||
|
||||
void gap_ssp_set_auto_accept(int auto_accept) {
|
||||
ssp_auto_accept = auto_accept != 0;
|
||||
}
|
||||
void sm_set_accepted_stk_generation_methods(uint8_t methods) {
|
||||
accepted_stk_methods = methods;
|
||||
}
|
||||
|
||||
|
||||
int gap_ssp_confirmation_response(const bd_addr_t) {
|
||||
++confirmation_accepts;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int gap_ssp_confirmation_negative(const bd_addr_t) {
|
||||
++confirmation_rejections;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int gap_ssp_passkey_response(const bd_addr_t, uint32_t) {
|
||||
++passkey_accepts;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int gap_ssp_passkey_negative(const bd_addr_t) {
|
||||
++passkey_rejections;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void hci_add_event_handler(
|
||||
btstack_packet_callback_registration_t* callback_handler) {
|
||||
pairing_event_handler = callback_handler->callback;
|
||||
}
|
||||
|
||||
uint8_t hci_event_packet_get_type(const uint8_t* packet) {
|
||||
return packet[0];
|
||||
}
|
||||
|
||||
void copy_event_address(const uint8_t* packet, bd_addr_t address) {
|
||||
for (size_t index = 0; index < sizeof(bd_addr_t); ++index) {
|
||||
address[index] = packet[7 - index];
|
||||
}
|
||||
}
|
||||
|
||||
void hci_event_user_confirmation_request_get_bd_addr(
|
||||
const uint8_t* packet, bd_addr_t address) {
|
||||
copy_event_address(packet, address);
|
||||
}
|
||||
|
||||
void hci_event_user_passkey_request_get_bd_addr(
|
||||
const uint8_t* packet, bd_addr_t address) {
|
||||
copy_event_address(packet, address);
|
||||
}
|
||||
|
||||
|
||||
void uni_platform_set_custom(uni_platform* platform) {
|
||||
installed_platform = platform;
|
||||
|
|
@ -186,10 +306,13 @@ namespace {
|
|||
void start_backend() {
|
||||
bluepad32_input_backend_init();
|
||||
platform_on_init_complete();
|
||||
require(!incoming_connections,
|
||||
"initialization must keep incoming connections closed");
|
||||
require(scan_starts == 0,
|
||||
"initialization must not scan before a BOOTSEL request");
|
||||
require(incoming_connections && scanning_enabled &&
|
||||
classic_scanning_enabled && scan_starts == 1 &&
|
||||
link_supervision_timeout ==
|
||||
kClassicLinkSupervisionTimeout &&
|
||||
!bondable && accepted_stk_methods == 0 &&
|
||||
!ssp_auto_accept && pairing_event_handler != nullptr,
|
||||
"initialization must configure liveness and pairing policy");
|
||||
}
|
||||
void start_pairing_backend() {
|
||||
start_backend();
|
||||
|
|
@ -200,6 +323,11 @@ void start_pairing_backend() {
|
|||
incoming_connections,
|
||||
"test connection setup requires an open pairing window");
|
||||
}
|
||||
void dispatch_pairing_event(uint8_t event_type) {
|
||||
uint8_t packet[8] = {event_type, 6, 1, 2, 3, 4, 5, 6};
|
||||
pairing_event_handler(HCI_EVENT_PACKET, 0, packet, sizeof(packet));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -334,6 +462,11 @@ void test_independent_lifecycle() {
|
|||
"connected device must remain identifiable while becoming ready");
|
||||
|
||||
const int starts_before_aborted_disconnect = scan_starts;
|
||||
const int classic_starts_before_aborted_disconnect =
|
||||
classic_scan_starts;
|
||||
const int stops_before_aborted_disconnect = scan_stops;
|
||||
const int classic_stops_before_aborted_disconnect =
|
||||
classic_scan_stops;
|
||||
platform_on_device_disconnected(&aborted);
|
||||
require(g_slots[0].device == nullptr && !g_slots[0].active,
|
||||
"pre-ready disconnect must clear its pending slot identity");
|
||||
|
|
@ -342,8 +475,13 @@ void test_independent_lifecycle() {
|
|||
require(g_connection_status == ConnectionStatus::Scanning &&
|
||||
scanning_enabled && classic_scanning_enabled &&
|
||||
incoming_connections &&
|
||||
scan_starts == starts_before_aborted_disconnect,
|
||||
"pre-ready disconnect must preserve the open pairing scan");
|
||||
scan_starts == starts_before_aborted_disconnect + 1 &&
|
||||
classic_scan_starts ==
|
||||
classic_starts_before_aborted_disconnect + 1 &&
|
||||
scan_stops == stops_before_aborted_disconnect + 1 &&
|
||||
classic_scan_stops ==
|
||||
classic_stops_before_aborted_disconnect + 1,
|
||||
"pre-ready disconnect must restart Classic and BLE scans");
|
||||
|
||||
uni_hid_device_t devices[kSlotCount] = {
|
||||
device(0), device(1), device(2), device(3)};
|
||||
|
|
@ -591,41 +729,42 @@ void test_pairing_window_policy() {
|
|||
"discovery must remain closed before backend initialization");
|
||||
|
||||
start_backend();
|
||||
require(g_connection_policy_state == ConnectionPolicyState::Locked &&
|
||||
!classic_scanning_enabled && !scanning_enabled &&
|
||||
!incoming_connections,
|
||||
"boot must disable all discovery and incoming connections");
|
||||
require(g_connection_policy_state == ConnectionPolicyState::Open &&
|
||||
classic_scanning_enabled && scanning_enabled &&
|
||||
incoming_connections,
|
||||
"boot must allow normal Bluepad32 autoconnect");
|
||||
require(platform_on_device_discovered(address, "controller", 0, 0) ==
|
||||
UNI_ERROR_IGNORE_DEVICE,
|
||||
"locked policy must reject every discovery");
|
||||
UNI_ERROR_SUCCESS,
|
||||
"boot policy must accept a discovered controller");
|
||||
dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST);
|
||||
dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST);
|
||||
require(confirmation_rejections == 1 && passkey_rejections == 1 &&
|
||||
confirmation_accepts == 0 && passkey_accepts == 0,
|
||||
"closed BOOTSEL window must reject new SSP authentication");
|
||||
|
||||
uni_hid_device_t rejected = device(0);
|
||||
platform_on_device_connected(&rejected);
|
||||
require(device_disconnect_calls == 1 &&
|
||||
last_disconnected_device == &rejected &&
|
||||
g_slots[0].device == nullptr,
|
||||
"locked policy must disconnect every incoming controller");
|
||||
|
||||
uni_hid_device_t reconnecting = device(0);
|
||||
platform_on_device_connected(&reconnecting);
|
||||
require(device_disconnect_calls == 0 &&
|
||||
g_slots[0].device == &reconnecting,
|
||||
"boot policy must retain a reconnecting controller");
|
||||
platform_on_device_disconnected(&reconnecting);
|
||||
|
||||
bluepad32_input_backend_open_pairing_window();
|
||||
require(!g_pairing_window_open,
|
||||
"Core0 request must wait for Core1 consumption");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_pairing_window_open &&
|
||||
require(g_pairing_window_open && bondable &&
|
||||
accepted_stk_methods == kAllBlePairingMethods &&
|
||||
g_pairing_window_deadline_ms == 60000 &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Open &&
|
||||
classic_scanning_enabled && scanning_enabled &&
|
||||
incoming_connections,
|
||||
"BOOTSEL window must run Bluepad32's normal pairing scan");
|
||||
require(platform_on_device_discovered(address, "controller", 0, 0) ==
|
||||
UNI_ERROR_SUCCESS,
|
||||
"open pairing window must accept a discovered controller");
|
||||
|
||||
uni_hid_device_t paired = device(0);
|
||||
platform_on_device_connected(&paired);
|
||||
require(device_disconnect_calls == 1 &&
|
||||
g_slots[0].device == &paired,
|
||||
"open pairing window must retain a connected controller");
|
||||
platform_on_device_disconnected(&paired);
|
||||
"BOOTSEL must enable Classic and BLE pairing without interrupting autoconnect");
|
||||
dispatch_pairing_event(HCI_EVENT_USER_CONFIRMATION_REQUEST);
|
||||
dispatch_pairing_event(HCI_EVENT_USER_PASSKEY_REQUEST);
|
||||
require(confirmation_accepts == 1 && passkey_accepts == 1,
|
||||
"open BOOTSEL window must accept new SSP authentication");
|
||||
|
||||
tick_backend_timer(20);
|
||||
require(!observed_status_led_on,
|
||||
|
|
@ -652,21 +791,22 @@ void test_pairing_window_policy() {
|
|||
require(g_connection_policy_state == ConnectionPolicyState::Paused &&
|
||||
g_pairing_window_open && !scanning_enabled &&
|
||||
!classic_scanning_enabled && !incoming_connections,
|
||||
"full slots must pause pairing without closing the deadline");
|
||||
"full slots must pause scanning without closing the deadline");
|
||||
|
||||
now_ms = 90000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(!g_pairing_window_open &&
|
||||
require(!g_pairing_window_open && !bondable &&
|
||||
accepted_stk_methods == 0 &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Paused,
|
||||
"deadline must expire while slots remain full");
|
||||
"Classic and BLE pairing authentication must close at the deadline");
|
||||
platform_on_device_disconnected(&devices[3]);
|
||||
require(g_connection_policy_state == ConnectionPolicyState::Locked &&
|
||||
!classic_scanning_enabled && !scanning_enabled &&
|
||||
!incoming_connections,
|
||||
"a freed slot after expiry must remain closed");
|
||||
require(g_connection_policy_state == ConnectionPolicyState::Open &&
|
||||
classic_scanning_enabled && scanning_enabled &&
|
||||
incoming_connections,
|
||||
"a freed slot must resume autoconnect after pairing indication expires");
|
||||
require(platform_on_device_discovered(address, "controller", 0, 0) ==
|
||||
UNI_ERROR_IGNORE_DEVICE,
|
||||
"expired pairing policy must reject discovery");
|
||||
UNI_ERROR_SUCCESS,
|
||||
"resumed autoconnect must accept a discovered controller");
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -884,6 +1024,70 @@ void test_motion_hotkey() {
|
|||
"disconnect did not reset slot 0 motion hotkey state");
|
||||
}
|
||||
|
||||
void test_clear_pairings() {
|
||||
classic_bond_count = 1;
|
||||
classic_bonds[0][0] = 0x10;
|
||||
ble_bond_count = 1;
|
||||
ble_bond_types[0] = BD_ADDR_TYPE_LE_PUBLIC;
|
||||
ble_bonds[0][0] = 0x20;
|
||||
start_pairing_backend();
|
||||
require(g_pairing_snapshot.status ==
|
||||
Bluepad32PairingSnapshotStatus::kReady &&
|
||||
g_pairing_snapshot.record_count == 2 &&
|
||||
g_pairing_snapshot.records[0].transport ==
|
||||
Bluepad32PairingTransport::kClassic &&
|
||||
g_pairing_snapshot.records[1].transport ==
|
||||
Bluepad32PairingTransport::kBle,
|
||||
"initial pairing snapshot must enumerate Classic and BLE bonds");
|
||||
const uint32_t snapshot_generation =
|
||||
g_pairing_snapshot.generation;
|
||||
uni_hid_device_t devices[2] = {device(0), device(1)};
|
||||
for (uni_hid_device_t& controller : devices) {
|
||||
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
|
||||
"pairing reset controller did not become ready");
|
||||
}
|
||||
bluepad32_input_backend_queue_rumble(
|
||||
0, SwitchRumbleOutput{100, 101});
|
||||
|
||||
bluepad32_input_backend_clear_pairings();
|
||||
require(g_clear_pairings_requested && delete_key_calls == 0 &&
|
||||
device_disconnect_calls == 0,
|
||||
"Core0 pairing reset request must wait for Core1");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
|
||||
require(delete_key_calls == 1 && device_disconnect_calls == 2,
|
||||
"pairing reset must delete bonds and disconnect every session");
|
||||
require(g_pairing_snapshot.status ==
|
||||
Bluepad32PairingSnapshotStatus::kReady &&
|
||||
g_pairing_snapshot.record_count == 0 &&
|
||||
g_pairing_snapshot.generation ==
|
||||
snapshot_generation + 1,
|
||||
"pairing reset must publish an empty refreshed snapshot");
|
||||
for (const BackendSlot& slot : g_slots) {
|
||||
require(slot.device == nullptr && !slot.active &&
|
||||
!slot.rumble_pending && !slot.feedback_pending &&
|
||||
slot.state.lx == kStickMidpoint &&
|
||||
slot.state.ly == kStickMidpoint &&
|
||||
slot.state.rx == kStickMidpoint &&
|
||||
slot.state.ry == kStickMidpoint &&
|
||||
slot.state.imu_sample_count == 0,
|
||||
"pairing reset must publish neutral empty slots");
|
||||
}
|
||||
require(!g_pairing_window_open && !bondable &&
|
||||
accepted_stk_methods == 0 &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Open &&
|
||||
scanning_enabled && classic_scanning_enabled &&
|
||||
incoming_connections && observed_status_led_on,
|
||||
"pairing reset must close authentication and resume autoconnect");
|
||||
|
||||
tick_backend_timer(9);
|
||||
require(!observed_status_led_on,
|
||||
"pairing reset confirmation must use the rapid blink pattern");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(delete_key_calls == 1 && device_disconnect_calls == 2,
|
||||
"pairing reset request must execute only once");
|
||||
}
|
||||
|
||||
void test_flash_core_start_contract() {
|
||||
bluepad32_input_backend_init();
|
||||
flash_core_init_result = false;
|
||||
|
|
@ -939,6 +1143,8 @@ int main(int argc, char** argv) {
|
|||
test_abxy_hotkey();
|
||||
} else if (scenario == "motion-hotkey") {
|
||||
test_motion_hotkey();
|
||||
} else if (scenario == "clear-pairings") {
|
||||
test_clear_pairings();
|
||||
} else if (scenario == "flash-core-start") {
|
||||
test_flash_core_start_contract();
|
||||
} else if (scenario == "flash-core-failure") {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ struct btstack_link_key_iterator_t {
|
|||
|
||||
enum {
|
||||
ERROR_CODE_SUCCESS = 0,
|
||||
HCI_EVENT_PACKET = 4,
|
||||
HCI_EVENT_USER_CONFIRMATION_REQUEST = 0x33,
|
||||
HCI_EVENT_USER_PASSKEY_REQUEST = 0x34,
|
||||
SM_STK_GENERATION_METHOD_JUST_WORKS = 0x01,
|
||||
SM_STK_GENERATION_METHOD_OOB = 0x02,
|
||||
SM_STK_GENERATION_METHOD_PASSKEY = 0x04,
|
||||
SM_STK_GENERATION_METHOD_NUMERIC_COMPARISON = 0x08,
|
||||
};
|
||||
typedef int uni_property_idx_t;
|
||||
typedef int uni_platform_oob_event_t;
|
||||
|
|
@ -80,6 +87,12 @@ struct uni_controller_t {
|
|||
};
|
||||
|
||||
struct uni_hid_device_t;
|
||||
typedef void (*btstack_packet_handler_t)(uint8_t, uint16_t, uint8_t*,
|
||||
uint16_t);
|
||||
struct btstack_packet_callback_registration_t {
|
||||
void* item;
|
||||
btstack_packet_handler_t callback;
|
||||
};
|
||||
typedef void (*uni_play_dual_rumble_t)(uni_hid_device_t*, uint16_t,
|
||||
uint16_t, uint8_t, uint8_t);
|
||||
typedef void (*uni_set_player_leds_t)(uni_hid_device_t*, uint8_t);
|
||||
|
|
@ -148,5 +161,30 @@ void uni_bt_bredr_scan_start();
|
|||
void uni_bt_bredr_scan_stop();
|
||||
void uni_bt_le_scan_start();
|
||||
void uni_bt_le_scan_stop();
|
||||
void uni_bt_del_keys_unsafe();
|
||||
int gap_link_key_iterator_init(btstack_link_key_iterator_t* iterator);
|
||||
int gap_link_key_iterator_get_next(
|
||||
btstack_link_key_iterator_t* iterator, bd_addr_t address,
|
||||
link_key_t link_key, link_key_type_t* type);
|
||||
void gap_link_key_iterator_done(btstack_link_key_iterator_t* iterator);
|
||||
int le_device_db_max_count();
|
||||
void le_device_db_info(
|
||||
int index, int* address_type, bd_addr_t address, sm_key_t irk);
|
||||
void gap_set_bondable_mode(int enabled);
|
||||
void gap_set_link_supervision_timeout(uint16_t link_supervision_timeout);
|
||||
void gap_ssp_set_auto_accept(int auto_accept);
|
||||
void sm_set_accepted_stk_generation_methods(
|
||||
uint8_t accepted_stk_generation_methods);
|
||||
int gap_ssp_confirmation_response(const bd_addr_t address);
|
||||
int gap_ssp_confirmation_negative(const bd_addr_t address);
|
||||
int gap_ssp_passkey_response(const bd_addr_t address, uint32_t passkey);
|
||||
int gap_ssp_passkey_negative(const bd_addr_t address);
|
||||
void hci_add_event_handler(
|
||||
btstack_packet_callback_registration_t* callback_handler);
|
||||
uint8_t hci_event_packet_get_type(const uint8_t* packet);
|
||||
void hci_event_user_confirmation_request_get_bd_addr(
|
||||
const uint8_t* packet, bd_addr_t address);
|
||||
void hci_event_user_passkey_request_get_bd_addr(
|
||||
const uint8_t* packet, bd_addr_t address);
|
||||
void uni_platform_set_custom(uni_platform* platform);
|
||||
int uni_init(int argc, const char** argv);
|
||||
|
|
|
|||
|
|
@ -41,11 +41,14 @@ void require(bool condition, const char* message) {
|
|||
}
|
||||
}
|
||||
|
||||
int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) {
|
||||
int events = 0;
|
||||
std::vector<BootselPairingButtonEvent> apply_pressed(
|
||||
BootselPairingButtonHoldFsm& fsm, int count) {
|
||||
std::vector<BootselPairingButtonEvent> events;
|
||||
for (int sample = 0; sample < count; ++sample) {
|
||||
if (fsm.update(BootselPairingButtonSample::kPressed)) {
|
||||
++events;
|
||||
const BootselPairingButtonEvent event =
|
||||
fsm.update(BootselPairingButtonSample::kPressed);
|
||||
if (event != BootselPairingButtonEvent::kNone) {
|
||||
events.push_back(event);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
|
|
@ -53,62 +56,88 @@ int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) {
|
|||
|
||||
void test_short_press() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 19) == 0,
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"a 19-sample press must not complete the hold");
|
||||
require(!fsm.update(BootselPairingButtonSample::kReleased),
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a short-press release must not report a hold");
|
||||
require(apply_pressed(fsm, 19) == 0,
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"a release must discard the previous short press");
|
||||
}
|
||||
|
||||
void test_exact_and_long_hold_once() {
|
||||
void test_pairing_and_clear_events_once() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 19) == 0,
|
||||
"the hold must not fire before sample 20");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed),
|
||||
"the hold must fire on exactly sample 20");
|
||||
require(apply_pressed(fsm, 100) == 0,
|
||||
"a continuously held button must not repeat");
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"the pairing hold must not fire before sample 20");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"pairing must fire on exactly sample 20");
|
||||
require(apply_pressed(fsm, 79).empty(),
|
||||
"a long hold must not fire between pairing and clearing");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"clearing must fire on exactly sample 100");
|
||||
require(apply_pressed(fsm, 100).empty(),
|
||||
"a continuously held button must not repeat either event");
|
||||
}
|
||||
|
||||
void test_release_and_rearm() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 20) == 1,
|
||||
"the initial hold must fire once");
|
||||
require(!fsm.update(BootselPairingButtonSample::kReleased),
|
||||
const auto first_events = apply_pressed(fsm, 100);
|
||||
require(first_events.size() == 2 &&
|
||||
first_events[0] ==
|
||||
BootselPairingButtonEvent::kOpenPairing &&
|
||||
first_events[1] ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"the initial long hold must report pairing then clearing");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"release must rearm without reporting an event");
|
||||
require(apply_pressed(fsm, 20) == 1,
|
||||
"a valid release must permit one later hold");
|
||||
const auto second_events = apply_pressed(fsm, 20);
|
||||
require(second_events.size() == 1 &&
|
||||
second_events[0] ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a valid release must permit a later pairing hold");
|
||||
}
|
||||
|
||||
void test_unread_samples_do_not_transition() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 10) == 0,
|
||||
"the first half of a hold must not fire");
|
||||
require(apply_pressed(fsm, 10).empty(),
|
||||
"the first half of a pairing hold must not fire");
|
||||
for (int sample = 0; sample < 8; ++sample) {
|
||||
require(!fsm.update(BootselPairingButtonSample::kUnread),
|
||||
require(fsm.update(BootselPairingButtonSample::kUnread) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"unread press samples must not report or reset a hold");
|
||||
}
|
||||
require(apply_pressed(fsm, 9) == 0,
|
||||
require(apply_pressed(fsm, 9).empty(),
|
||||
"valid pressed samples must resume after unread samples");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed),
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"20 valid pressed samples must fire despite unread samples");
|
||||
|
||||
require(!fsm.update(BootselPairingButtonSample::kUnread),
|
||||
require(fsm.update(BootselPairingButtonSample::kUnread) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"an unread release must not rearm a completed hold");
|
||||
require(apply_pressed(fsm, 20) == 0,
|
||||
"the held state must persist until a valid release");
|
||||
require(!fsm.update(BootselPairingButtonSample::kReleased),
|
||||
require(apply_pressed(fsm, 79).empty(),
|
||||
"the long hold must continue across an unread sample");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"100 valid pressed samples must clear despite unread samples");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a valid release must only rearm");
|
||||
require(apply_pressed(fsm, 20) == 1,
|
||||
const auto events = apply_pressed(fsm, 20);
|
||||
require(events.size() == 1 &&
|
||||
events[0] == BootselPairingButtonEvent::kOpenPairing,
|
||||
"the FSM must fire after the eventual valid release");
|
||||
}
|
||||
|
||||
bool run_sample(uint64_t sample_time_ms, int result, bool pressed) {
|
||||
BootselPairingButtonEvent run_sample(
|
||||
uint64_t sample_time_ms, int result, bool pressed) {
|
||||
flash_responses.push_back({result, pressed});
|
||||
now_ms = sample_time_ms;
|
||||
const std::size_t expected_consumed = flash_responses.size();
|
||||
const bool event = bootsel_pairing_button_task();
|
||||
const BootselPairingButtonEvent event = bootsel_pairing_button_task();
|
||||
require(next_flash_response == expected_consumed,
|
||||
"a due poll must invoke flash_safe_execute exactly once");
|
||||
return event;
|
||||
|
|
@ -116,15 +145,18 @@ bool run_sample(uint64_t sample_time_ms, int result, bool pressed) {
|
|||
|
||||
void test_sampler_cadence_and_callback_failure() {
|
||||
now_ms = 0;
|
||||
require(!bootsel_pairing_button_task(),
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must wait for its first 100 ms cadence");
|
||||
now_ms = 99;
|
||||
require(!bootsel_pairing_button_task(),
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must not poll before 100 ms");
|
||||
require(flash_safe_calls == 0,
|
||||
"sub-cadence task calls must not enter flash-safe execution");
|
||||
|
||||
require(!run_sample(100, PICO_OK, true),
|
||||
require(run_sample(100, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the first valid pressed sample must only start the hold");
|
||||
require(flash_safe_calls == 1 && qspi_override_writes.size() == 2,
|
||||
"a successful sample must float and restore QSPI CSn once");
|
||||
|
|
@ -136,39 +168,49 @@ void test_sampler_cadence_and_callback_failure() {
|
|||
"the callback must restore normal QSPI CSn control");
|
||||
|
||||
now_ms = 199;
|
||||
require(!bootsel_pairing_button_task(),
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must remain gated between 10 Hz polls");
|
||||
require(flash_safe_calls == 1,
|
||||
"an early task call must not sample BOOTSEL");
|
||||
|
||||
const std::size_t writes_before_failure = qspi_override_writes.size();
|
||||
require(!run_sample(200, -1, true),
|
||||
require(run_sample(200, -1, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"flash-safe failure must be treated as unread");
|
||||
require(qspi_override_writes.size() == writes_before_failure,
|
||||
"a failed flash-safe entry must not invoke the callback");
|
||||
|
||||
for (uint64_t time = 300; time < 2100; time += 100) {
|
||||
require(!run_sample(time, PICO_OK, true),
|
||||
require(run_sample(time, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must wait for 20 valid pressed samples");
|
||||
}
|
||||
require(run_sample(2100, PICO_OK, true),
|
||||
require(run_sample(2100, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a failed sample must not reset the valid pressed count");
|
||||
require(!run_sample(2200, PICO_OK, true),
|
||||
"a held button must not repeat after firing");
|
||||
require(run_sample(2200, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a held button must not repeat pairing");
|
||||
|
||||
require(!run_sample(2300, -1, false),
|
||||
require(run_sample(2300, -1, false) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a failed release sample must remain unread");
|
||||
require(!run_sample(2400, PICO_OK, true),
|
||||
require(run_sample(2400, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"an unread release must not rearm the sampler FSM");
|
||||
require(!run_sample(2500, PICO_OK, false),
|
||||
require(run_sample(2500, PICO_OK, false) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a valid release must rearm without firing");
|
||||
|
||||
for (uint64_t time = 2600; time < 4500; time += 100) {
|
||||
require(!run_sample(time, PICO_OK, true),
|
||||
require(run_sample(time, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the rearmed sampler must count a fresh hold");
|
||||
}
|
||||
require(run_sample(4500, PICO_OK, true),
|
||||
"a valid release must permit a second completed hold");
|
||||
require(run_sample(4500, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a valid release must permit a second pairing hold");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -216,7 +258,7 @@ int flash_safe_execute(void (*function)(void*), void* parameter,
|
|||
|
||||
int main() {
|
||||
test_short_press();
|
||||
test_exact_and_long_hold_once();
|
||||
test_pairing_and_clear_events_once();
|
||||
test_release_and_rearm();
|
||||
test_unread_samples_do_not_transition();
|
||||
test_sampler_cadence_and_callback_failure();
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"slot-lighting",
|
||||
"abxy-hotkey",
|
||||
"motion-hotkey",
|
||||
"clear-pairings",
|
||||
"flash-core-start",
|
||||
"flash-core-failure",
|
||||
):
|
||||
|
|
|
|||
156
tests/test_pairing_manager.py
Normal file
156
tests/test_pairing_manager.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
import switch_pico_bridge.pairing_manager as pairing_manager
|
||||
|
||||
|
||||
def make_payload(
|
||||
generation: int,
|
||||
records: list[tuple[int, int, bytes]],
|
||||
*,
|
||||
status: int = pairing_manager.STATUS_READY,
|
||||
overflow: bool = False,
|
||||
) -> bytes:
|
||||
payload = bytearray(b"SPPM")
|
||||
payload.extend(
|
||||
[
|
||||
pairing_manager.PROTOCOL_VERSION,
|
||||
status,
|
||||
len(records),
|
||||
int(overflow),
|
||||
]
|
||||
)
|
||||
payload.extend(struct.pack("<I", generation))
|
||||
for transport, address_type, address in records:
|
||||
payload.extend([transport, address_type])
|
||||
payload.extend(address)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
bus = 1
|
||||
address = 7
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.generation = 3
|
||||
self.records = [
|
||||
(
|
||||
pairing_manager.TRANSPORT_CLASSIC,
|
||||
0xFE,
|
||||
bytes.fromhex("010203040506"),
|
||||
),
|
||||
(
|
||||
pairing_manager.TRANSPORT_BLE,
|
||||
2,
|
||||
bytes.fromhex("A1A2A3A4A5A6"),
|
||||
),
|
||||
]
|
||||
self.requests: list[int] = []
|
||||
|
||||
def ctrl_transfer(
|
||||
self,
|
||||
bm_request_type: int,
|
||||
request: int,
|
||||
value: int,
|
||||
index: int,
|
||||
data_or_w_length: object,
|
||||
timeout: int,
|
||||
) -> bytes | int:
|
||||
assert value == pairing_manager.REQUEST_VALUE
|
||||
assert index == pairing_manager.REQUEST_INDEX
|
||||
assert timeout == pairing_manager.USB_TIMEOUT_MS
|
||||
self.requests.append(request)
|
||||
if bm_request_type == 0xC0:
|
||||
assert request == pairing_manager.REQUEST_GET
|
||||
return make_payload(self.generation, self.records)
|
||||
assert bm_request_type == 0x40
|
||||
if request == pairing_manager.REQUEST_REFRESH:
|
||||
self.generation += 1
|
||||
elif request == pairing_manager.REQUEST_CLEAR:
|
||||
self.records = []
|
||||
self.generation += 1
|
||||
else:
|
||||
raise AssertionError(f"unexpected request {request}")
|
||||
return 0
|
||||
|
||||
|
||||
def test_parse_snapshot() -> None:
|
||||
snapshot = pairing_manager.parse_snapshot(
|
||||
make_payload(
|
||||
0x78563412,
|
||||
[
|
||||
(
|
||||
pairing_manager.TRANSPORT_CLASSIC,
|
||||
0xFE,
|
||||
bytes.fromhex("010203040506"),
|
||||
),
|
||||
(
|
||||
pairing_manager.TRANSPORT_BLE,
|
||||
3,
|
||||
bytes.fromhex("A1A2A3A4A5A6"),
|
||||
),
|
||||
],
|
||||
overflow=True,
|
||||
)
|
||||
)
|
||||
assert snapshot.generation == 0x78563412
|
||||
assert snapshot.overflow
|
||||
assert snapshot.records[0].transport_text == "Classic"
|
||||
assert snapshot.records[0].address_text == "01:02:03:04:05:06"
|
||||
assert snapshot.records[1].transport_text == "BLE (random identity)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"",
|
||||
b"NOPE" + bytes(8),
|
||||
b"SPPM\x02" + bytes(7),
|
||||
b"SPPM\x01\x00\x11\x00" + bytes(4),
|
||||
],
|
||||
)
|
||||
def test_parse_rejects_invalid_payload(payload: bytes) -> None:
|
||||
with pytest.raises(pairing_manager.PairingManagerError):
|
||||
pairing_manager.parse_snapshot(payload)
|
||||
|
||||
|
||||
def test_list_and_clear_commands(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
monkeypatch.setattr(pairing_manager, "_candidate_devices", lambda: [device])
|
||||
|
||||
assert pairing_manager.main(["list"]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "Classic 01:02:03:04:05:06" in output
|
||||
assert "BLE (public identity) A1:A2:A3:A4:A5:A6" in output
|
||||
|
||||
assert pairing_manager.main(["clear"]) == 2
|
||||
assert "requires --yes" in capsys.readouterr().err
|
||||
|
||||
assert pairing_manager.main(["clear", "--yes"]) == 0
|
||||
assert capsys.readouterr().out == "Cleared 2 stored pairing(s).\n"
|
||||
assert device.records == []
|
||||
assert pairing_manager.REQUEST_REFRESH in device.requests
|
||||
assert pairing_manager.REQUEST_CLEAR in device.requests
|
||||
|
||||
|
||||
def test_find_requires_selector_for_multiple_picos(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = FakeDevice()
|
||||
second = FakeDevice()
|
||||
second.address = 8
|
||||
monkeypatch.setattr(
|
||||
pairing_manager, "_candidate_devices", lambda: [first, second]
|
||||
)
|
||||
with pytest.raises(
|
||||
pairing_manager.PairingManagerError,
|
||||
match="multiple switch-pico devices",
|
||||
):
|
||||
pairing_manager.find_pico(None, None)
|
||||
assert pairing_manager.find_pico(1, 8) is second
|
||||
30
tests/test_usb_pairing_management_native.py
Normal file
30
tests/test_usb_pairing_management_native.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
def test_usb_pairing_management_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "usb_pairing_management_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'tests' / 'usb_management_native_stubs'}",
|
||||
f"-I{root}",
|
||||
str(root / "tests" / "usb_pairing_management_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
43
tests/usb_management_native_stubs/tusb.h
Normal file
43
tests/usb_management_native_stubs/tusb.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
enum {
|
||||
CONTROL_STAGE_SETUP = 0,
|
||||
CONTROL_STAGE_DATA = 1,
|
||||
CONTROL_STAGE_ACK = 2,
|
||||
TUSB_REQ_RCPT_DEVICE = 0,
|
||||
TUSB_DIR_OUT = 0,
|
||||
TUSB_DIR_IN = 1,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint8_t recipient;
|
||||
uint8_t type;
|
||||
uint8_t direction;
|
||||
} tusb_request_type_bits_t;
|
||||
|
||||
typedef struct {
|
||||
tusb_request_type_bits_t bmRequestType_bit;
|
||||
uint8_t bRequest;
|
||||
uint16_t wValue;
|
||||
uint16_t wIndex;
|
||||
uint16_t wLength;
|
||||
} tusb_control_request_t;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
bool tud_control_xfer(uint8_t rhport,
|
||||
const tusb_control_request_t* request,
|
||||
void* buffer, uint16_t length);
|
||||
bool tud_control_status(uint8_t rhport,
|
||||
const tusb_control_request_t* request);
|
||||
bool tud_vendor_control_xfer_cb(
|
||||
uint8_t rhport, uint8_t stage,
|
||||
const tusb_control_request_t* request);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
144
tests/usb_pairing_management_test.cpp
Normal file
144
tests/usb_pairing_management_test.cpp
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#include "usb_pairing_management.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <tusb.h>
|
||||
|
||||
namespace {
|
||||
|
||||
Bluepad32PairingSnapshot current_snapshot{};
|
||||
bool refresh_requested = false;
|
||||
bool clear_requested = false;
|
||||
bool control_status_sent = false;
|
||||
std::vector<uint8_t> control_payload;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void test_encoding() {
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
snapshot.generation = 0x78563412;
|
||||
snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
snapshot.record_count = 2;
|
||||
snapshot.overflow = true;
|
||||
snapshot.records[0].transport =
|
||||
Bluepad32PairingTransport::kClassic;
|
||||
snapshot.records[0].address_type = 0xfe;
|
||||
const uint8_t classic_address[6] = {1, 2, 3, 4, 5, 6};
|
||||
memcpy(snapshot.records[0].address, classic_address, 6);
|
||||
snapshot.records[1].transport = Bluepad32PairingTransport::kBle;
|
||||
snapshot.records[1].address_type = 2;
|
||||
const uint8_t ble_address[6] = {6, 5, 4, 3, 2, 1};
|
||||
memcpy(snapshot.records[1].address, ble_address, 6);
|
||||
|
||||
uint8_t payload[UsbPairingManagement::kMaximumResponseSize]{};
|
||||
const size_t size = UsbPairingManagement::encode_snapshot(
|
||||
snapshot, payload, sizeof(payload));
|
||||
require(size == UsbPairingManagement::kResponseHeaderSize +
|
||||
2 * UsbPairingManagement::kRecordSize,
|
||||
"snapshot encoded with the wrong size");
|
||||
require(memcmp(payload, "SPPM", 4) == 0 &&
|
||||
payload[4] == UsbPairingManagement::kProtocolVersion &&
|
||||
payload[5] == 0 && payload[6] == 2 && payload[7] == 1,
|
||||
"snapshot header encoding is invalid");
|
||||
require(payload[8] == 0x12 && payload[9] == 0x34 &&
|
||||
payload[10] == 0x56 && payload[11] == 0x78,
|
||||
"snapshot generation is not little endian");
|
||||
require(payload[12] == 1 && payload[13] == 0xfe &&
|
||||
memcmp(&payload[14], classic_address, 6) == 0 &&
|
||||
payload[20] == 2 && payload[21] == 2 &&
|
||||
memcmp(&payload[22], ble_address, 6) == 0,
|
||||
"pairing records are encoded incorrectly");
|
||||
require(UsbPairingManagement::encode_snapshot(
|
||||
snapshot, payload, size - 1) == 0,
|
||||
"encoder accepted a short destination buffer");
|
||||
}
|
||||
|
||||
void test_vendor_requests() {
|
||||
current_snapshot = {};
|
||||
current_snapshot.generation = 7;
|
||||
current_snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
current_snapshot.record_count = 1;
|
||||
current_snapshot.records[0].transport =
|
||||
Bluepad32PairingTransport::kClassic;
|
||||
|
||||
tusb_control_request_t request{};
|
||||
request.bmRequestType_bit.recipient = TUSB_REQ_RCPT_DEVICE;
|
||||
request.bmRequestType_bit.direction = TUSB_DIR_IN;
|
||||
request.bRequest = UsbPairingManagement::kRequestGet;
|
||||
request.wValue = UsbPairingManagement::kRequestValue;
|
||||
request.wIndex = UsbPairingManagement::kRequestIndex;
|
||||
request.wLength = UsbPairingManagement::kMaximumResponseSize;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
control_payload.size() ==
|
||||
UsbPairingManagement::kResponseHeaderSize +
|
||||
UsbPairingManagement::kRecordSize &&
|
||||
control_payload[8] == 7,
|
||||
"GET request did not return the current pairing snapshot");
|
||||
|
||||
request.bmRequestType_bit.direction = TUSB_DIR_OUT;
|
||||
request.wLength = 0;
|
||||
request.bRequest = UsbPairingManagement::kRequestRefresh;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
refresh_requested && control_status_sent,
|
||||
"REFRESH request was not acknowledged and queued");
|
||||
|
||||
control_status_sent = false;
|
||||
request.bRequest = UsbPairingManagement::kRequestClear;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
clear_requested && control_status_sent,
|
||||
"CLEAR request was not acknowledged and queued");
|
||||
|
||||
request.wValue = 0;
|
||||
require(!tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"request with invalid magic was accepted");
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_ACK, &request),
|
||||
"non-setup control stage was rejected");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void bluepad32_input_backend_request_pairing_snapshot() {
|
||||
refresh_requested = true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_clear_pairings() {
|
||||
clear_requested = true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out) {
|
||||
*out = current_snapshot;
|
||||
}
|
||||
|
||||
bool tud_control_xfer(uint8_t, const tusb_control_request_t*,
|
||||
void* buffer, uint16_t length) {
|
||||
const auto* bytes = static_cast<const uint8_t*>(buffer);
|
||||
control_payload.assign(bytes, bytes + length);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tud_control_status(uint8_t, const tusb_control_request_t*) {
|
||||
control_status_sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
#include "../usb_pairing_management.cpp"
|
||||
|
||||
int main() {
|
||||
test_encoding();
|
||||
test_vendor_requests();
|
||||
return 0;
|
||||
}
|
||||
92
usb_pairing_management.cpp
Normal file
92
usb_pairing_management.cpp
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#include "usb_pairing_management.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "tusb.h"
|
||||
|
||||
namespace UsbPairingManagement {
|
||||
|
||||
size_t encode_snapshot(const Bluepad32PairingSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size) {
|
||||
const size_t required =
|
||||
kResponseHeaderSize + snapshot.record_count * kRecordSize;
|
||||
if (output == nullptr || output_size < required ||
|
||||
snapshot.record_count > BLUEPAD32_PAIRING_RECORD_CAPACITY) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
output[0] = 'S';
|
||||
output[1] = 'P';
|
||||
output[2] = 'P';
|
||||
output[3] = 'M';
|
||||
output[4] = kProtocolVersion;
|
||||
output[5] = static_cast<uint8_t>(snapshot.status);
|
||||
output[6] = snapshot.record_count;
|
||||
output[7] = snapshot.overflow ? 1 : 0;
|
||||
output[8] = static_cast<uint8_t>(snapshot.generation);
|
||||
output[9] = static_cast<uint8_t>(snapshot.generation >> 8);
|
||||
output[10] = static_cast<uint8_t>(snapshot.generation >> 16);
|
||||
output[11] = static_cast<uint8_t>(snapshot.generation >> 24);
|
||||
|
||||
size_t offset = kResponseHeaderSize;
|
||||
for (uint8_t index = 0; index < snapshot.record_count; ++index) {
|
||||
const Bluepad32PairingRecord& record = snapshot.records[index];
|
||||
output[offset] = static_cast<uint8_t>(record.transport);
|
||||
output[offset + 1] = record.address_type;
|
||||
memcpy(&output[offset + 2], record.address,
|
||||
sizeof(record.address));
|
||||
offset += kRecordSize;
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
} // namespace UsbPairingManagement
|
||||
|
||||
extern "C" bool tud_vendor_control_xfer_cb(
|
||||
uint8_t rhport, uint8_t stage,
|
||||
tusb_control_request_t const* request) {
|
||||
if (stage != CONTROL_STAGE_SETUP) {
|
||||
return true;
|
||||
}
|
||||
if (request == nullptr ||
|
||||
request->bmRequestType_bit.recipient != TUSB_REQ_RCPT_DEVICE ||
|
||||
request->wValue != UsbPairingManagement::kRequestValue ||
|
||||
request->wIndex != UsbPairingManagement::kRequestIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (request->bRequest) {
|
||||
case UsbPairingManagement::kRequestGet: {
|
||||
if (request->bmRequestType_bit.direction != TUSB_DIR_IN) {
|
||||
return false;
|
||||
}
|
||||
static uint8_t response[
|
||||
UsbPairingManagement::kMaximumResponseSize];
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
bluepad32_input_backend_pairing_snapshot(&snapshot);
|
||||
const size_t response_size =
|
||||
UsbPairingManagement::encode_snapshot(
|
||||
snapshot, response, sizeof(response));
|
||||
return response_size != 0 &&
|
||||
tud_control_xfer(
|
||||
rhport, request, response,
|
||||
static_cast<uint16_t>(response_size));
|
||||
}
|
||||
case UsbPairingManagement::kRequestRefresh:
|
||||
if (request->bmRequestType_bit.direction != TUSB_DIR_OUT ||
|
||||
request->wLength != 0) {
|
||||
return false;
|
||||
}
|
||||
bluepad32_input_backend_request_pairing_snapshot();
|
||||
return tud_control_status(rhport, request);
|
||||
case UsbPairingManagement::kRequestClear:
|
||||
if (request->bmRequestType_bit.direction != TUSB_DIR_OUT ||
|
||||
request->wLength != 0) {
|
||||
return false;
|
||||
}
|
||||
bluepad32_input_backend_clear_pairings();
|
||||
return tud_control_status(rhport, request);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
25
usb_pairing_management.h
Normal file
25
usb_pairing_management.h
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "bluepad32_input_backend.h"
|
||||
|
||||
namespace UsbPairingManagement {
|
||||
|
||||
constexpr uint8_t kRequestClear = 0x50;
|
||||
constexpr uint8_t kRequestGet = 0x51;
|
||||
constexpr uint8_t kRequestRefresh = 0x52;
|
||||
constexpr uint16_t kRequestValue = 0x5350;
|
||||
constexpr uint16_t kRequestIndex = 0x4d47;
|
||||
constexpr uint8_t kProtocolVersion = 1;
|
||||
constexpr size_t kResponseHeaderSize = 12;
|
||||
constexpr size_t kRecordSize = 8;
|
||||
constexpr size_t kMaximumResponseSize =
|
||||
kResponseHeaderSize +
|
||||
BLUEPAD32_PAIRING_RECORD_CAPACITY * kRecordSize;
|
||||
|
||||
size_t encode_snapshot(const Bluepad32PairingSnapshot& snapshot,
|
||||
uint8_t* output, size_t output_size);
|
||||
|
||||
} // namespace UsbPairingManagement
|
||||
11
uv.lock
generated
11
uv.lock
generated
|
|
@ -903,6 +903,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyusb"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
|
|
@ -940,6 +949,7 @@ dependencies = [
|
|||
{ name = "hidapi" },
|
||||
{ name = "pysdl3" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyusb" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
|
|
@ -948,6 +958,7 @@ requires-dist = [
|
|||
{ name = "hidapi" },
|
||||
{ name = "pysdl3" },
|
||||
{ name = "pyserial" },
|
||||
{ name = "pyusb" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue