feat(wake): add USB-triggered Switch 2 wake with Windows interface support

Queue correlated, volatile wake requests for the BTstack owner and expose
read-only completion state. Keep the controller chord, captured identity,
pairings, profiles, HD settings and existing wake burst unchanged.

Add Python API and wake CLI with bounded waits, explicit failure outcomes
and no automatic broadcast retries. Avoid importing SDL just to obtain
fixed sensor IDs so USB automation has clean output and no SDL dependency.

Windows libusb cannot control hub roots. Expose only INFO/WAKE on each
native child's existing vendor Interface 1 with isolated control state;
retain hub/HID drivers, descriptors and identities. Add safe sibling-aware
Windows discovery and a packaged Windows libusb runtime. Document manual
WinUSB binding to Interface 1 only and migrate examples to find_wake_pico.

Validate with 713 tests, 10 firmware/probe builds, Windows x64 wheel
resolution on Python 3.9/3.11, and a real child-interface wake burst on
Linux (2.03 s, no controller, no failures). Device/configuration descriptors,
serials and persistent state matched before/after. Physical Windows
hardware/driver operation remains unverified on this Linux workstation.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-19 20:19:46 -06:00
commit 19358a0fff
20 changed files with 2152 additions and 85 deletions

View file

@ -81,6 +81,8 @@ int uni_init_calls = 0;
void (*during_uni_init)() = nullptr;
int switch2_wake_initializations = 0;
int switch2_wake_requests = 0;
Switch2WakeDiagnostics wake_diagnostics{};
bool wake_dispatch_available = true;
bool switch2_connections_ready = true;
int device_disconnect_calls = 0;
uni_hid_device_t* last_disconnected_device = nullptr;
@ -695,6 +697,7 @@ uint64_t time_us_64() { return uint64_t{now_ms} * 1000 + now_sub_ms_us; }
void switch2_wake_initialize() {
++switch2_wake_initializations;
wake_diagnostics.configured = SWITCH_PICO_ENABLE_BLE;
if (!SWITCH_PICO_ENABLE_BLE) switch2_connections_ready = true;
}
bool switch2_wake_ready_for_connections() {
@ -705,10 +708,15 @@ bool switch2_wake_ready_for_connections() {
bool switch2_wake_request() {
++switch2_wake_requests;
if (!wake_diagnostics.configured || wake_diagnostics.busy ||
!wake_dispatch_available) return false;
++wake_diagnostics.accepted_requests;
wake_diagnostics.busy = true;
return true;
}
void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
void switch2_wake_diagnostics(Switch2WakeDiagnostics* output) {
*output = wake_diagnostics;
}
#include "core/controller_identity.cpp"
@ -6226,11 +6234,175 @@ void test_transport_background_scan() {
"last disconnect must restore foreground scan timing");
}
Bluepad32Switch2WakeStatus usb_wake_status() {
Bluepad32Switch2WakeStatus status{};
bluepad32_input_backend_switch2_wake_snapshot(&status);
return status;
}
void test_usb_switch2_wake() {
using State = Bluepad32Switch2WakeState;
require(usb_wake_status().state == State::kIdle &&
!bluepad32_input_backend_request_switch2_wake(0) &&
!bluepad32_input_backend_request_switch2_wake(0x80000000u),
"invalid IDs must not admit wake work");
require(bluepad32_input_backend_request_switch2_wake(17) &&
usb_wake_status().request_id == 17 &&
usb_wake_status().state == State::kQueued &&
!usb_wake_status().configured &&
switch2_wake_initializations == 0 && switch2_wake_requests == 0,
"USB must publish correlation before radio startup without running radio work");
require(bluepad32_input_backend_request_switch2_wake(17) &&
!bluepad32_input_backend_request_switch2_wake(18),
"queued work must be idempotent and cannot be superseded");
start_backend();
process_rumble_timer(&g_rumble_timer);
auto status = usb_wake_status();
if (!SWITCH_PICO_ENABLE_BLE) {
require(status.request_id == 17 && status.state == State::kUnconfigured &&
!status.configured && switch2_wake_requests == 0,
"Classic-only firmware must report unavailable wake without a radio request");
return;
}
Bluepad32BackendDiagnostics backend{};
bluepad32_input_backend_diagnostics(&backend);
require(backend.active_slots == 0 && status.request_id == 17 &&
status.state == State::kBroadcasting && status.configured && status.busy &&
status.accepted_requests == 1 && switch2_wake_requests == 1,
"the owning timer must start exactly one real wake request without a controller");
require(bluepad32_input_backend_request_switch2_wake(17) &&
!bluepad32_input_backend_request_switch2_wake(18),
"broadcasting work must remain correlated and bounded");
process_rumble_timer(&g_rumble_timer);
require(switch2_wake_requests == 1 && usb_wake_status().state == State::kBroadcasting,
"polling cannot replay an active wake burst");
wake_diagnostics.busy = false;
++wake_diagnostics.completed_bursts;
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().state == State::kComplete &&
usb_wake_status().completed_bursts == 1 &&
bluepad32_input_backend_request_switch2_wake(17),
"the originating request must retain its completion and be retry-safe");
// An unrelated chord after completion must not change the retained result.
require(switch2_wake_request(), "the chord fixture must start a separate burst");
wake_diagnostics.busy = false;
++wake_diagnostics.completed_bursts;
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().request_id == 17 &&
usb_wake_status().state == State::kComplete &&
usb_wake_status().accepted_requests == 1 &&
usb_wake_status().completed_bursts == 1,
"terminal USB snapshots must not be rewritten by a later chord");
require(bluepad32_input_backend_request_switch2_wake(18),
"a new ID must replace a completed request");
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().state == State::kBroadcasting &&
usb_wake_status().completed_bursts == 2,
"prior chord completions must not complete a new USB request");
// Real wake cleanup may increment both counters between backend ticks.
++wake_diagnostics.failures;
++wake_diagnostics.completed_bursts;
wake_diagnostics.busy = false;
process_rumble_timer(&g_rumble_timer);
const int attempts_after_failure = switch2_wake_requests;
require(usb_wake_status().request_id == 18 &&
usb_wake_status().state == State::kFailed &&
bluepad32_input_backend_request_switch2_wake(18),
"failed HCI work must not become Complete when cleanup finishes");
process_rumble_timer(&g_rumble_timer);
require(switch2_wake_requests == attempts_after_failure &&
usb_wake_status().state == State::kFailed,
"failed requests must remain terminal without automatic retry");
require(switch2_wake_request(), "the chord fixture must own the radio");
const int attempts_with_chord = switch2_wake_requests;
require(bluepad32_input_backend_request_switch2_wake(19),
"USB must defer the chord-busy decision to the radio owner");
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().request_id == 19 &&
usb_wake_status().state == State::kBusy &&
switch2_wake_requests == attempts_with_chord,
"a chord-owned burst must reject USB without another broadcast");
wake_diagnostics.busy = false;
++wake_diagnostics.completed_bursts;
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().state == State::kBusy,
"a chord completion must never complete a rejected USB request");
wake_diagnostics.configured = false;
require(bluepad32_input_backend_request_switch2_wake(20),
"availability is decided by the radio owner, not stale USB diagnostics");
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().state == State::kUnconfigured &&
switch2_wake_requests == attempts_with_chord,
"missing wake configuration must be explicit and never start a burst");
wake_diagnostics.configured = true;
wake_dispatch_available = false;
require(bluepad32_input_backend_request_switch2_wake(21),
"USB must admit a radio-owner dispatch decision");
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().state == State::kFailed,
"an idle but failed radio machine must report Failed, not completion");
}
void test_usb_wake_chord_correlation() {
using State = Bluepad32Switch2WakeState;
start_pairing_backend();
auto controller = device(0);
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
"chord correlation requires a live controller");
require(bluepad32_input_backend_request_switch2_wake(1),
"USB wake must queue before the chord");
process_rumble_timer(&g_rumble_timer);
wake_diagnostics.busy = false;
++wake_diagnostics.completed_bursts;
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
platform_on_controller_data(&controller, &input);
// No backend timer ran between USB completion and the new chord.
++wake_diagnostics.failures;
wake_diagnostics.busy = false;
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().request_id == 1 &&
usb_wake_status().state == State::kComplete &&
usb_wake_status().failures == 0 &&
wake_diagnostics.accepted_requests == 2,
"a subsequent chord failure must not be attributed to the completed USB burst");
input.gamepad.buttons = 0;
input.gamepad.misc_buttons = 0;
platform_on_controller_data(&controller, &input);
require(bluepad32_input_backend_request_switch2_wake(2),
"a second USB request must queue");
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
platform_on_controller_data(&controller, &input);
require(usb_wake_status().state == State::kQueued &&
wake_diagnostics.accepted_requests == 3,
"queued USB work must never preempt a rising chord");
process_rumble_timer(&g_rumble_timer);
require(usb_wake_status().request_id == 2 &&
usb_wake_status().state == State::kBusy &&
wake_diagnostics.accepted_requests == 3,
"the chord must retain its burst while the queued USB request becomes Busy");
}
} // namespace
int main(int argc, char** argv) {
require(argc == 2, "scenario argument required");
const std::string scenario = argv[1];
if (scenario == "usb-wake") {
test_usb_switch2_wake();
return 0;
}
if (scenario == "usb-wake-chord") {
test_usb_wake_chord_correlation();
return 0;
}
#ifdef SWITCH2_BRIDGE_WII_INPUT
if (scenario == "wii-bridge-sensors") {
test_wii_bridge_sensors();

View file

@ -1641,6 +1641,7 @@ void hd_admission_cancel() {
int main(int argc, char** argv) {
require(argc == 2, "scenario required");
const std::string scenario = argv[1];
if (scenario == "usb-wake") { test_usb_switch2_wake(); return 0; }
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
if (scenario == "hd-second-pair-wii") { hd_second_pair(true); return 0; }
if (scenario == "hd-second-pair-other") { hd_second_pair(false); return 0; }

View file

@ -41,6 +41,10 @@ std::array<uint8_t, 64> child_identity[PROBE_CONTROLLER_COUNT];
bool interleave_identity_ack = false;
bool synthetic_root_management = false;
uint32_t bootsel_time_ms = 0;
uint32_t wake_request_calls = 0;
uint32_t last_wake_request_id = 0;
bool accept_wake_request = true;
Bluepad32Switch2WakeStatus wake_status{};
void require(bool condition, const char* message) {
if (!condition) { std::cerr << message << '\n'; std::exit(1); }
@ -68,6 +72,11 @@ tusb_control_request_t request(Operation op, bool input, uint16_t length) {
setup.wValue = kRequestValue; setup.wIndex = kRequestIndex; setup.wLength = length;
return setup;
}
tusb_control_request_t child_request(Operation op, bool input, uint16_t length) {
auto setup = request(op, input, length);
setup.bmRequestType = input ? 0xc1 : 0x41;
return setup;
}
std::vector<uint8_t> envelope(Operation op, const std::vector<uint8_t>& payload) {
std::vector<uint8_t> bytes(kRequestHeaderSize + payload.size());
memcpy(bytes.data(), "SPMG", 4);
@ -114,6 +123,189 @@ void write_operation(Operation op, const std::vector<uint8_t>& payload, bool ack
}
if (ack) acknowledge();
}
void test_wake_transport() {
const uint32_t writes_before = programs;
const uint32_t erases_before = erases;
const auto bytes = envelope(Operation::kSwitch2Wake, {1, 0, 0, 0});
const auto setup = request(Operation::kSwitch2Wake, false, bytes.size());
require(native_test_setup(0, &setup, true) &&
native_test_out(0, bytes.data(), bytes.size(), true) &&
wake_request_calls == 1,
"native wake must be admitted before its status packet");
acknowledge();
require(wake_request_calls == 1, "native status ACK replayed wake work");
wake_status = {1, Bluepad32Switch2WakeState::kQueued, false, false, 0, 0, 0};
const auto status = read_operation(Operation::kSwitch2Wake);
require(status.size() == 40 && status[6] == 0 && u16(status, 10) == 1 &&
u32(status, 12) == 1 && u32(status, 20) == 1 && status[24] == 1 &&
wake_request_calls == 1,
"native root wake read must remain correlated and read-only");
uint8_t packet[64];
uint16_t length = 0;
accept_wake_request = false;
require(native_test_setup(0, &setup, true) &&
!native_test_out(0, bytes.data(), bytes.size(), true) &&
!native_test_in(0, packet, &length, true) && wake_request_calls == 2,
"native busy wake must stall before its status packet");
accept_wake_request = true;
auto corrupt = bytes;
corrupt[12] ^= 1;
require(native_test_setup(0, &setup, true) &&
!native_test_out(0, corrupt.data(), corrupt.size(), true) &&
!native_test_in(0, packet, &length, true) && wake_request_calls == 2,
"native malformed wake must not dispatch or obtain status authorization");
require(read_operation(Operation::kSwitch2Wake) == status &&
programs == writes_before && erases == erases_before,
"volatile wake management must not alter status on reads or persist anything");
}
void read_child(uint8_t slot) {
tusb_control_request_t setup{};
setup.bmRequestType = 0xc0; setup.bRequest = 3; setup.wLength = 128;
require(native_test_setup(slot, &setup, true), "native child identity stalled");
const auto bytes = receive(slot);
require(bytes == std::vector<uint8_t>(child_identity[slot - 1].begin(), child_identity[slot - 1].end()),
"native child identity leaked root or sibling vendor bytes");
}
void test_child_wake_transport() {
const uint32_t programs_before = programs, erases_before = erases;
const auto info_setup = child_request(Operation::kInfo, true, kMaximumResponseSize);
const auto status_setup = child_request(Operation::kSwitch2Wake, true, kMaximumResponseSize);
const auto setup = child_request(Operation::kSwitch2Wake, false, kRequestHeaderSize + 4);
const auto root_info = read_operation(Operation::kInfo);
const auto root_status = read_operation(Operation::kSwitch2Wake);
uint32_t expected_calls = wake_request_calls;
uint8_t packet[64]; uint16_t length;
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
require(native_test_setup(slot, &info_setup, true) && receive(slot) == root_info &&
native_test_setup(slot, &status_setup, true) && receive(slot) == root_status &&
wake_request_calls == expected_calls,
"child discovery/status must match the read-only root management schema");
const auto bytes = envelope(Operation::kSwitch2Wake, {slot, 0, 0, 0});
require(native_test_setup(slot, &setup, true) &&
!native_test_in(slot, packet, &length, true) &&
!probe_management_vendor_control(slot, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == expected_calls,
"child wake must not acknowledge or submit before DATA validation");
require(native_test_out(slot, bytes.data(), bytes.size(), true) &&
wake_request_calls == ++expected_calls && last_wake_request_id == slot,
"child wake did not admit the requested ID before status ACK");
require(probe_management_vendor_control(slot, CONTROL_STAGE_DATA, &setup),
"repeated validated child DATA changed its admission result");
acknowledge(slot);
require(probe_management_vendor_control(slot, CONTROL_STAGE_ACK, &setup) &&
probe_management_vendor_control(slot, CONTROL_STAGE_DATA, &setup) &&
wake_request_calls == expected_calls,
"duplicate child DATA/ACK repeated the wake mutation");
accept_wake_request = false;
require(native_test_setup(slot, &setup, true) &&
!native_test_out(slot, bytes.data(), bytes.size(), true) &&
!native_test_in(slot, packet, &length, true) &&
wake_request_calls == ++expected_calls,
"busy child wake must stall before its status packet");
require(!probe_management_vendor_control(slot, CONTROL_STAGE_DATA, &setup) &&
!probe_management_vendor_control(slot, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == expected_calls,
"repeated rejected child stages resubmitted wake");
accept_wake_request = true;
for (size_t offset : {0, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}) {
auto corrupt = bytes;
corrupt[offset] ^= 1;
require(native_test_setup(slot, &setup, true) &&
!native_test_out(slot, corrupt.data(), corrupt.size(), true) &&
!native_test_in(slot, packet, &length, true),
"malformed child envelope obtained status authorization");
}
for (const auto& invalid : {envelope(Operation::kSwitch2Wake, {0, 0, 0, 0}),
envelope(Operation::kSwitch2Wake, {0, 0, 0, 0x80})}) {
require(native_test_setup(slot, &setup, true) &&
!native_test_out(slot, invalid.data(), invalid.size(), true) &&
!native_test_in(slot, packet, &length, true),
"invalid child request ID obtained status authorization");
}
require(native_test_setup(slot, &setup, true) &&
!native_test_out(slot, bytes.data(), bytes.size() - 1, true) &&
!native_test_in(slot, packet, &length, true) &&
wake_request_calls == expected_calls,
"short child OUT inherited a previous valid envelope");
for (uint8_t recipient : {0x40, 0x42, 0x43, 0xc0, 0xc2, 0xc3}) {
auto invalid = setup;
invalid.bmRequestType = recipient;
require(!native_test_setup(slot, &invalid, true),
"child management accepted the wrong recipient");
}
std::array<tusb_control_request_t, 5> invalid_setups;
invalid_setups.fill(setup);
invalid_setups[0].wValue ^= 1;
invalid_setups[1].wIndex = 0;
invalid_setups[2].wIndex = 0x101;
invalid_setups[3].wLength -= 1;
invalid_setups[4].wLength += 1;
for (const auto& invalid : invalid_setups)
require(!native_test_setup(slot, &invalid, true),
"child wake accepted an invalid value, interface, or length");
for (unsigned op = 0; op <= UINT8_MAX; ++op) {
for (bool input : {false, true}) {
if (op == static_cast<unsigned>(Operation::kSwitch2Wake) ||
(input && op == static_cast<unsigned>(Operation::kInfo))) continue;
const auto invalid = child_request(static_cast<Operation>(op), input,
input ? kMaximumResponseSize : kRequestHeaderSize);
require(!native_test_setup(slot, &invalid, true),
"child management exposed an operation outside INFO/WAKE");
}
}
read_child(slot);
}
require(!native_test_setup(0, &setup, true) &&
!native_test_setup(0, &info_setup, true),
"root management incorrectly accepted interface-recipient requests");
profile_service_task_on_storage_core(4500);
require(wake_request_calls == expected_calls &&
programs == programs_before && erases == erases_before,
"child discovery/rejection dispatched wake or persisted a change");
}
void test_child_management_interleaving() {
const uint32_t programs_before = programs, erases_before = erases;
uint32_t expected_calls = wake_request_calls;
const auto root_setup = request(Operation::kSwitch2Wake, false, kRequestHeaderSize + 4);
const auto child_setup = child_request(Operation::kSwitch2Wake, false, kRequestHeaderSize + 4);
require(native_test_setup(0, &root_setup, true), "root pending wake setup failed");
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot)
require(native_test_setup(slot, &child_setup, true), "concurrent child wake setup failed");
const auto root_bytes = envelope(Operation::kSwitch2Wake, {0x70, 0, 0, 0});
require(native_test_out(0, root_bytes.data(), root_bytes.size(), true) &&
wake_request_calls == ++expected_calls && last_wake_request_id == 0x70,
"child SETUP canceled or overwrote root pending wake");
acknowledge();
for (uint8_t slot = PROBE_CONTROLLER_COUNT; slot; --slot) {
const auto bytes = envelope(Operation::kSwitch2Wake, {slot, 0, 0, 0});
read_operation(Operation::kInfo);
require(native_test_out(slot, bytes.data(), bytes.size(), true) &&
wake_request_calls == ++expected_calls && last_wake_request_id == slot,
"root or sibling request canceled or overwrote a child's pending wake");
acknowledge(slot);
}
// Distinct snapshots remain stable until each independent IN is consumed.
wake_status.request_id = 0x80;
const auto root_read = request(Operation::kSwitch2Wake, true, kMaximumResponseSize);
const auto child_read = child_request(Operation::kSwitch2Wake, true, kMaximumResponseSize);
require(native_test_setup(0, &root_read, true), "root snapshot setup failed");
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
wake_status.request_id = slot;
require(native_test_setup(slot, &child_read, true), "child snapshot setup failed");
}
require(u32(receive(), kResponseHeaderSize) == 0x80,
"child response overwrote pending root IN");
for (uint8_t slot = PROBE_CONTROLLER_COUNT; slot; --slot)
require(u32(receive(slot), kResponseHeaderSize) == slot,
"root or sibling response overwrote pending child IN");
profile_service_task_on_storage_core(4600);
require(wake_request_calls == expected_calls &&
programs == programs_before && erases == erases_before,
"interleaved volatile management unexpectedly mutated or persisted state");
}
std::vector<uint8_t> identity_payload(uint8_t profile) {
std::vector<uint8_t> payload(15);
require(controller_identity_encode(controller_identity_global(), payload.data(), 14), "global identity did not encode");
@ -156,14 +348,6 @@ void require_profile(const std::vector<uint8_t>& expected) {
std::vector<uint8_t>(bytes.begin() + kResponseHeaderSize, bytes.end()) == expected,
"host readback differs from the durable selected profile");
}
void read_child(uint8_t slot) {
tusb_control_request_t setup{};
setup.bmRequestType = 0xc0; setup.bRequest = 3; setup.wLength = 128;
require(native_test_setup(slot, &setup, true), "native child identity stalled");
const auto bytes = receive(slot);
require(bytes == std::vector<uint8_t>(child_identity[slot - 1].begin(), child_identity[slot - 1].end()),
"native child identity leaked root or sibling vendor bytes");
}
void assign_address(uint8_t slot, uint8_t address) {
tusb_control_request_t setup{};
@ -431,6 +615,11 @@ void test_profile_transport() {
for (uint8_t slot = 1; slot <= PROBE_CONTROLLER_COUNT; ++slot) read_child(slot);
const auto child_management = request(Operation::kInfo, true, kMaximumResponseSize);
require(!native_test_setup(1, &child_management, true), "child INFO was accepted during a root write");
if (!SWITCH2_PROBE_NEUTRAL_INPUT) {
const auto child_info = child_request(Operation::kInfo, true, kMaximumResponseSize);
require(native_test_setup(1, &child_info, true) && receive(1) == info,
"child interface discovery failed during multipart root OUT");
}
require(native_test_out(0, chunk.data() + 64, chunk.size() - 64, true), "interleaved child requests corrupted root OUT tail");
acknowledge();
for (size_t offset = kMaximumChunkSize; offset < edited.size(); offset += kMaximumChunkSize)
@ -650,6 +839,7 @@ void test_neutral_management_surface() {
{Operation::kWiiOrientation, 19}, {Operation::kPairingRefresh, 0},
{Operation::kPairingClear, 0},
};
const uint32_t wake_calls_before = wake_request_calls;
for (uint8_t slot = 0; slot <= PROBE_CONTROLLER_COUNT; ++slot) {
for (Operation op : {Operation::kInfo, Operation::kConfigurationRead,
Operation::kTransactionStatus, Operation::kPairingRead,
@ -663,10 +853,22 @@ void test_neutral_management_surface() {
const auto setup = request(item.operation, false, kRequestHeaderSize + item.payload_size);
require(!native_test_setup(slot, &setup, true), "neutral device exposed a management mutation");
}
for (bool input : {false, true}) {
const auto wake = request(Operation::kSwitch2Wake, input,
input ? kMaximumResponseSize : kRequestHeaderSize + 4);
const auto child_wake = child_request(Operation::kSwitch2Wake, input, wake.wLength);
require(!native_test_setup(slot, &wake, true) &&
!native_test_setup(slot, &child_wake, true),
"neutral firmware exposed a wake management route");
}
const auto child_info = child_request(Operation::kInfo, true, kMaximumResponseSize);
require(!native_test_setup(slot, &child_info, true),
"neutral firmware exposed child management discovery");
if (slot) read_child(slot);
}
profile_service_task_on_storage_core(5000);
require(programs == programs_before && erases == erases_before,
require(programs == programs_before && erases == erases_before &&
wake_request_calls == wake_calls_before,
"neutral management rejection changed saved profiles");
require_no_bootsel();
}
@ -817,6 +1019,14 @@ ConfigurationTransactionStatus configuration_service_set_mode(uint32_t, AdapterR
const AdapterModeAvailability& adapter_usb_mode_availability() { unexpected_mutation(); }
bool adapter_reboot_for_mode_transaction(uint32_t) { unexpected_mutation(); }
bool adapter_reboot_to_bootsel() { unexpected_mutation(); }
bool bluepad32_input_backend_request_switch2_wake(uint32_t request_id) {
++wake_request_calls;
last_wake_request_id = request_id;
return accept_wake_request;
}
void bluepad32_input_backend_switch2_wake_snapshot(Bluepad32Switch2WakeStatus* out) {
*out = wake_status;
}
void bluepad32_input_backend_request_pairing_snapshot() { unexpected_mutation(); }
uint32_t bluepad32_input_backend_clear_pairings() { unexpected_mutation(); }
void bluepad32_input_backend_pairing_snapshot(Bluepad32PairingSnapshot* out) { *out = {}; }
@ -862,7 +1072,12 @@ int main(int argc, char** argv) {
test_profile_transport();
test_interrupted_transactions();
test_pending_control_buffer_ownership();
test_wake_transport();
synthetic_root_management = false;
if (!SWITCH2_PROBE_NEUTRAL_INPUT) {
test_child_wake_transport();
test_child_management_interleaving();
}
test_read_ack_allows_usb_progress();
test_private_transmit_survives_round_robin_tokens();
test_masked_irq_completion_handoff();

View file

@ -114,6 +114,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
]
)
subprocess.run(command, check=True, cwd=root)
subprocess.run([str(executable), "usb-wake"], check=True, cwd=root)
if wii_bridge:
for scenario in (
"wii-bridge-sensors",
@ -133,6 +134,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
continue
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
for scenario in (
"usb-wake-chord",
"wii-orientation",
"wii-orientation-races",
"wii-accelerometers",

View file

@ -5,6 +5,7 @@ import struct
import zlib
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
@ -705,6 +706,256 @@ def test_response_validation() -> None:
config_manager.parse_response(response, config_manager.OP_INFO)
def wake_response(
state: int = 0,
request_id: int = 0,
*,
configured: int = 1,
busy: int = 0,
completed: int = 8,
) -> bytes:
return make_response(
config_manager.OP_SWITCH2_WAKE,
struct.pack("<IBBBxIII", request_id, state, configured, busy, 10, completed, 2),
schema=1,
generation=request_id,
)
class WakeDevice(FakeDevice):
def __init__(self, responses: list[bytes | Exception]) -> None:
super().__init__()
self.wake_responses = responses
self.wake_directions: list[int] = []
self.wake_timeouts: 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:
if request != config_manager.OP_SWITCH2_WAKE:
return super().ctrl_transfer(
bm_request_type, request, value, index, data_or_w_length, timeout
)
assert value == config_manager.REQUEST_VALUE
assert index == config_manager.REQUEST_INDEX
assert 0 < timeout <= config_manager.USB_TIMEOUT_MS
self.wake_directions.append(bm_request_type)
self.wake_timeouts.append(timeout)
if bm_request_type == 0xC0:
response = self.wake_responses[0]
if len(self.wake_responses) > 1:
self.wake_responses.pop(0)
if isinstance(response, Exception):
raise response
return response
assert bm_request_type == 0x40
encoded = bytes(data_or_w_length)
payload = encoded[config_manager.REQUEST_HEADER_SIZE :]
assert len(payload) == 4
assert encoded == config_manager.encode_request(request, payload)
assert 0 < struct.unpack("<I", payload)[0] <= 0x7FFFFFFF
self.out_requests.append((request, payload, encoded))
return len(encoded)
@pytest.fixture
def wake_clock(
monkeypatch: pytest.MonkeyPatch, haptics_clock: list[float]
) -> list[float]:
monkeypatch.setattr(config_manager.secrets, "randbits", lambda _: 23)
return haptics_clock
def test_switch2_wake_waits_for_own_burst_despite_startup_snapshot(
wake_clock: list[float],
) -> None:
device = WakeDevice(
[
wake_response(configured=0),
wake_response(1, 23, configured=0),
wake_response(2, 23, busy=1, completed=9),
wake_response(3, 23, completed=10),
]
)
status = config_manager.request_switch2_wake(device)
assert status.state_name == "complete"
assert status.completed_bursts == 10
assert status.request_id == 23
assert device.wake_directions == [0xC0, 0x40, 0xC0, 0xC0, 0xC0]
assert wake_clock[0] == pytest.approx(0.1)
assert config_manager.read_switch2_wake_status(device) == status
assert len(device.out_requests) == 1
def test_switch2_wake_avoids_reusing_terminal_id(wake_clock: list[float]) -> None:
device = WakeDevice([wake_response(3, 23), wake_response(3, 24)])
assert config_manager.request_switch2_wake(device).request_id == 24
assert struct.unpack("<I", device.out_requests[0][1])[0] == 24
@pytest.mark.parametrize("state", (4, 5, 6))
def test_switch2_wake_terminal_failure_never_retries(
state: int, wake_clock: list[float]
) -> None:
device = WakeDevice([wake_response(), wake_response(state, 23)])
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_switch2_wake(device)
assert device.wake_directions == [0xC0, 0x40, 0xC0]
@pytest.mark.parametrize("state", (1, 2))
def test_switch2_wake_does_not_replace_active_request(
state: int, wake_clock: list[float]
) -> None:
device = WakeDevice([wake_response(state, 19, busy=1)])
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_switch2_wake(device)
assert device.out_requests == []
def test_switch2_wake_rejects_another_requests_completion(
wake_clock: list[float],
) -> None:
device = WakeDevice(
[wake_response(), wake_response(2, 23), wake_response(3, 24, completed=99)]
)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_switch2_wake(device)
assert len(device.out_requests) == 1
def test_switch2_wake_times_out_without_retry(wake_clock: list[float]) -> None:
device = WakeDevice([wake_response(), wake_response(1, 23)])
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_switch2_wake(device, timeout=0.12)
assert wake_clock[0] == pytest.approx(0.12)
assert len(device.out_requests) == 1
assert max(device.wake_timeouts) <= 120
@pytest.mark.parametrize(
"timeout", (0, -1, float("nan"), float("inf"), float("-inf"), True, "1", None)
)
def test_switch2_wake_invalid_timeout_never_touches_usb(timeout: object) -> None:
device = WakeDevice([wake_response()])
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_switch2_wake(device, timeout=timeout) # type: ignore[arg-type]
assert device.wake_directions == []
@pytest.mark.parametrize(
"changes",
(
{"schema_version": 2},
{"payload": b""},
{"flags": 1},
{"generation": 1},
{"payload": struct.pack("<IBBBBIII", 0, 0, 1, 0, 1, 10, 8, 2)},
{"payload": struct.pack("<IBBBxIII", 0, 0, 2, 0, 10, 8, 2)},
{"payload": struct.pack("<IBBBxIII", 0, 0, 1, 2, 10, 8, 2)},
{"payload": struct.pack("<IBBBxIII", 1, 7, 1, 0, 10, 8, 2), "generation": 1},
{"payload": struct.pack("<IBBBxIII", 0, 3, 1, 0, 10, 8, 2)},
{
"payload": struct.pack("<IBBBxIII", 0x80000000, 3, 1, 0, 10, 8, 2),
"generation": 0x80000000,
},
{"status": config_manager.STATUS_PENDING},
),
)
def test_switch2_wake_malformed_preflight_cannot_start_burst(
changes: dict[str, object],
) -> None:
initial = config_manager.parse_response(
wake_response(), config_manager.OP_SWITCH2_WAKE
)
malformed = replace(initial, **changes)
device = WakeDevice(
[
make_response(
config_manager.OP_SWITCH2_WAKE,
malformed.payload,
status=malformed.status,
flags=malformed.flags,
schema=malformed.schema_version,
generation=malformed.generation,
)
]
)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.request_switch2_wake(device)
assert device.out_requests == []
@pytest.mark.parametrize("errno,backend", ((32, None), (None, -9), (13, -3), (19, -4)))
def test_switch2_wake_preflight_transport_errors_preserve_real_cause(
errno: int | None, backend: int | None
) -> None:
error = config_manager.usb.core.USBError(
"transport error", error_code=backend, errno=errno
)
device = WakeDevice([error])
if errno == 32 or backend == -9:
with pytest.raises(config_manager.ConfigManagerError) as raised:
config_manager.request_switch2_wake(device)
assert raised.value.__cause__ is error
else:
with pytest.raises(config_manager.usb.core.USBError) as raised:
config_manager.request_switch2_wake(device)
assert raised.value is error
assert device.out_requests == []
@pytest.mark.parametrize("errno", (32, None))
def test_switch2_wake_poll_stall_preserves_transport_failure(
wake_clock: list[float], errno: int | None
) -> None:
error = config_manager.usb.core.USBError(
"transport error", error_code=-9, errno=errno
)
device = WakeDevice([wake_response(), error])
with pytest.raises(config_manager.usb.core.USBError) as raised:
config_manager.request_switch2_wake(device)
assert raised.value is error
assert len(device.out_requests) == 1
def test_switch2_wake_cli_selects_device_and_reports_limited_evidence(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
wake_clock: list[float],
) -> None:
device = WakeDevice([wake_response(), wake_response(3, 23)])
other = WakeDevice([wake_response()])
other.address = 8
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: [other, device])
assert config_manager.main(["--bus", "1", "--address", "7", "wake", "--json"]) == 0
result = json.loads(capsys.readouterr().out)
assert result["state_name"] == "complete"
assert result["console_power_confirmed"] is False
assert len(device.out_requests) == 1
assert other.out_requests == []
device.wake_responses = [wake_response(), wake_response(6, 23)]
assert config_manager.main(["--address", "7", "wake", "--json"]) == 1
assert capsys.readouterr().out == ""
@pytest.mark.parametrize("timeout", ("nan", "inf", "-inf", "0", "-1"))
def test_switch2_wake_cli_rejects_unbounded_timeout_before_discovery(
monkeypatch: pytest.MonkeyPatch, timeout: str
) -> None:
def unexpected_discovery(*args: object) -> None:
pytest.fail("invalid wake timeout reached USB discovery")
monkeypatch.setattr(config_manager, "find_wake_pico", unexpected_discovery)
assert config_manager.main([f"--timeout={timeout}", "wake"]) == 2
def native_rumble_identity(
address: bytes = bytes.fromhex("102030405060"), product_id: int = 0x2009
) -> config_manager.ControllerIdentity:
@ -2092,8 +2343,12 @@ def test_set_b_sparse_settings_and_macro_modes_round_trip() -> None:
del legacy_json["extra_button_map"]
del legacy_json["shift"]["extra_button_map"]
for field in (
"swing", "nunchuk_swing", "combined_swing", "combination_window_ms",
"native_joycon_layout", "swap_sticks",
"swing",
"nunchuk_swing",
"combined_swing",
"combination_window_ms",
"native_joycon_layout",
"swap_sticks",
):
del legacy_json[field]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
@ -2288,8 +2543,10 @@ def test_source_only_controls_are_not_output_destinations(
def test_left_stick_direction_destinations_round_trip_without_new_sources() -> None:
obj = custom_profile().to_json_object()
obj["button_map"].update(
dpad_up="left_stick_up", dpad_down="left_stick_down",
dpad_left="left_stick_left", dpad_right="left_stick_right",
dpad_up="left_stick_up",
dpad_down="left_stick_down",
dpad_left="left_stick_left",
dpad_right="left_stick_right",
)
obj["extra_button_map"]["c"] = "left_stick_left"
obj["shift"]["button_map"]["south"] = "left_stick_down"
@ -2421,7 +2678,9 @@ def test_schema10_native_settings_migrate_without_persistent_writes() -> None:
@pytest.mark.parametrize("version", [1, 2])
def test_legacy_button_maps_reject_analog_destinations(version: int) -> None:
payload = legacy_profile_wire(version)
assert config_manager.ControllerProfile.from_bytes(payload).button_map == tuple(range(16))
assert config_manager.ControllerProfile.from_bytes(payload).button_map == tuple(
range(16)
)
payload[4] = 16
with pytest.raises(config_manager.ConfigManagerError):
config_manager.ControllerProfile.from_bytes(payload)
@ -2460,8 +2719,17 @@ def test_schema9_gestures_migrate_without_persistent_writes() -> None:
@pytest.mark.parametrize(
("offset", "value"),
[(4, 18), (60, 18), (70, 24), (344, 18), (267, 16), (351, 16),
(376, 1), (377, 1), (383, 1)],
[
(4, 18),
(60, 18),
(70, 24),
(344, 18),
(267, 16),
(351, 16),
(376, 1),
(377, 1),
(383, 1),
],
)
def test_schema9_keeps_legacy_output_and_reserved_byte_bounds(
offset: int, value: int
@ -2486,9 +2754,14 @@ def test_native_fields_reject_invalid_wire_values(offset: int, value: int) -> No
@pytest.mark.parametrize(
("field", "value"),
[("native_joycon_layout", "solo"), ("native_joycon_layout", 1),
("native_joycon_layout", None), ("swap_sticks", 1),
("swap_sticks", "false"), ("swap_sticks", None)],
[
("native_joycon_layout", "solo"),
("native_joycon_layout", 1),
("native_joycon_layout", None),
("swap_sticks", 1),
("swap_sticks", "false"),
("swap_sticks", None),
],
)
def test_native_fields_reject_invalid_json_types(field: str, value: object) -> None:
obj = custom_profile().to_json_object()
@ -2736,8 +3009,11 @@ def test_schema8_swing_migration_preserves_remote_binding() -> None:
legacy_json["schema_version"] = 8
del legacy_json["swing"]["macro"]
for field in (
"nunchuk_swing", "combined_swing", "combination_window_ms",
"native_joycon_layout", "swap_sticks",
"nunchuk_swing",
"combined_swing",
"combination_window_ms",
"native_joycon_layout",
"swap_sticks",
):
del legacy_json[field]
assert config_manager.ControllerProfile.from_bytes(legacy_wire) == profile
@ -3646,6 +3922,7 @@ def test_discovery_finds_one_adapter_without_its_native_children(
monkeypatch: pytest.MonkeyPatch,
identity: tuple[int, int],
) -> None:
monkeypatch.setattr(config_manager, "sys", SimpleNamespace(platform="linux"))
device = FakeDevice()
if identity == (0x057E, 0x2068):
device.firmware_version = (0, 72, 0)
@ -3667,6 +3944,7 @@ def test_discovery_finds_one_adapter_without_its_native_children(
monkeypatch.setattr(config_manager.usb.core, "find", find)
assert config_manager.find_pico(None, None, timeout=0) is device
assert config_manager.find_wake_pico(None, None, timeout=0) is device
for child in (right, left):
with pytest.raises(config_manager.ConfigManagerError, match="no USB-connected"):
config_manager.find_pico(child.bus, child.address, timeout=0)
@ -3685,6 +3963,8 @@ def test_native_discovery_requires_validated_management_info(
monkeypatch: pytest.MonkeyPatch,
response: bytes | Exception,
) -> None:
monkeypatch.setattr(config_manager, "sys", SimpleNamespace(platform="linux"))
class NintendoDevice(FakeDevice):
address = 8
@ -3723,6 +4003,531 @@ def test_find_requires_selector_for_multiple_picos(
assert config_manager.find_pico(1, 8) is second
class WakeDiscoveryBackend:
"""Descriptor/transport fixture using real PyUSB Device resource management."""
def __init__(self) -> None:
self.nodes: dict[tuple[int, int], SimpleNamespace] = {}
self.opened: list[tuple[int, int]] = []
self.closed: list[tuple[int, int]] = []
self.claimed: list[tuple[tuple[int, int], int]] = []
self.released: list[tuple[tuple[int, int], int]] = []
self.controls: list[tuple[tuple[int, int], int, int, int, int]] = []
self.writes: list[bytes] = []
def add(
self,
identity: tuple[int, int],
address: int,
*,
bus: int = 1,
ports: tuple[int, ...] | None = (1,),
parent: tuple[int, int] | None = None,
) -> tuple[int, int]:
native = identity in config_manager.NATIVE_CHILD_IDENTITIES
hub = identity == config_manager.NATIVE_HUB_IDENTITY
key = (bus, address)
self.nodes[key] = SimpleNamespace(
descriptor=SimpleNamespace(
bLength=18,
bDescriptorType=1,
bcdUSB=0x200,
bDeviceClass=9 if hub else 0xEF if native else 0,
bDeviceSubClass=2 if native else 0,
bDeviceProtocol=1 if native else 0,
bMaxPacketSize0=64,
idVendor=identity[0],
idProduct=identity[1],
bcdDevice=0x110,
iManufacturer=0,
iProduct=0,
iSerialNumber=0,
bNumConfigurations=1,
address=address,
bus=bus,
port_number=ports[-1] if ports else None,
port_numbers=ports,
speed=2,
),
interface=SimpleNamespace(
bLength=9,
bDescriptorType=4,
bInterfaceNumber=1,
bAlternateSetting=0,
bNumEndpoints=2,
bInterfaceClass=0xFF,
bInterfaceSubClass=0,
bInterfaceProtocol=0,
iInterface=0,
extra_descriptors=b"",
),
parent=parent,
info=make_response(
config_manager.OP_INFO,
bytes((0, 110, 0, 2, 5 if native or hub else 1, 7, 0, 2)),
),
claim_error=None,
request_id=0,
)
return key
def enumerate_devices(self) -> object:
return iter(self.nodes)
def get_device_descriptor(self, key: tuple[int, int]) -> SimpleNamespace:
return self.nodes[key].descriptor
def get_parent(self, key: tuple[int, int]) -> tuple[int, int] | None:
return self.nodes[key].parent
def get_configuration_descriptor(
self, key: tuple[int, int], configuration: int
) -> SimpleNamespace:
if configuration != 0:
raise IndexError(configuration)
return SimpleNamespace(
bLength=9,
bDescriptorType=2,
wTotalLength=64,
bNumInterfaces=2,
bConfigurationValue=1,
iConfiguration=0,
bmAttributes=0x80,
bMaxPower=250,
extra_descriptors=b"",
)
def get_interface_descriptor(
self, key: tuple[int, int], interface: int, alternate: int, configuration: int
) -> SimpleNamespace:
if interface != 1 or alternate != 0 or configuration != 0:
raise IndexError(interface)
descriptor = self.nodes[key].interface
if descriptor is None:
raise IndexError(interface)
return descriptor
def open_device(self, key: tuple[int, int]) -> tuple[int, int]:
self.opened.append(key)
return key
def close_device(self, key: tuple[int, int]) -> None:
self.closed.append(key)
def claim_interface(self, key: tuple[int, int], interface: int) -> None:
error = self.nodes[key].claim_error
if error is not None:
raise error
self.claimed.append((key, interface))
def release_interface(self, key: tuple[int, int], interface: int) -> None:
self.released.append((key, interface))
def ctrl_transfer(
self,
key: tuple[int, int],
direction: int,
operation: int,
value: int,
index: int,
data: object,
timeout: int,
) -> int:
self.controls.append((key, direction, operation, value, index))
node = self.nodes[key]
if direction & 0x80:
if operation == config_manager.OP_INFO:
response = node.info
if isinstance(response, Exception):
raise response
else:
assert operation == config_manager.OP_SWITCH2_WAKE
response = wake_response(3 if node.request_id else 0, node.request_id)
for offset, byte in enumerate(response):
data[offset] = byte
return len(response)
encoded = bytes(data)
self.writes.append(encoded)
if operation == config_manager.OP_SWITCH2_WAKE:
payload = encoded[config_manager.REQUEST_HEADER_SIZE :]
assert encoded == config_manager.encode_request(operation, payload)
node.request_id = struct.unpack("<I", payload)[0]
return len(encoded)
@pytest.fixture
def windows_usb(monkeypatch: pytest.MonkeyPatch) -> WakeDiscoveryBackend:
backend = WakeDiscoveryBackend()
original_import = config_manager.importlib.import_module
def import_module(name: str, package: str | None = None) -> object:
if name == "libusb_package":
return SimpleNamespace(get_libusb1_backend=lambda: backend)
return original_import(name, package)
monkeypatch.setattr(config_manager.importlib, "import_module", import_module)
monkeypatch.setattr(
config_manager,
"sys",
SimpleNamespace(platform="win32", stderr=config_manager.sys.stderr),
)
return backend
def test_windows_wake_groups_siblings_without_opening_hub(
windows_usb: WakeDiscoveryBackend,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
right = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
windows_usb.add((0x057E, 0x2067), 9, ports=(1, 2), parent=hub)
device = config_manager.find_wake_pico(None, None, timeout=0)
assert isinstance(device, config_manager.usb.core.Device)
assert device.address == 8
assert windows_usb.opened == [right]
assert windows_usb.claimed == [(right, 1)]
assert windows_usb.controls == [(right, 0xC1, 0x01, 0x5350, 1)]
assert windows_usb.writes == []
config_manager.usb.util.dispose_resources(device)
assert windows_usb.released == [(right, 1)]
assert windows_usb.closed == [right]
def test_windows_wake_uses_sibling_with_available_vendor_driver(
windows_usb: WakeDiscoveryBackend,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
right = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
left = windows_usb.add((0x057E, 0x2067), 9, ports=(1, 2), parent=hub)
windows_usb.nodes[right].claim_error = config_manager.usb.core.USBError(
"no WinUSB interface", error_code=-12
)
device = config_manager.find_wake_pico(None, None, timeout=0)
assert device.address == 9
assert windows_usb.opened == [right, left]
assert windows_usb.closed == [right]
assert windows_usb.claimed == [(left, 1)]
assert windows_usb.controls == [(left, 0xC1, 0x01, 0x5350, 1)]
config_manager.usb.util.dispose_resources(device)
assert windows_usb.closed == [right, left]
def test_windows_wake_distinct_picos_remain_ambiguous(
windows_usb: WakeDiscoveryBackend,
) -> None:
for bus in (1, 2):
hub = windows_usb.add((0x057E, 0x2068), 7, bus=bus)
windows_usb.add((0x057E, 0x2066), 8, bus=bus, ports=(1, 1), parent=hub)
with pytest.raises(config_manager.ConfigManagerError, match="multiple switch-pico"):
config_manager.find_wake_pico(None, None, timeout=0)
assert windows_usb.opened == [(1, 8), (2, 8)]
assert windows_usb.closed == windows_usb.opened
assert windows_usb.released == windows_usb.claimed
assert windows_usb.writes == []
@pytest.mark.parametrize(
"bus,address,expected",
(
(1, 7, (1, 8)),
(1, 9, (1, 9)),
(2, None, (2, 11)),
(None, 10, (2, 11)),
(None, 11, (2, 11)),
(1, 11, None),
),
)
def test_windows_wake_selectors_accept_parent_or_child(
windows_usb: WakeDiscoveryBackend,
bus: int | None,
address: int | None,
expected: tuple[int, int] | None,
) -> None:
first = windows_usb.add((0x057E, 0x2068), 7)
windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=first)
windows_usb.add((0x057E, 0x2067), 9, ports=(1, 2), parent=first)
second = windows_usb.add((0x057E, 0x2068), 10, bus=2)
windows_usb.add((0x057E, 0x2066), 11, bus=2, ports=(1, 1), parent=second)
if expected is None:
with pytest.raises(config_manager.ConfigManagerError):
config_manager.find_wake_pico(bus, address, timeout=0)
assert windows_usb.opened == []
else:
device = config_manager.find_wake_pico(bus, address, timeout=0)
assert (device.bus, device.address) == expected
assert windows_usb.opened == [expected]
config_manager.usb.util.dispose_resources(device)
assert windows_usb.writes == []
@pytest.mark.parametrize("source", ("parent_address", "parent_topology", "topology"))
def test_windows_wake_groups_by_parent_identity_or_topology(
windows_usb: WakeDiscoveryBackend,
source: str,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
for address, product, port in ((8, 0x2066, 1), (9, 0x2067, 2)):
child = windows_usb.add(
(0x057E, product),
address,
ports=(1, port),
parent=None if source == "topology" else hub,
)
if source == "parent_address":
windows_usb.nodes[child].descriptor.port_numbers = None
if source == "parent_address":
windows_usb.nodes[hub].descriptor.port_numbers = None
elif source == "parent_topology":
windows_usb.nodes[hub].descriptor.address = None
device = config_manager.find_wake_pico(None, None, timeout=0)
assert device.address == 8
assert windows_usb.opened == [(1, 8)]
config_manager.usb.util.dispose_resources(device)
@pytest.mark.parametrize("wake", (False, True))
def test_windows_normal_adapter_keeps_device_recipient_info(
windows_usb: WakeDiscoveryBackend,
wake: bool,
) -> None:
windows_usb.add((0x057E, 0x2068), 7)
aio = windows_usb.add((0xCAFE, 0x4010), 10, ports=(2,))
discover = config_manager.find_wake_pico if wake else config_manager.find_pico
device = discover(None, None, timeout=0)
assert device.address == 10
assert windows_usb.opened == [aio]
assert windows_usb.controls == [(aio, 0xC0, 0x01, 0x5350, 1)]
assert windows_usb.claimed == []
config_manager.usb.util.dispose_resources(device)
@pytest.mark.parametrize(
"target,field,value",
(
("child", "idVendor", 0x1234),
("child", "idProduct", 0x2065),
("child", "bDeviceClass", 9),
("child", "bDeviceSubClass", 0),
("child", "bDeviceProtocol", 0),
("parent", "idVendor", 0x1234),
("parent", "idProduct", 0x2009),
("parent", "bDeviceClass", 0),
("interface", "bInterfaceNumber", 0),
("interface", "bAlternateSetting", 1),
("interface", "bInterfaceClass", 3),
("interface", "bInterfaceSubClass", 1),
("interface", "bInterfaceProtocol", 1),
("missing", "", None),
("orphan", "", None),
),
)
def test_windows_wake_rejects_untrusted_descriptors_before_probe(
windows_usb: WakeDiscoveryBackend,
target: str,
field: str,
value: object,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
child = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
node = windows_usb.nodes[child]
if target == "missing":
node.interface = None
elif target == "orphan":
node.parent = None
node.descriptor.port_numbers = None
else:
descriptor = (
node.interface
if target == "interface"
else windows_usb.nodes[hub].descriptor
if target == "parent"
else node.descriptor
)
setattr(descriptor, field, value)
with pytest.raises(config_manager.ConfigManagerError):
config_manager.find_wake_pico(1, 8, timeout=0)
assert windows_usb.opened == []
assert windows_usb.controls == []
assert windows_usb.writes == []
@pytest.mark.parametrize(
"response",
(
b"Nintendo",
make_response(config_manager.OP_INFO, b""),
make_response(config_manager.OP_INFO, bytes((0, 110, 0, 2, 1, 7, 0, 2))),
make_response(config_manager.OP_INFO, bytes((0, 110, 0, 2, 5, 0x80, 0, 2))),
make_response(config_manager.OP_INFO, bytes((0, 110, 0, 2, 5, 7, 0, 2)))[:-1],
),
)
def test_windows_wake_identity_probe_is_read_only_and_fail_closed(
windows_usb: WakeDiscoveryBackend,
response: bytes,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
child = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
windows_usb.nodes[child].info = response
with pytest.raises(config_manager.ConfigManagerError):
config_manager.find_wake_pico(None, None, timeout=0)
assert windows_usb.controls == [(child, 0xC1, 0x01, 0x5350, 1)]
assert windows_usb.writes == []
assert windows_usb.closed == [child]
assert windows_usb.released == [(child, 1)]
def test_windows_wake_hub_without_children_never_opens_root(
windows_usb: WakeDiscoveryBackend,
) -> None:
windows_usb.add((0x057E, 0x2068), 7)
with pytest.raises(config_manager.ConfigManagerError, match="WinUSB"):
config_manager.find_wake_pico(None, None, timeout=0)
assert windows_usb.opened == []
assert windows_usb.controls == []
def test_windows_wake_missing_driver_reports_safe_binding_guidance(
windows_usb: WakeDiscoveryBackend,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
child = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
error = config_manager.usb.core.USBError("no vendor driver", error_code=-12)
windows_usb.nodes[child].claim_error = error
with pytest.raises(config_manager.ConfigManagerError) as raised:
config_manager.find_wake_pico(None, None, timeout=0)
assert raised.value.__cause__ is error
message = str(raised.value)
assert "WinUSB" in message and "Interface 1" in message
assert "Never replace" in message and "Interface 0" in message
assert "composite parent" in message and "hub" in message
assert windows_usb.controls == []
assert windows_usb.closed == [child]
@pytest.mark.parametrize("code", (-3, -4, -9, -7))
def test_windows_wake_probe_preserves_transport_errors(
windows_usb: WakeDiscoveryBackend,
code: int,
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
child = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
error = config_manager.usb.core.USBError("transport failure", error_code=code)
windows_usb.nodes[child].info = error
with pytest.raises(config_manager.usb.core.USBError) as raised:
config_manager.find_wake_pico(None, None, timeout=0)
assert raised.value is error
assert windows_usb.writes == []
assert windows_usb.closed == [child]
assert windows_usb.released == [(child, 1)]
def test_windows_wake_enumeration_preserves_backend_error(
windows_usb: WakeDiscoveryBackend,
monkeypatch: pytest.MonkeyPatch,
) -> None:
error = config_manager.usb.core.USBError("enumeration failed", error_code=-1)
def fail() -> None:
raise error
monkeypatch.setattr(windows_usb, "enumerate_devices", fail)
with pytest.raises(config_manager.usb.core.USBError) as raised:
config_manager.find_wake_pico(None, None, timeout=0)
assert raised.value is error
assert windows_usb.opened == []
def test_windows_backend_initialization_preserves_usb_error(
windows_usb: WakeDiscoveryBackend,
monkeypatch: pytest.MonkeyPatch,
) -> None:
error = config_manager.usb.core.USBError("backend init failed", error_code=-1)
def load_backend() -> None:
raise error
monkeypatch.setattr(
config_manager.importlib,
"import_module",
lambda _name: SimpleNamespace(get_libusb1_backend=load_backend),
)
with pytest.raises(config_manager.usb.core.USBError) as raised:
config_manager.find_wake_pico(None, None, timeout=0)
assert raised.value is error
assert windows_usb.opened == []
@pytest.mark.parametrize("failure", ("package", "dll", "backend"))
def test_windows_wake_reports_missing_libusb_runtime(
windows_usb: WakeDiscoveryBackend,
monkeypatch: pytest.MonkeyPatch,
failure: str,
) -> None:
def load_backend() -> None:
if failure == "dll":
raise OSError("DLL could not load")
def import_module(name: str) -> SimpleNamespace:
assert name == "libusb_package"
if failure == "package":
raise ModuleNotFoundError(name)
return SimpleNamespace(get_libusb1_backend=load_backend)
monkeypatch.setattr(config_manager.importlib, "import_module", import_module)
with pytest.raises(config_manager.ConfigManagerError, match="libusb-package"):
config_manager.find_wake_pico(None, None, timeout=0)
assert windows_usb.opened == []
def test_native_child_wake_route_also_works_without_windows_discovery(
wake_clock: list[float],
) -> None:
backend = WakeDiscoveryBackend()
child = backend.add((0x057E, 0x2066), 8)
device = config_manager.usb.core.Device(child, backend)
assert (
config_manager.read_info(device).active_mode
== config_manager.ACTIVE_MODE_NATIVE_HUB
)
assert config_manager.request_switch2_wake(device).state_name == "complete"
config_manager.request_bootsel_reboot(device)
assert [
(direction, operation) for _, direction, operation, _, _ in backend.controls
] == [
(0xC1, 0x01),
(0xC1, 0x05),
(0x41, 0x05),
(0xC1, 0x05),
(0x40, 0x04),
]
assert all(
value == 0x5350 and index == 1 for _, _, _, value, index in backend.controls
)
config_manager.usb.util.dispose_resources(device)
def test_windows_wake_cli_uses_vendor_interface_and_preserves_json(
windows_usb: WakeDiscoveryBackend,
wake_clock: list[float],
capsys: pytest.CaptureFixture[str],
) -> None:
hub = windows_usb.add((0x057E, 0x2068), 7)
child = windows_usb.add((0x057E, 0x2066), 8, ports=(1, 1), parent=hub)
assert config_manager.main(["--bus", "1", "--address", "7", "wake", "--json"]) == 0
result = json.loads(capsys.readouterr().out)
assert result["state_name"] == "complete"
assert result["console_power_confirmed"] is False
assert result["request_id"] == 23
assert windows_usb.controls == [
(child, 0xC1, 0x01, 0x5350, 1),
(child, 0xC1, 0x05, 0x5350, 1),
(child, 0x41, 0x05, 0x5350, 1),
(child, 0xC1, 0x05, 0x5350, 1),
]
assert len(windows_usb.writes) == 1
def haptics_response(
state: int = 0,
*,

View file

@ -81,6 +81,7 @@ def test_native_gamepad_backend_native(
) -> None:
root, executable = compile_native_backend(tmp_path, source, controller_count)
scenarios = [
"usb-wake",
"stable-logical-slot",
"cue-lifetime",
"cue-races",
@ -128,6 +129,7 @@ def test_native_gamepad_backend_native(
def test_native_gamepad_hd_backend_native(tmp_path: Path) -> None:
root, executable = compile_native_backend(tmp_path, "GAMEPAD", 4, hd=True)
for scenario in (
"usb-wake",
"hd-second-pair-wii",
"hd-second-pair-other",
"hd-unselected-dualsense",

View file

@ -8,8 +8,13 @@ import pytest
@pytest.mark.parametrize("clock_mhz", [240, 300], ids=["240MHz", "300MHz"])
@pytest.mark.parametrize(
("controller_count", "neutral_input"),
[(2, False), (2, True), (4, True)],
ids=["native-management", "neutral-one-pair", "neutral-two-pair"],
[(2, False), (4, False), (2, True), (4, True)],
ids=[
"native-management",
"native-two-pair",
"neutral-one-pair",
"neutral-two-pair",
],
)
def test_native_hub_management_native(
tmp_path: Path, controller_count: int, neutral_input: bool, clock_mhz: int
@ -37,6 +42,8 @@ def test_native_hub_management_native(
f"-DSWITCH_PICO_SYS_CLOCK_MHZ={clock_mhz}",
]
flags.append(f"-DSWITCH2_PROBE_NEUTRAL_INPUT={int(neutral_input)}")
if controller_count == 4 and not neutral_input:
flags.append("-DSWITCH2_BRIDGE_FULL_INPUT=1")
transport = tmp_path / "native_hub_transport.o"
executable = tmp_path / "native_hub_management_test"
subprocess.run(

View file

@ -40,6 +40,9 @@ bool bootsel_reboot_requested = false;
bool refresh_requested = false;
bool clear_requested = false;
Bluepad32BackendDiagnostics current_diagnostics{};
Bluepad32Switch2WakeStatus current_wake_status{};
uint32_t wake_request_calls = 0;
bool accept_wake_request = true;
std::vector<uint8_t> control_payload;
std::vector<uint8_t> next_out_payload;
uint32_t begin_transaction_id = 0;
@ -771,6 +774,99 @@ void test_profile_vendor_requests() {
"short profile selection request was accepted");
}
std::vector<uint8_t> read_wake_status() {
using namespace UsbConfigurationManagement;
const auto setup = setup_request(Operation::kSwitch2Wake, TUSB_DIR_IN, 40);
require(usb_configuration_management_vendor_control(0, CONTROL_STAGE_SETUP, &setup) &&
usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup),
"read-only wake status transfer was rejected");
require(control_payload.size() == 40 && control_payload[5] == 0x05 &&
control_payload[6] == 0 && control_payload[7] == 0 &&
read_u16(control_payload, 8) == 20 &&
read_u16(control_payload, 10) == 1 &&
read_u32(control_payload, 12) == read_u32(control_payload, 20) &&
control_payload[27] == 0 &&
read_u32(control_payload, 16) ==
configuration_crc32(control_payload.data() + 20, 20),
"wake schema, request correlation, reserved byte or CRC is invalid");
return control_payload;
}
void test_switch2_wake_requests() {
using namespace UsbConfigurationManagement;
std::vector<uint8_t> payload(4);
write_u32(&payload, 0, 0x12345678);
const auto setup = setup_request(Operation::kSwitch2Wake, TUSB_DIR_OUT, 20);
for (uint16_t size : {16, 19, 21}) {
const auto malformed = setup_request(Operation::kSwitch2Wake, TUSB_DIR_OUT, size);
require(!usb_configuration_management_vendor_control(0, CONTROL_STAGE_SETUP, &malformed),
"wake OUT accepted a non-u32 payload size");
}
const auto begin = [&] {
next_out_payload = make_request(Operation::kSwitch2Wake, payload);
require(usb_configuration_management_vendor_control(0, CONTROL_STAGE_SETUP, &setup),
"wake OUT setup was rejected");
};
begin();
require(!usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == 0,
"wake work must be accepted during DATA, never after an unchecked status ACK");
for (uint32_t id : {0u, 0x80000000u}) {
write_u32(&payload, 0, id);
begin();
require(!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == 0,
"invalid wake ID reached the backend or obtained a status ACK");
}
write_u32(&payload, 0, 0x12345678);
next_out_payload = make_request(Operation::kSwitch2Wake, payload);
next_out_payload[12] ^= 1;
require(usb_configuration_management_vendor_control(0, CONTROL_STAGE_SETUP, &setup) &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
wake_request_calls == 0,
"corrupt wake envelope dispatched radio work");
begin();
auto wrong_setup = setup;
--wrong_setup.wLength;
require(!usb_configuration_management_vendor_control(1, CONTROL_STAGE_DATA, &setup) &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &wrong_setup) &&
wake_request_calls == 0,
"another pipe or SETUP must not consume the staged wake request");
require(usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
wake_request_calls == 1,
"wake acceptance must occur before the status ACK");
require(!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup) &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == 1,
"duplicate USB stages must not enqueue another wake");
accept_wake_request = false;
begin();
require(!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup),
"a busy mailbox must reject before status ACK");
accept_wake_request = true;
require(!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == 2,
"a duplicate rejected DATA must not become accepted after the mailbox frees");
begin();
current_wake_status = {0x12345678, Bluepad32Switch2WakeState::kFailed,
true, false, 7, 4, 2};
const auto status = read_wake_status(); // IN SETUP cancels the staged OUT.
require(read_u32(status, 20) == 0x12345678 && status[24] == 6 &&
status[25] == 1 && status[26] == 0 &&
read_u32(status, 28) == 7 && read_u32(status, 32) == 4 &&
read_u32(status, 36) == 2,
"a failed asynchronous burst must remain correlated in the wire status");
require(read_wake_status() == status &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_DATA, &setup) &&
!usb_configuration_management_vendor_control(0, CONTROL_STAGE_ACK, &setup) &&
wake_request_calls == 2,
"status reads or stale OUT stages must never enqueue or consume wake work");
}
std::vector<uint8_t> read_haptics_payload() {
using namespace UsbConfigurationManagement;
tusb_control_request_t request = setup_request(
@ -1252,6 +1348,15 @@ bool bluepad32_input_backend_capture_page(
return true;
}
bool bluepad32_input_backend_request_switch2_wake(uint32_t) {
++wake_request_calls;
return accept_wake_request;
}
void bluepad32_input_backend_switch2_wake_snapshot(Bluepad32Switch2WakeStatus* output) {
*output = current_wake_status;
}
void bluepad32_input_backend_request_pairing_snapshot() {
refresh_requested = true;
}
@ -1346,6 +1451,7 @@ int main() {
test_vendor_requests();
test_mode_vendor_requests();
test_profile_vendor_requests();
test_switch2_wake_requests();
test_haptics_experiment_requests();
test_haptics_transport_probe_requests();
return 0;