Add physical and USB pairing management

This commit is contained in:
Joey Yakimowich-Payne 2026-09-01 08:19:11 -06:00
commit 41c7021813
22 changed files with 1263 additions and 72 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

@ -63,10 +63,14 @@ Pairing order determines the initial USB slot assignment. Up to four controllers
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**: 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; Bluetooth discovery and autoconnect are running.
@ -78,6 +82,18 @@ The Pico 2 W onboard LED reports the overall Bluetooth state:
- **Reconnect a paired controller**: power it on normally with its Home, PS, or Xbox button.
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
- **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 +157,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,7 @@ 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;
constexpr uint8_t kAllBlePairingMethods =
SM_STK_GENERATION_METHOD_JUST_WORKS |
SM_STK_GENERATION_METHOD_OOB |
@ -125,6 +126,8 @@ 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;
@ -135,9 +138,11 @@ 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{};
@ -507,6 +512,113 @@ bool update_pairing_window(uint32_t now_ms) {
}
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 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();
@ -534,9 +646,13 @@ void apply_connection_policy() {
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) {
@ -556,6 +672,8 @@ void update_status_led() {
void process_rumble_timer(btstack_timer_source_t* timer) {
const uint32_t now_ms = btstack_run_loop_get_time_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) {
@ -636,6 +754,7 @@ void platform_on_init_complete() {
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);
@ -850,9 +969,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;
}
@ -885,6 +1010,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

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

@ -28,6 +28,12 @@ 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;
@ -134,6 +140,50 @@ 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;
}
@ -957,6 +1007,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;
@ -1012,6 +1126,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

@ -161,6 +161,15 @@ 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_ssp_set_auto_accept(int auto_accept);
void sm_set_accepted_stk_generation_methods(

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