diff --git a/CMakeLists.txt b/CMakeLists.txt index 223ce8c..5f69a35 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/README.md b/README.md index e248ac5..84b9351 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index bb8f160..9115c79 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -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(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( + 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)) { diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index b969635..74ed309 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -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); diff --git a/bootsel_pairing_button.cpp b/bootsel_pairing_button.cpp index ab9de55..fc50a46 100644 --- a/bootsel_pairing_button.cpp +++ b/bootsel_pairing_button.cpp @@ -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(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; diff --git a/bootsel_pairing_button.h b/bootsel_pairing_button.h index 7a881b0..9cc37bc 100644 --- a/bootsel_pairing_button.h +++ b/bootsel_pairing_button.h @@ -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(); diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index 9b97169..6b8c7fe 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index 675a264..aa5273f 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/pyproject.toml b/pyproject.toml index 79a0508..9eefdf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/src/switch_pico_bridge/pairing_manager.py b/src/switch_pico_bridge/pairing_manager.py new file mode 100755 index 0000000..837fbd5 --- /dev/null +++ b/src/switch_pico_bridge/pairing_manager.py @@ -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(" 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()) diff --git a/switch-pico.cpp b/switch-pico.cpp index ba3ba3f..04a2dcb 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -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) { diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index e1ab6b8..865732d 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -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") { diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index 021dacf..38805f0 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -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( diff --git a/tests/bootsel_pairing_button_test.cpp b/tests/bootsel_pairing_button_test.cpp index 1d30ff6..1e6ab19 100644 --- a/tests/bootsel_pairing_button_test.cpp +++ b/tests/bootsel_pairing_button_test.cpp @@ -41,11 +41,14 @@ void require(bool condition, const char* message) { } } -int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) { - int events = 0; +std::vector apply_pressed( + BootselPairingButtonHoldFsm& fsm, int count) { + std::vector 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(); diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 957b0b4..a583717 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -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", ): diff --git a/tests/test_pairing_manager.py b/tests/test_pairing_manager.py new file mode 100644 index 0000000..a3901f8 --- /dev/null +++ b/tests/test_pairing_manager.py @@ -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(" 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 diff --git a/tests/test_usb_pairing_management_native.py b/tests/test_usb_pairing_management_native.py new file mode 100644 index 0000000..d52aa08 --- /dev/null +++ b/tests/test_usb_pairing_management_native.py @@ -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) diff --git a/tests/usb_management_native_stubs/tusb.h b/tests/usb_management_native_stubs/tusb.h new file mode 100644 index 0000000..2196f72 --- /dev/null +++ b/tests/usb_management_native_stubs/tusb.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +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 diff --git a/tests/usb_pairing_management_test.cpp b/tests/usb_pairing_management_test.cpp new file mode 100644 index 0000000..9f79ea2 --- /dev/null +++ b/tests/usb_pairing_management_test.cpp @@ -0,0 +1,144 @@ +#include "usb_pairing_management.h" + +#include +#include +#include +#include + +#include + +namespace { + +Bluepad32PairingSnapshot current_snapshot{}; +bool refresh_requested = false; +bool clear_requested = false; +bool control_status_sent = false; +std::vector 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(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; +} diff --git a/usb_pairing_management.cpp b/usb_pairing_management.cpp new file mode 100644 index 0000000..5360eeb --- /dev/null +++ b/usb_pairing_management.cpp @@ -0,0 +1,92 @@ +#include "usb_pairing_management.h" + +#include + +#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(snapshot.status); + output[6] = snapshot.record_count; + output[7] = snapshot.overflow ? 1 : 0; + output[8] = static_cast(snapshot.generation); + output[9] = static_cast(snapshot.generation >> 8); + output[10] = static_cast(snapshot.generation >> 16); + output[11] = static_cast(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(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(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; + } +} diff --git a/usb_pairing_management.h b/usb_pairing_management.h new file mode 100644 index 0000000..a952485 --- /dev/null +++ b/usb_pairing_management.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#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 diff --git a/uv.lock b/uv.lock index d400c30..f562a14 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ]