Compare commits

..

4 commits

23 changed files with 1514 additions and 422 deletions

View file

@ -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

View file

@ -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.

View file

@ -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)) {

View file

@ -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);

View file

@ -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;

View file

@ -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.

View file

@ -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

View file

@ -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"}

View 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())

View file

@ -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) {

View file

@ -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") {

View file

@ -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);

View file

@ -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();

View file

@ -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",
):

View 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

View 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)

View 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

View 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;
}

View 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
View 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
View file

@ -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" },
]