Add physical and USB pairing management
This commit is contained in:
parent
74a62f035b
commit
41c7021813
22 changed files with 1263 additions and 72 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -41,11 +41,14 @@ void require(bool condition, const char* message) {
|
|||
}
|
||||
}
|
||||
|
||||
int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) {
|
||||
int events = 0;
|
||||
std::vector<BootselPairingButtonEvent> apply_pressed(
|
||||
BootselPairingButtonHoldFsm& fsm, int count) {
|
||||
std::vector<BootselPairingButtonEvent> events;
|
||||
for (int sample = 0; sample < count; ++sample) {
|
||||
if (fsm.update(BootselPairingButtonSample::kPressed)) {
|
||||
++events;
|
||||
const BootselPairingButtonEvent event =
|
||||
fsm.update(BootselPairingButtonSample::kPressed);
|
||||
if (event != BootselPairingButtonEvent::kNone) {
|
||||
events.push_back(event);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
|
|
@ -53,62 +56,88 @@ int apply_pressed(BootselPairingButtonHoldFsm& fsm, int count) {
|
|||
|
||||
void test_short_press() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 19) == 0,
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"a 19-sample press must not complete the hold");
|
||||
require(!fsm.update(BootselPairingButtonSample::kReleased),
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a short-press release must not report a hold");
|
||||
require(apply_pressed(fsm, 19) == 0,
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"a release must discard the previous short press");
|
||||
}
|
||||
|
||||
void test_exact_and_long_hold_once() {
|
||||
void test_pairing_and_clear_events_once() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 19) == 0,
|
||||
"the hold must not fire before sample 20");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed),
|
||||
"the hold must fire on exactly sample 20");
|
||||
require(apply_pressed(fsm, 100) == 0,
|
||||
"a continuously held button must not repeat");
|
||||
require(apply_pressed(fsm, 19).empty(),
|
||||
"the pairing hold must not fire before sample 20");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"pairing must fire on exactly sample 20");
|
||||
require(apply_pressed(fsm, 79).empty(),
|
||||
"a long hold must not fire between pairing and clearing");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"clearing must fire on exactly sample 100");
|
||||
require(apply_pressed(fsm, 100).empty(),
|
||||
"a continuously held button must not repeat either event");
|
||||
}
|
||||
|
||||
void test_release_and_rearm() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 20) == 1,
|
||||
"the initial hold must fire once");
|
||||
require(!fsm.update(BootselPairingButtonSample::kReleased),
|
||||
const auto first_events = apply_pressed(fsm, 100);
|
||||
require(first_events.size() == 2 &&
|
||||
first_events[0] ==
|
||||
BootselPairingButtonEvent::kOpenPairing &&
|
||||
first_events[1] ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"the initial long hold must report pairing then clearing");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"release must rearm without reporting an event");
|
||||
require(apply_pressed(fsm, 20) == 1,
|
||||
"a valid release must permit one later hold");
|
||||
const auto second_events = apply_pressed(fsm, 20);
|
||||
require(second_events.size() == 1 &&
|
||||
second_events[0] ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a valid release must permit a later pairing hold");
|
||||
}
|
||||
|
||||
void test_unread_samples_do_not_transition() {
|
||||
BootselPairingButtonHoldFsm fsm;
|
||||
require(apply_pressed(fsm, 10) == 0,
|
||||
"the first half of a hold must not fire");
|
||||
require(apply_pressed(fsm, 10).empty(),
|
||||
"the first half of a pairing hold must not fire");
|
||||
for (int sample = 0; sample < 8; ++sample) {
|
||||
require(!fsm.update(BootselPairingButtonSample::kUnread),
|
||||
require(fsm.update(BootselPairingButtonSample::kUnread) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"unread press samples must not report or reset a hold");
|
||||
}
|
||||
require(apply_pressed(fsm, 9) == 0,
|
||||
require(apply_pressed(fsm, 9).empty(),
|
||||
"valid pressed samples must resume after unread samples");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed),
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"20 valid pressed samples must fire despite unread samples");
|
||||
|
||||
require(!fsm.update(BootselPairingButtonSample::kUnread),
|
||||
require(fsm.update(BootselPairingButtonSample::kUnread) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"an unread release must not rearm a completed hold");
|
||||
require(apply_pressed(fsm, 20) == 0,
|
||||
"the held state must persist until a valid release");
|
||||
require(!fsm.update(BootselPairingButtonSample::kReleased),
|
||||
require(apply_pressed(fsm, 79).empty(),
|
||||
"the long hold must continue across an unread sample");
|
||||
require(fsm.update(BootselPairingButtonSample::kPressed) ==
|
||||
BootselPairingButtonEvent::kClearPairings,
|
||||
"100 valid pressed samples must clear despite unread samples");
|
||||
require(fsm.update(BootselPairingButtonSample::kReleased) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a valid release must only rearm");
|
||||
require(apply_pressed(fsm, 20) == 1,
|
||||
const auto events = apply_pressed(fsm, 20);
|
||||
require(events.size() == 1 &&
|
||||
events[0] == BootselPairingButtonEvent::kOpenPairing,
|
||||
"the FSM must fire after the eventual valid release");
|
||||
}
|
||||
|
||||
bool run_sample(uint64_t sample_time_ms, int result, bool pressed) {
|
||||
BootselPairingButtonEvent run_sample(
|
||||
uint64_t sample_time_ms, int result, bool pressed) {
|
||||
flash_responses.push_back({result, pressed});
|
||||
now_ms = sample_time_ms;
|
||||
const std::size_t expected_consumed = flash_responses.size();
|
||||
const bool event = bootsel_pairing_button_task();
|
||||
const BootselPairingButtonEvent event = bootsel_pairing_button_task();
|
||||
require(next_flash_response == expected_consumed,
|
||||
"a due poll must invoke flash_safe_execute exactly once");
|
||||
return event;
|
||||
|
|
@ -116,15 +145,18 @@ bool run_sample(uint64_t sample_time_ms, int result, bool pressed) {
|
|||
|
||||
void test_sampler_cadence_and_callback_failure() {
|
||||
now_ms = 0;
|
||||
require(!bootsel_pairing_button_task(),
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must wait for its first 100 ms cadence");
|
||||
now_ms = 99;
|
||||
require(!bootsel_pairing_button_task(),
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must not poll before 100 ms");
|
||||
require(flash_safe_calls == 0,
|
||||
"sub-cadence task calls must not enter flash-safe execution");
|
||||
|
||||
require(!run_sample(100, PICO_OK, true),
|
||||
require(run_sample(100, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the first valid pressed sample must only start the hold");
|
||||
require(flash_safe_calls == 1 && qspi_override_writes.size() == 2,
|
||||
"a successful sample must float and restore QSPI CSn once");
|
||||
|
|
@ -136,39 +168,49 @@ void test_sampler_cadence_and_callback_failure() {
|
|||
"the callback must restore normal QSPI CSn control");
|
||||
|
||||
now_ms = 199;
|
||||
require(!bootsel_pairing_button_task(),
|
||||
require(bootsel_pairing_button_task() ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must remain gated between 10 Hz polls");
|
||||
require(flash_safe_calls == 1,
|
||||
"an early task call must not sample BOOTSEL");
|
||||
|
||||
const std::size_t writes_before_failure = qspi_override_writes.size();
|
||||
require(!run_sample(200, -1, true),
|
||||
require(run_sample(200, -1, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"flash-safe failure must be treated as unread");
|
||||
require(qspi_override_writes.size() == writes_before_failure,
|
||||
"a failed flash-safe entry must not invoke the callback");
|
||||
|
||||
for (uint64_t time = 300; time < 2100; time += 100) {
|
||||
require(!run_sample(time, PICO_OK, true),
|
||||
require(run_sample(time, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the sampler must wait for 20 valid pressed samples");
|
||||
}
|
||||
require(run_sample(2100, PICO_OK, true),
|
||||
require(run_sample(2100, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a failed sample must not reset the valid pressed count");
|
||||
require(!run_sample(2200, PICO_OK, true),
|
||||
"a held button must not repeat after firing");
|
||||
require(run_sample(2200, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a held button must not repeat pairing");
|
||||
|
||||
require(!run_sample(2300, -1, false),
|
||||
require(run_sample(2300, -1, false) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a failed release sample must remain unread");
|
||||
require(!run_sample(2400, PICO_OK, true),
|
||||
require(run_sample(2400, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"an unread release must not rearm the sampler FSM");
|
||||
require(!run_sample(2500, PICO_OK, false),
|
||||
require(run_sample(2500, PICO_OK, false) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"a valid release must rearm without firing");
|
||||
|
||||
for (uint64_t time = 2600; time < 4500; time += 100) {
|
||||
require(!run_sample(time, PICO_OK, true),
|
||||
require(run_sample(time, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kNone,
|
||||
"the rearmed sampler must count a fresh hold");
|
||||
}
|
||||
require(run_sample(4500, PICO_OK, true),
|
||||
"a valid release must permit a second completed hold");
|
||||
require(run_sample(4500, PICO_OK, true) ==
|
||||
BootselPairingButtonEvent::kOpenPairing,
|
||||
"a valid release must permit a second pairing hold");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -216,7 +258,7 @@ int flash_safe_execute(void (*function)(void*), void* parameter,
|
|||
|
||||
int main() {
|
||||
test_short_press();
|
||||
test_exact_and_long_hold_once();
|
||||
test_pairing_and_clear_events_once();
|
||||
test_release_and_rearm();
|
||||
test_unread_samples_do_not_transition();
|
||||
test_sampler_cadence_and_callback_failure();
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"slot-lighting",
|
||||
"abxy-hotkey",
|
||||
"motion-hotkey",
|
||||
"clear-pairings",
|
||||
"flash-core-start",
|
||||
"flash-core-failure",
|
||||
):
|
||||
|
|
|
|||
156
tests/test_pairing_manager.py
Normal file
156
tests/test_pairing_manager.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
import switch_pico_bridge.pairing_manager as pairing_manager
|
||||
|
||||
|
||||
def make_payload(
|
||||
generation: int,
|
||||
records: list[tuple[int, int, bytes]],
|
||||
*,
|
||||
status: int = pairing_manager.STATUS_READY,
|
||||
overflow: bool = False,
|
||||
) -> bytes:
|
||||
payload = bytearray(b"SPPM")
|
||||
payload.extend(
|
||||
[
|
||||
pairing_manager.PROTOCOL_VERSION,
|
||||
status,
|
||||
len(records),
|
||||
int(overflow),
|
||||
]
|
||||
)
|
||||
payload.extend(struct.pack("<I", generation))
|
||||
for transport, address_type, address in records:
|
||||
payload.extend([transport, address_type])
|
||||
payload.extend(address)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
bus = 1
|
||||
address = 7
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.generation = 3
|
||||
self.records = [
|
||||
(
|
||||
pairing_manager.TRANSPORT_CLASSIC,
|
||||
0xFE,
|
||||
bytes.fromhex("010203040506"),
|
||||
),
|
||||
(
|
||||
pairing_manager.TRANSPORT_BLE,
|
||||
2,
|
||||
bytes.fromhex("A1A2A3A4A5A6"),
|
||||
),
|
||||
]
|
||||
self.requests: list[int] = []
|
||||
|
||||
def ctrl_transfer(
|
||||
self,
|
||||
bm_request_type: int,
|
||||
request: int,
|
||||
value: int,
|
||||
index: int,
|
||||
data_or_w_length: object,
|
||||
timeout: int,
|
||||
) -> bytes | int:
|
||||
assert value == pairing_manager.REQUEST_VALUE
|
||||
assert index == pairing_manager.REQUEST_INDEX
|
||||
assert timeout == pairing_manager.USB_TIMEOUT_MS
|
||||
self.requests.append(request)
|
||||
if bm_request_type == 0xC0:
|
||||
assert request == pairing_manager.REQUEST_GET
|
||||
return make_payload(self.generation, self.records)
|
||||
assert bm_request_type == 0x40
|
||||
if request == pairing_manager.REQUEST_REFRESH:
|
||||
self.generation += 1
|
||||
elif request == pairing_manager.REQUEST_CLEAR:
|
||||
self.records = []
|
||||
self.generation += 1
|
||||
else:
|
||||
raise AssertionError(f"unexpected request {request}")
|
||||
return 0
|
||||
|
||||
|
||||
def test_parse_snapshot() -> None:
|
||||
snapshot = pairing_manager.parse_snapshot(
|
||||
make_payload(
|
||||
0x78563412,
|
||||
[
|
||||
(
|
||||
pairing_manager.TRANSPORT_CLASSIC,
|
||||
0xFE,
|
||||
bytes.fromhex("010203040506"),
|
||||
),
|
||||
(
|
||||
pairing_manager.TRANSPORT_BLE,
|
||||
3,
|
||||
bytes.fromhex("A1A2A3A4A5A6"),
|
||||
),
|
||||
],
|
||||
overflow=True,
|
||||
)
|
||||
)
|
||||
assert snapshot.generation == 0x78563412
|
||||
assert snapshot.overflow
|
||||
assert snapshot.records[0].transport_text == "Classic"
|
||||
assert snapshot.records[0].address_text == "01:02:03:04:05:06"
|
||||
assert snapshot.records[1].transport_text == "BLE (random identity)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"",
|
||||
b"NOPE" + bytes(8),
|
||||
b"SPPM\x02" + bytes(7),
|
||||
b"SPPM\x01\x00\x11\x00" + bytes(4),
|
||||
],
|
||||
)
|
||||
def test_parse_rejects_invalid_payload(payload: bytes) -> None:
|
||||
with pytest.raises(pairing_manager.PairingManagerError):
|
||||
pairing_manager.parse_snapshot(payload)
|
||||
|
||||
|
||||
def test_list_and_clear_commands(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
monkeypatch.setattr(pairing_manager, "_candidate_devices", lambda: [device])
|
||||
|
||||
assert pairing_manager.main(["list"]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "Classic 01:02:03:04:05:06" in output
|
||||
assert "BLE (public identity) A1:A2:A3:A4:A5:A6" in output
|
||||
|
||||
assert pairing_manager.main(["clear"]) == 2
|
||||
assert "requires --yes" in capsys.readouterr().err
|
||||
|
||||
assert pairing_manager.main(["clear", "--yes"]) == 0
|
||||
assert capsys.readouterr().out == "Cleared 2 stored pairing(s).\n"
|
||||
assert device.records == []
|
||||
assert pairing_manager.REQUEST_REFRESH in device.requests
|
||||
assert pairing_manager.REQUEST_CLEAR in device.requests
|
||||
|
||||
|
||||
def test_find_requires_selector_for_multiple_picos(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
first = FakeDevice()
|
||||
second = FakeDevice()
|
||||
second.address = 8
|
||||
monkeypatch.setattr(
|
||||
pairing_manager, "_candidate_devices", lambda: [first, second]
|
||||
)
|
||||
with pytest.raises(
|
||||
pairing_manager.PairingManagerError,
|
||||
match="multiple switch-pico devices",
|
||||
):
|
||||
pairing_manager.find_pico(None, None)
|
||||
assert pairing_manager.find_pico(1, 8) is second
|
||||
30
tests/test_usb_pairing_management_native.py
Normal file
30
tests/test_usb_pairing_management_native.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
||||
def test_usb_pairing_management_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
|
||||
executable = tmp_path / "usb_pairing_management_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'tests' / 'usb_management_native_stubs'}",
|
||||
f"-I{root}",
|
||||
str(root / "tests" / "usb_pairing_management_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
43
tests/usb_management_native_stubs/tusb.h
Normal file
43
tests/usb_management_native_stubs/tusb.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
enum {
|
||||
CONTROL_STAGE_SETUP = 0,
|
||||
CONTROL_STAGE_DATA = 1,
|
||||
CONTROL_STAGE_ACK = 2,
|
||||
TUSB_REQ_RCPT_DEVICE = 0,
|
||||
TUSB_DIR_OUT = 0,
|
||||
TUSB_DIR_IN = 1,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint8_t recipient;
|
||||
uint8_t type;
|
||||
uint8_t direction;
|
||||
} tusb_request_type_bits_t;
|
||||
|
||||
typedef struct {
|
||||
tusb_request_type_bits_t bmRequestType_bit;
|
||||
uint8_t bRequest;
|
||||
uint16_t wValue;
|
||||
uint16_t wIndex;
|
||||
uint16_t wLength;
|
||||
} tusb_control_request_t;
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
bool tud_control_xfer(uint8_t rhport,
|
||||
const tusb_control_request_t* request,
|
||||
void* buffer, uint16_t length);
|
||||
bool tud_control_status(uint8_t rhport,
|
||||
const tusb_control_request_t* request);
|
||||
bool tud_vendor_control_xfer_cb(
|
||||
uint8_t rhport, uint8_t stage,
|
||||
const tusb_control_request_t* request);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
144
tests/usb_pairing_management_test.cpp
Normal file
144
tests/usb_pairing_management_test.cpp
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#include "usb_pairing_management.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <tusb.h>
|
||||
|
||||
namespace {
|
||||
|
||||
Bluepad32PairingSnapshot current_snapshot{};
|
||||
bool refresh_requested = false;
|
||||
bool clear_requested = false;
|
||||
bool control_status_sent = false;
|
||||
std::vector<uint8_t> control_payload;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void test_encoding() {
|
||||
Bluepad32PairingSnapshot snapshot{};
|
||||
snapshot.generation = 0x78563412;
|
||||
snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
snapshot.record_count = 2;
|
||||
snapshot.overflow = true;
|
||||
snapshot.records[0].transport =
|
||||
Bluepad32PairingTransport::kClassic;
|
||||
snapshot.records[0].address_type = 0xfe;
|
||||
const uint8_t classic_address[6] = {1, 2, 3, 4, 5, 6};
|
||||
memcpy(snapshot.records[0].address, classic_address, 6);
|
||||
snapshot.records[1].transport = Bluepad32PairingTransport::kBle;
|
||||
snapshot.records[1].address_type = 2;
|
||||
const uint8_t ble_address[6] = {6, 5, 4, 3, 2, 1};
|
||||
memcpy(snapshot.records[1].address, ble_address, 6);
|
||||
|
||||
uint8_t payload[UsbPairingManagement::kMaximumResponseSize]{};
|
||||
const size_t size = UsbPairingManagement::encode_snapshot(
|
||||
snapshot, payload, sizeof(payload));
|
||||
require(size == UsbPairingManagement::kResponseHeaderSize +
|
||||
2 * UsbPairingManagement::kRecordSize,
|
||||
"snapshot encoded with the wrong size");
|
||||
require(memcmp(payload, "SPPM", 4) == 0 &&
|
||||
payload[4] == UsbPairingManagement::kProtocolVersion &&
|
||||
payload[5] == 0 && payload[6] == 2 && payload[7] == 1,
|
||||
"snapshot header encoding is invalid");
|
||||
require(payload[8] == 0x12 && payload[9] == 0x34 &&
|
||||
payload[10] == 0x56 && payload[11] == 0x78,
|
||||
"snapshot generation is not little endian");
|
||||
require(payload[12] == 1 && payload[13] == 0xfe &&
|
||||
memcmp(&payload[14], classic_address, 6) == 0 &&
|
||||
payload[20] == 2 && payload[21] == 2 &&
|
||||
memcmp(&payload[22], ble_address, 6) == 0,
|
||||
"pairing records are encoded incorrectly");
|
||||
require(UsbPairingManagement::encode_snapshot(
|
||||
snapshot, payload, size - 1) == 0,
|
||||
"encoder accepted a short destination buffer");
|
||||
}
|
||||
|
||||
void test_vendor_requests() {
|
||||
current_snapshot = {};
|
||||
current_snapshot.generation = 7;
|
||||
current_snapshot.status = Bluepad32PairingSnapshotStatus::kReady;
|
||||
current_snapshot.record_count = 1;
|
||||
current_snapshot.records[0].transport =
|
||||
Bluepad32PairingTransport::kClassic;
|
||||
|
||||
tusb_control_request_t request{};
|
||||
request.bmRequestType_bit.recipient = TUSB_REQ_RCPT_DEVICE;
|
||||
request.bmRequestType_bit.direction = TUSB_DIR_IN;
|
||||
request.bRequest = UsbPairingManagement::kRequestGet;
|
||||
request.wValue = UsbPairingManagement::kRequestValue;
|
||||
request.wIndex = UsbPairingManagement::kRequestIndex;
|
||||
request.wLength = UsbPairingManagement::kMaximumResponseSize;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
control_payload.size() ==
|
||||
UsbPairingManagement::kResponseHeaderSize +
|
||||
UsbPairingManagement::kRecordSize &&
|
||||
control_payload[8] == 7,
|
||||
"GET request did not return the current pairing snapshot");
|
||||
|
||||
request.bmRequestType_bit.direction = TUSB_DIR_OUT;
|
||||
request.wLength = 0;
|
||||
request.bRequest = UsbPairingManagement::kRequestRefresh;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
refresh_requested && control_status_sent,
|
||||
"REFRESH request was not acknowledged and queued");
|
||||
|
||||
control_status_sent = false;
|
||||
request.bRequest = UsbPairingManagement::kRequestClear;
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
clear_requested && control_status_sent,
|
||||
"CLEAR request was not acknowledged and queued");
|
||||
|
||||
request.wValue = 0;
|
||||
require(!tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_SETUP, &request),
|
||||
"request with invalid magic was accepted");
|
||||
require(tud_vendor_control_xfer_cb(
|
||||
0, CONTROL_STAGE_ACK, &request),
|
||||
"non-setup control stage was rejected");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void bluepad32_input_backend_request_pairing_snapshot() {
|
||||
refresh_requested = true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_clear_pairings() {
|
||||
clear_requested = true;
|
||||
}
|
||||
|
||||
void bluepad32_input_backend_pairing_snapshot(
|
||||
Bluepad32PairingSnapshot* out) {
|
||||
*out = current_snapshot;
|
||||
}
|
||||
|
||||
bool tud_control_xfer(uint8_t, const tusb_control_request_t*,
|
||||
void* buffer, uint16_t length) {
|
||||
const auto* bytes = static_cast<const uint8_t*>(buffer);
|
||||
control_payload.assign(bytes, bytes + length);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tud_control_status(uint8_t, const tusb_control_request_t*) {
|
||||
control_status_sent = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
#include "../usb_pairing_management.cpp"
|
||||
|
||||
int main() {
|
||||
test_encoding();
|
||||
test_vendor_requests();
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue