diff --git a/README.md b/README.md index 57dd166..4a3c38e 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ is introduced. ### Switch 2 wake from L + R + Home, PS, or Xbox -The AIO firmware can wake a sleeping Switch 2 when a connected controller's +Configured AIO and native-hub firmware can wake a sleeping Switch 2 when a connected controller's physical **L + R + System** chord becomes held: L + R + Home on Nintendo-style controllers, L1 + R1 + PS on PlayStation controllers, or LB + RB + Xbox on Xbox controllers. Plain Home, PS, or Xbox remains a normal @@ -204,6 +204,79 @@ before another attempt. Plain Home, PS, or Xbox is forwarded normally and does not disturb the radio. Because the wake identity is stable from startup, the wake code itself does not disconnect the input controller. +**Wake from Python over USB:** firmware with the USB wake command (native +0.109 and rebuilt AIO images) can send the same burst while the Pico stays +plugged into the PC. The wake signal goes over BLE; the Pico does not need a +USB connection to the Switch or a connected controller. The console-specific +wake configuration above is still required, and Classic-only builds cannot +broadcast it. The physical controller chord remains available. + +From the repository root: + +```sh +uv run python -m switch_pico_bridge.config_manager wake +# Equivalent CLI, with machine-readable output: +uv run switch-pico-config wake --json +``` + +For multiple Picos, put `--bus N --address N` before `wake`. The command submits +one volatile request and waits for its advertising burst to finish. It reports +unconfigured, busy and radio-failure outcomes rather than silently retrying. +`--timeout SECONDS` bounds the wait, but does not cancel an already accepted +burst. A successful result confirms the firmware sequence, not physical RF +delivery or the console's power state. No pairings, profiles, USB mode or +saved settings are changed. + +**Windows native-hub setup (firmware 0.110 or newer):** + +Windows libusb backends cannot send these requests to a hub root; see the +[libusb Windows restrictions](https://github.com/libusb/libusb/wiki/Windows#known-restrictions). +The wake command instead uses the existing vendor **Interface 1** on one +native child. The hub and HID drivers, descriptors and USB identities stay +unchanged. + +1. Run `uv sync` (or `python -m pip install -e .`). Windows dependencies include + the libusb DLL through `libusb-package`; the command loads it explicitly. + The USB management/wake command does not require SDL3.dll. +2. In Device Manager's connection view, identify the Pico's `057E:2068` hub + and one of its native children (`057E:2066` or `057E:2067`). +3. In [Zadig](https://zadig.akeo.ie/), enable **Options → List All Devices**. + Select that child's **Interface 1** (`MI_01`, vendor interface) and install + **WinUSB** for that interface only. **Do not replace the hub's driver, + Interface 0's HID driver, or the composite-parent driver.** Check the Pico's + physical USB tree rather than selecting unrelated Nintendo hardware. +4. Run the same `wake` command above. Discovery only reads project identity + before sending a request, groups siblings into one Pico, and never detaches + drivers, resets a device, or installs a driver automatically. On Windows, + `--bus/--address` can select the parent hub or the chosen child. + +Only INFO and WAKE are exposed through the native child interface. Other +native-hub management operations still require root access; this does not make +all configuration/profile commands available through WinUSB on a child. +Non-hub AIO modes retain their existing device-level transport and require a +compatible Windows USB driver binding. + +The request uses interface-recipient vendor control transfers with `wIndex=1`, +matching [WinUSB's interface handling](https://learn.microsoft.com/en-us/windows/win32/api/winusb/nf-winusb-winusb_controltransfer). +Windows discovery/error paths are regression-tested and the same interface +requests were exercised on a real Pico under Linux. Windows x64 dependency +resolution was checked for Python 3.9 and 3.11. **Physical Windows driver and +hardware operation has not been verified on this Linux development machine.** + +Scripts can call the same API directly: + +```python +import usb.util +from switch_pico_bridge.config_manager import find_wake_pico, request_switch2_wake + +pico = find_wake_pico(bus=None, address=None) +try: + result = request_switch2_wake(pico, timeout=15.0) + print(result.state_name) # "complete"; errors raise instead of retrying +finally: + usb.util.dispose_resources(pico) +``` + Hardware testing found that the Switch 2 can briefly remove USB power while entering or leaving sleep. A Pico powered only by that port necessarily reboots, resetting the CYW43439 and dropping every controller link regardless @@ -893,7 +966,7 @@ existing IMU target mask is side-local and repeats for each pair. Physical Bluetooth capacity remains four devices: a physical Joy-Con pair uses two links. GAMEPAD/DUALSENSE builds support native gameplay vibration. With HD enabled, -0.108 preserves the native frequency/amplitude timeline for one selected +0.110 preserves the native frequency/amplitude timeline for one selected DualSense, in any physical slot. Other/unselected controllers, including Wii Remotes, retain the conventional source-driver path introduced in 0.101. Each virtual R/L half controls only its assigned source's right/left actuator; @@ -920,7 +993,7 @@ cmake --build build-switch2-native-two-pair-live --parallel 4 ``` That command explicitly retains the 240 MHz compatibility configuration. To -build the **0.108 diagnostic HD candidate** using the same private inputs: +build the **0.110 HD/wake candidate** using the same private inputs: ```sh cmake -S . -B build-switch2-native-two-pair-live \ @@ -2578,11 +2651,11 @@ with SwitchUARTClient("/dev/cu.usbserial-0001") as client: ### Windows tips - Use `COMx` for ports (e.g., `COM5`). Auto‑detect lists COM ports. -- Ensure SDL3.dll is on PATH or alongside the script. +- Ensure SDL3.dll is on PATH or alongside the script for the SDL controller/UART bridge. The USB configuration/wake CLI does not require SDL3.dll; native-hub wake uses the Interface 1 WinUSB setup described above, not a COM port. ### Linux tips - You may need udev permissions for `/dev/ttyUSB*`/`/dev/ttyACM*` (add user to `dialout`/`uucp` or use `udev` rules). -- For the development XInput/DInput/Mac identities, install `udev/99-switch-pico.rules` into `/etc/udev/rules.d/`, reload udev, and reconnect the Pico so `switch-pico-config` can access endpoint zero without root. +- For the native-hub root and development XInput/DInput/Mac identities, install `udev/99-switch-pico.rules` into `/etc/udev/rules.d/`, reload udev, and reconnect the Pico so `switch-pico-config` can access endpoint zero without root. ## IMU / Motion Controls diff --git a/pyproject.toml b/pyproject.toml index 17deced..8cd4c11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "rich", "hidapi", "pyusb", + "libusb-package; sys_platform == 'win32'", ] [project.scripts] diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 8d41dd2..e79722a 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -454,6 +454,8 @@ uint32_t g_clear_pairings_requested_token = 0; uint32_t g_clear_pairings_in_progress_token = 0; uint32_t g_next_clear_pairings_request_token = 1; bool g_pairing_snapshot_requested = false; +Bluepad32Switch2WakeStatus g_usb_wake_status{}; +Switch2WakeDiagnostics g_cached_wake_diagnostics{}; bool g_initialized = false; bool g_started = false; #ifdef SWITCH2_BRIDGE_WII_INPUT @@ -848,6 +850,7 @@ void complete_native_hd_cues() { // These fields are only read or written by the BTstack execution context. btstack_timer_source_t g_rumble_timer{}; btstack_timer_source_t g_configuration_timer{}; +Switch2WakeDiagnostics g_usb_wake_baseline{}; ConnectionStatus g_connection_status = ConnectionStatus::Initializing; btstack_packet_callback_registration_t g_pairing_event_callback{}; btstack_packet_callback_registration_t g_identity_event_callback{}; @@ -3437,10 +3440,68 @@ void seed_native_host_rumble() { } #endif +void copy_wake_diagnostics(Bluepad32Switch2WakeStatus& status, + const Switch2WakeDiagnostics& diagnostics) { + status.configured = diagnostics.configured; + status.busy = diagnostics.busy; + status.accepted_requests = diagnostics.accepted_requests; + status.completed_bursts = diagnostics.completed_bursts; + status.failures = diagnostics.failures; +} + +void process_usb_switch2_wake(bool dispatch_pending) { + // Only the BTstack owner may inspect or dispatch the real wake machine. + Switch2WakeDiagnostics diagnostics{}; + switch2_wake_diagnostics(&diagnostics); + state_lock_enter(); + g_cached_wake_diagnostics = diagnostics; + Bluepad32Switch2WakeStatus status = g_usb_wake_status; + if (status.state == Bluepad32Switch2WakeState::kIdle) { + copy_wake_diagnostics(g_usb_wake_status, diagnostics); + } + state_lock_exit(); + + if (status.state == Bluepad32Switch2WakeState::kQueued) { + if (!dispatch_pending) return; + if (!diagnostics.configured) { + status.state = Bluepad32Switch2WakeState::kUnconfigured; + } else if (diagnostics.busy) { + // A chord/startup already owns the wake machine. Do not retry. + status.state = Bluepad32Switch2WakeState::kBusy; + } else { + g_usb_wake_baseline = diagnostics; + status.state = switch2_wake_request() + ? Bluepad32Switch2WakeState::kBroadcasting + : Bluepad32Switch2WakeState::kFailed; + switch2_wake_diagnostics(&diagnostics); + } + } else if (status.state == Bluepad32Switch2WakeState::kBroadcasting) { + // Cleanup after a failed HCI command can also advance completed_bursts. + // Failure wins, including when both changes occur between timer ticks. + if (diagnostics.failures != g_usb_wake_baseline.failures) { + status.state = Bluepad32Switch2WakeState::kFailed; + } else if (diagnostics.completed_bursts != + g_usb_wake_baseline.completed_bursts) { + status.state = Bluepad32Switch2WakeState::kComplete; + } else if (!diagnostics.busy) { + status.state = Bluepad32Switch2WakeState::kFailed; + } + } else { + // Never replace a retained terminal snapshot with a chord's outcome. + return; + } + copy_wake_diagnostics(status, diagnostics); + state_lock_enter(); + g_cached_wake_diagnostics = diagnostics; + g_usb_wake_status = status; + state_lock_exit(); +} + void process_rumble_timer(btstack_timer_source_t* timer) { __atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED); uint32_t now_ms = btstack_run_loop_get_time_ms(); + process_usb_switch2_wake(true); process_clear_pairings(now_ms); process_pairing_snapshot_request(); process_joycon_gestures(now_ms); @@ -4661,6 +4722,9 @@ void platform_on_controller_data(uni_hid_device_t* device, const uint16_t pre_hotkey_button_mask = logical_button_mask(gamepad); if (wake_chord_rising_edge( static_cast(slot_index), owner, pre_hotkey_button_mask)) { + // Retire any finished USB burst before another chord can advance the + // global diagnostics. Queued USB work must not preempt this chord. + process_usb_switch2_wake(false); switch2_wake_request(); } const HotkeyDecision hotkeys = update_controller_hotkeys( @@ -4885,6 +4949,9 @@ void bluepad32_input_backend_init() { g_joycon_reconcile_requested = false; g_pairing_window_requested = false; g_pairing_snapshot_requested = false; + g_usb_wake_status = {}; + g_cached_wake_diagnostics = {}; + g_usb_wake_baseline = {}; g_pairing_snapshot = {}; g_pairing_snapshot.status = Bluepad32PairingSnapshotStatus::kPending; @@ -4984,6 +5051,34 @@ uint32_t bluepad32_input_backend_clear_pairings() { return request_token; } +bool bluepad32_input_backend_request_switch2_wake(uint32_t request_id) { + if (request_id == 0 || request_id > INT32_MAX) return false; + if (!g_initialized) bluepad32_input_backend_init(); + + state_lock_enter(); + bool accepted = request_id == g_usb_wake_status.request_id; + if (!accepted && + g_usb_wake_status.state != Bluepad32Switch2WakeState::kQueued && + g_usb_wake_status.state != Bluepad32Switch2WakeState::kBroadcasting) { + g_usb_wake_status.request_id = request_id; + g_usb_wake_status.state = Bluepad32Switch2WakeState::kQueued; + copy_wake_diagnostics(g_usb_wake_status, g_cached_wake_diagnostics); + accepted = true; + } + state_lock_exit(); + return accepted; +} + +void bluepad32_input_backend_switch2_wake_snapshot( + Bluepad32Switch2WakeStatus* output) { + if (output == nullptr) return; + *output = {}; + if (!g_initialized) return; + state_lock_enter(); + *output = g_usb_wake_status; + state_lock_exit(); +} + void bluepad32_input_backend_request_pairing_snapshot() { if (!g_initialized) { bluepad32_input_backend_init(); diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index aeb2234..398387b 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -14,6 +14,35 @@ constexpr uint8_t BLUEPAD32_INPUT_BACKEND_SLOT_COUNT = 4; constexpr uint8_t BLUEPAD32_PAIRING_RECORD_CAPACITY = 16; +enum class Bluepad32Switch2WakeState : uint8_t { + kIdle = 0, + kQueued = 1, + kBroadcasting = 2, + kComplete = 3, + kUnconfigured = 4, + kBusy = 5, + kFailed = 6, +}; + +struct Bluepad32Switch2WakeStatus { + uint32_t request_id = 0; + Bluepad32Switch2WakeState state = Bluepad32Switch2WakeState::kIdle; + bool configured = false; + bool busy = false; + uint32_t accepted_requests = 0; + uint32_t completed_bursts = 0; + uint32_t failures = 0; +}; + +// Volatile, bounded USB mailbox; only the BTstack-owning timer calls the radio. +// IDs are nonzero signed-positive u32s. The current ID is idempotent, including +// terminal outcomes; a different ID cannot replace Queued/Broadcasting work. +bool bluepad32_input_backend_request_switch2_wake(uint32_t request_id); +// Read-only cached status. Complete means the radio burst finished, not that +// the console woke. Terminal snapshots survive until another ID is accepted. +void bluepad32_input_backend_switch2_wake_snapshot( + Bluepad32Switch2WakeStatus* output); + enum class Bluepad32PairingTransport : uint8_t { kClassic = 1, kBle = 2, diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index e357995..e926845 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -100,6 +100,7 @@ bool valid_out_size(Operation operation, size_t size) { case Operation::kModeSet: return size == kRequestHeaderSize + 5; case Operation::kReboot: + case Operation::kSwitch2Wake: return size == kRequestHeaderSize + 4; case Operation::kBootselReboot: return size == kRequestHeaderSize; @@ -147,6 +148,30 @@ bool valid_out_size(Operation operation, size_t size) { } } +bool process_switch2_wake(const DecodedRequest& request) { + if (request.payload_size != 4) return false; + const uint32_t request_id = read_u32(request.payload); + return request_id != 0 && request_id <= INT32_MAX && + bluepad32_input_backend_request_switch2_wake(request_id); +} + +size_t encode_switch2_wake(uint8_t* output, size_t output_size) { + Bluepad32Switch2WakeStatus snapshot{}; + bluepad32_input_backend_switch2_wake_snapshot(&snapshot); + uint8_t payload[kSwitch2WakePayloadSize]{}; + write_u32(&payload[0], snapshot.request_id); + payload[4] = static_cast(snapshot.state); + payload[5] = snapshot.configured ? 1 : 0; + payload[6] = snapshot.busy ? 1 : 0; + write_u32(&payload[8], snapshot.accepted_requests); + write_u32(&payload[12], snapshot.completed_bursts); + write_u32(&payload[16], snapshot.failures); + return encode_response( + Operation::kSwitch2Wake, Status::kOk, 0, + kSwitch2WakeSchemaVersion, snapshot.request_id, + payload, sizeof(payload), output, output_size); +} + size_t encode_configuration(uint8_t* output, size_t output_size) { ConfigurationServiceSnapshot snapshot{}; configuration_service_snapshot(&snapshot); @@ -192,7 +217,7 @@ size_t encode_transaction(uint8_t* output, size_t output_size) { size_t encode_info(uint8_t* output, size_t output_size) { uint8_t payload[8] = { #if SWITCH2_PROBE_HUB - 0, 108, 0, 2, + 0, 110, 0, 2, kNativeHubActiveMode, USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | USB_OUTPUT_CAPABILITY_MOTION, @@ -705,6 +730,7 @@ UsbConfigurationManagement::Operation g_pending_operation = UsbConfigurationManagement::Operation::kInfo; bool g_out_pending = false; bool g_out_processed = false; +bool g_out_attempted = false; size_t g_pending_request_size = 0; uint8_t g_pending_rhport = 0; tusb_control_request_t g_pending_setup{}; @@ -733,6 +759,8 @@ bool process_out_request() { const uint8_t* payload = request.payload; switch (request.operation) { + case Operation::kSwitch2Wake: + return process_switch2_wake(request); case Operation::kMacroCapture: { if (request.payload_size == 16 && payload[0] == 1) { CaptureOptions options{}; @@ -1004,6 +1032,72 @@ bool process_out_request() { } // namespace +#if SWITCH2_PROBE_HUB && !SWITCH2_PROBE_NEUTRAL_INPUT +bool usb_configuration_management_child_vendor_control( + uint8_t rhport, uint8_t stage, + const tusb_control_request_t* request) { + using namespace UsbConfigurationManagement; + // The largest child response is WAKE (40 bytes). Never borrow root EP0 + // storage: the hub and each child can have a transfer in flight together. + struct ChildTransfer { + uint8_t buffer[kResponseHeaderSize + kSwitch2WakePayloadSize]; + tusb_control_request_t setup; + bool pending; + bool attempted; + bool accepted; + }; + static ChildTransfer children[PROBE_CONTROLLER_COUNT]{}; + if (rhport == 0 || rhport > PROBE_CONTROLLER_COUNT) return false; + ChildTransfer& transfer = children[rhport - 1]; + if (stage == CONTROL_STAGE_SETUP) { + transfer.pending = false; + transfer.attempted = false; + transfer.accepted = false; + } + if (request == nullptr || + (request->bmRequestType != 0xc1 && request->bmRequestType != 0x41) || + request->wValue != kRequestValue || request->wIndex != kRequestIndex) { + return false; + } + const bool input = request->bmRequestType == 0xc1; + const Operation operation = static_cast(request->bRequest); + if (operation != Operation::kSwitch2Wake && + !(input && operation == Operation::kInfo)) return false; + if (stage == CONTROL_STAGE_SETUP) { + transfer.setup = *request; + if (input) { + const size_t size = operation == Operation::kInfo + ? encode_info(transfer.buffer, sizeof(transfer.buffer)) + : encode_switch2_wake(transfer.buffer, sizeof(transfer.buffer)); + transfer.pending = size != 0 && management_control_xfer( + rhport, request, transfer.buffer, static_cast(size)); + } else { + if (!valid_out_size(operation, request->wLength)) return false; + // A short OUT must not inherit valid envelope bytes from a prior + // request. Native transport also rejects incomplete OUT payloads. + memset(transfer.buffer, 0xff, sizeof(transfer.buffer)); + transfer.pending = management_control_xfer( + rhport, request, transfer.buffer, request->wLength); + } + return transfer.pending; + } + if (!transfer.pending || + memcmp(request, &transfer.setup, sizeof(*request)) != 0) return false; + if (stage != CONTROL_STAGE_DATA && stage != CONTROL_STAGE_ACK) return false; + if (input) return true; + if (stage == CONTROL_STAGE_DATA && !transfer.attempted) { + transfer.attempted = true; + DecodedRequest decoded{}; + // Admission happens before status ACK. Repeated DATA/ACK callbacks + // return the original result without submitting the mailbox twice. + transfer.accepted = decode_request(operation, transfer.buffer, + request->wLength, &decoded) && + process_switch2_wake(decoded); + } + return transfer.attempted && transfer.accepted; +} +#endif + bool usb_configuration_management_vendor_control( uint8_t rhport, uint8_t stage, tusb_control_request_t const* request) { @@ -1015,6 +1109,7 @@ bool usb_configuration_management_vendor_control( if (stage == CONTROL_STAGE_SETUP) { g_out_pending = false; g_out_processed = false; + g_out_attempted = false; g_pending_request_size = 0; #if SWITCH2_PROBE_HUB g_out_validated = false; @@ -1062,7 +1157,8 @@ bool usb_configuration_management_vendor_control( if (!g_out_validated) return false; g_out_validated = false; #endif - if (operation == Operation::kHapticsExperiment) { + if (operation == Operation::kHapticsExperiment || + operation == Operation::kSwitch2Wake) { return g_out_processed; } return process_out_request(); @@ -1078,13 +1174,17 @@ bool usb_configuration_management_vendor_control( if (!g_out_validated) return false; } #endif - if (operation == Operation::kHapticsExperiment && + if ((operation == Operation::kHapticsExperiment || + operation == Operation::kSwitch2Wake) && request->bmRequestType_bit.direction == TUSB_DIR_OUT) { if (!g_out_pending || operation != g_pending_operation || - g_out_processed) { + rhport != g_pending_rhport || + memcmp(request, &g_pending_setup, sizeof(*request)) != 0 || + g_out_attempted) { return false; } // Reject before the USB status ACK, and never enqueue twice. + g_out_attempted = true; g_out_processed = process_out_request(); return g_out_processed; } @@ -1114,6 +1214,9 @@ bool usb_configuration_management_vendor_control( case Operation::kInfo: response_size = encode_info(response, sizeof(response)); break; + case Operation::kSwitch2Wake: + response_size = encode_switch2_wake(response, sizeof(response)); + break; case Operation::kConfigurationRead: response_size = encode_configuration(response, sizeof(response)); diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 35d745f..f7afbd5 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -17,6 +17,8 @@ constexpr uint8_t kProtocolVersion = 1; // INFO-only active mode; never persisted as an AdapterRequestedMode. constexpr uint8_t kNativeHubActiveMode = 5; constexpr size_t kRequestHeaderSize = 16; +constexpr uint16_t kSwitch2WakeSchemaVersion = 1; +constexpr size_t kSwitch2WakePayloadSize = 20; constexpr size_t kResponseHeaderSize = 20; constexpr size_t kPairingRecordSize = 8; constexpr size_t kPairingPayloadHeaderSize = 4; @@ -54,6 +56,7 @@ enum class Operation : uint8_t { kModeSet = 0x02, kReboot = 0x03, kBootselReboot = 0x04, + kSwitch2Wake = 0x05, kConfigurationRead = 0x10, kConfigurationBegin = 0x11, kConfigurationChunk = 0x12, @@ -132,3 +135,11 @@ size_t encode_profile_metadata( bool usb_configuration_management_vendor_control( uint8_t rhport, uint8_t stage, tusb_control_request_t const* request); + +#if SWITCH2_PROBE_HUB && !SWITCH2_PROBE_NEUTRAL_INPUT +// Native child Interface 1 only: read-only INFO and volatile WAKE. Each child +// owns its EP0 state independently from the root's full management service. +bool usb_configuration_management_child_vendor_control( + uint8_t rhport, uint8_t stage, + const tusb_control_request_t* request); +#endif diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index ab2ad83..b3d9138 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any, Protocol, cast import usb.core +import usb.util USB_IDENTITIES = ( (0x057E, 0x2009), @@ -26,6 +27,13 @@ USB_IDENTITIES = ( (0xCAFE, 0x4020), (0xCAFE, 0x4021), ) +NATIVE_HUB_IDENTITY = (0x057E, 0x2068) +NATIVE_CHILD_IDENTITIES = ((0x057E, 0x2066), (0x057E, 0x2067)) +WINDOWS_WAKE_DRIVER_HINT = ( + "For native firmware 0.110 or newer, manually bind WinUSB to Interface 1 " + "of one native child (057e:2066 or 057e:2067) only. " + "Never replace the hub, Interface 0 (HID), or composite parent driver." +) REQUEST_VALUE = 0x5350 REQUEST_INDEX = 0x0001 PROTOCOL_VERSION = 1 @@ -42,6 +50,22 @@ OP_INFO = 0x01 OP_MODE_SET = 0x02 OP_REBOOT = 0x03 OP_BOOTSEL_REBOOT = 0x04 +OP_SWITCH2_WAKE = 0x05 +SWITCH2_WAKE_SCHEMA_VERSION = 1 +SWITCH2_WAKE_STATUS_SIZE = 20 +SWITCH2_WAKE_STATES = ( + "idle", + "queued", + "broadcasting", + "complete", + "unconfigured", + "busy", + "failed", +) +SWITCH2_WAKE_EVIDENCE_NOTE = ( + "Complete means the firmware finished its advertising burst; " + "it does not confirm physical RF delivery or the console's power state." +) OP_CONFIGURATION_READ = 0x10 OP_CONFIGURATION_BEGIN = 0x11 OP_CONFIGURATION_CHUNK = 0x12 @@ -300,7 +324,10 @@ EXTRA_BUTTONS = ("c", "gl", "gr", "left_sl", "left_sr", "right_sl", "right_sr") LOGICAL_CONTROLS = STANDARD_CONTROLS + EXTRA_BUTTONS RAIL_OUTPUTS = ("left_sl", "left_sr", "right_sl", "right_sr") LEFT_STICK_DIRECTION_OUTPUTS = ( - "left_stick_up", "left_stick_down", "left_stick_left", "left_stick_right" + "left_stick_up", + "left_stick_down", + "left_stick_left", + "left_stick_right", ) OUTPUT_CONTROLS = STANDARD_CONTROLS + RAIL_OUTPUTS + LEFT_STICK_DIRECTION_OUTPUTS PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 @@ -384,6 +411,31 @@ class DeviceInfo: return "input only" if names == ("input",) else ", ".join(names) +@dataclass(frozen=True) +class Switch2WakeStatus: + """Radio wake outcome, not confirmation that the console powered on.""" + + request_id: int + state: int + configured: bool + busy: bool + accepted_requests: int + completed_bursts: int + failures: int + + @property + def state_name(self) -> str: + return SWITCH2_WAKE_STATES[self.state] + + def to_json_object(self) -> dict[str, Any]: + return { + **asdict(self), + "state_name": self.state_name, + "console_power_confirmed": False, + "evidence_note": SWITCH2_WAKE_EVIDENCE_NOTE, + } + + @dataclass(frozen=True) class RuntimeDiagnostics: initialization_stage: int @@ -1759,15 +1811,19 @@ class ProfileShift: ), tuple( _output_index( - mappings[name], f"profile.shift.button_map.{name}", - schema_version=schema_version, shift=True, + mappings[name], + f"profile.shift.button_map.{name}", + schema_version=schema_version, + shift=True, ) for name in LOGICAL_BUTTONS ), tuple( _output_index( - extras[name], f"profile.shift.extra_button_map.{name}", - schema_version=schema_version, shift=True, + extras[name], + f"profile.shift.extra_button_map.{name}", + schema_version=schema_version, + shift=True, ) for name in EXTRA_BUTTONS ), @@ -2677,7 +2733,9 @@ class ControllerProfile: fields.extend(("nunchuk_swing", "combined_swing", "combination_window_ms")) if schema_version >= PROFILE_NATIVE_LAYOUT_SCHEMA_VERSION: fields.extend( - name for name in ("native_joycon_layout", "swap_sticks") if name in value + name + for name in ("native_joycon_layout", "swap_sticks") + if name in value ) obj = _require_object(value, fields, "profile") expected_size = ( @@ -2831,7 +2889,8 @@ class ControllerProfile: return cls( button_map=tuple( _output_index( - button_map[name], f"profile.button_map.{name}", + button_map[name], + f"profile.button_map.{name}", schema_version=schema_version, ) for name in LOGICAL_BUTTONS @@ -2874,7 +2933,8 @@ class ControllerProfile: turbo_overrides=tuple(turbo_overrides), extra_button_map=tuple( _output_index( - extras[name], f"profile.extra_button_map.{name}", + extras[name], + f"profile.extra_button_map.{name}", schema_version=schema_version, ) for name in EXTRA_BUTTONS @@ -2888,7 +2948,8 @@ class ControllerProfile: ), nunchuk_swing=( ProfileSwing.from_json_object( - obj["nunchuk_swing"], "profile.nunchuk_swing", + obj["nunchuk_swing"], + "profile.nunchuk_swing", schema_version=schema_version, ) if schema_version >= PROFILE_COMBINED_SWING_SCHEMA_VERSION @@ -3007,27 +3068,52 @@ def _raise_status(envelope: Envelope, *, pending_ok: bool = False) -> None: ) -def _control_in(device: UsbDevice, operation: int) -> Envelope: +def _usb_identity(device: UsbDevice) -> tuple[int | None, int | None]: + return getattr(device, "idVendor", None), getattr(device, "idProduct", None) + + +def _is_native_child(device: UsbDevice) -> bool: + return ( + _usb_identity(device) in NATIVE_CHILD_IDENTITIES + and getattr(device, "bDeviceClass", None) == 0xEF + and getattr(device, "bDeviceSubClass", None) == 2 + and getattr(device, "bDeviceProtocol", None) == 1 + ) + + +def _control_in( + device: UsbDevice, operation: int, *, timeout_ms: int = USB_TIMEOUT_MS +) -> Envelope: payload = device.ctrl_transfer( - 0xC0, + 0xC1 + if operation in (OP_INFO, OP_SWITCH2_WAKE) and _is_native_child(device) + else 0xC0, operation, REQUEST_VALUE, REQUEST_INDEX, MAXIMUM_RESPONSE_SIZE, - timeout=USB_TIMEOUT_MS, + timeout=timeout_ms, ) return parse_response(bytes(payload), operation) -def _control_out(device: UsbDevice, operation: int, payload: bytes = b"") -> None: +def _control_out( + device: UsbDevice, + operation: int, + payload: bytes = b"", + *, + timeout_ms: int = USB_TIMEOUT_MS, +) -> None: request = encode_request(operation, payload) device.ctrl_transfer( - 0x40, + 0x41 + if operation in (OP_INFO, OP_SWITCH2_WAKE) and _is_native_child(device) + else 0x40, operation, REQUEST_VALUE, REQUEST_INDEX, request, - timeout=USB_TIMEOUT_MS, + timeout=timeout_ms, ) @@ -3059,6 +3145,110 @@ def read_info(device: UsbDevice) -> DeviceInfo: ) +def parse_switch2_wake_status(envelope: Envelope) -> Switch2WakeStatus: + _raise_status(envelope) + if envelope.schema_version != SWITCH2_WAKE_SCHEMA_VERSION: + raise ConfigManagerError( + f"unsupported Switch 2 wake schema {envelope.schema_version}" + ) + if len(envelope.payload) != SWITCH2_WAKE_STATUS_SIZE: + raise ConfigManagerError("invalid Switch 2 wake status payload size") + if envelope.flags != 0 or envelope.payload[7] != 0: + raise ConfigManagerError("invalid Switch 2 wake reserved fields") + request_id, state, configured, busy, accepted, completed, failures = struct.unpack( + " HOST_TRANSACTION_ID_MASK or (request_id == 0 and state != 0): + raise ConfigManagerError("invalid Switch 2 wake request ID") + if envelope.generation != request_id: + raise ConfigManagerError("Switch 2 wake envelope request ID mismatch") + if state >= len(SWITCH2_WAKE_STATES): + raise ConfigManagerError(f"invalid Switch 2 wake state {state}") + if configured not in (0, 1) or busy not in (0, 1): + raise ConfigManagerError("invalid Switch 2 wake status boolean") + return Switch2WakeStatus( + request_id, state, bool(configured), bool(busy), accepted, completed, failures + ) + + +def _read_switch2_wake_status(device: UsbDevice, timeout_ms: int) -> Switch2WakeStatus: + try: + envelope = _control_in(device, OP_SWITCH2_WAKE, timeout_ms=timeout_ms) + except usb.core.USBError as exc: + if exc.errno == 32 or exc.backend_error_code == -9: + raise ConfigManagerError( + "firmware does not support USB Switch 2 wake operation 0x05; " + "update to firmware with USB wake support " + f"(native {'0.110' if _is_native_child(device) else '0.109'} or newer)" + ) from exc + raise + return parse_switch2_wake_status(envelope) + + +def read_switch2_wake_status(device: UsbDevice) -> Switch2WakeStatus: + """Read the last USB wake status without requesting a broadcast.""" + return _read_switch2_wake_status(device, USB_TIMEOUT_MS) + + +def request_switch2_wake( + device: UsbDevice, timeout: float = DEFAULT_OPERATION_TIMEOUT_SECONDS +) -> Switch2WakeStatus: + """Request one wake burst and wait for its outcome, not console power-on.""" + if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0: + raise ConfigManagerError("Switch 2 wake timeout must be finite and positive") + deadline = time.monotonic() + timeout + + def transfer_timeout_ms() -> int: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ConfigManagerError( + "Switch 2 wake timed out; no automatic retry was sent" + ) + return max(1, math.ceil(min(USB_TIMEOUT_MS / 1000, remaining) * 1000)) + + # Validate firmware support and the entire status before any mutation. + initial = _read_switch2_wake_status(device, transfer_timeout_ms()) + if initial.state_name in ("queued", "broadcasting"): + raise ConfigManagerError("Switch 2 wake is busy with another USB request") + request_id = _host_transaction_id() + if request_id == initial.request_id: + # The firmware makes the current ID idempotent, including terminal states. + request_id = request_id % HOST_TRANSACTION_ID_MASK + 1 + _control_out( + device, + OP_SWITCH2_WAKE, + struct.pack("= deadline: + raise ConfigManagerError( + "Switch 2 wake timed out; no automatic retry was sent" + ) + if status.request_id != request_id: + raise ConfigManagerError( + "Switch 2 wake request was superseded by a different request ID" + ) + if status.state_name == "complete": + return status + if status.state_name == "unconfigured": + raise ConfigManagerError( + "Switch 2 wake is unconfigured or unsupported, or BLE is disabled" + ) + if status.state_name == "busy": + raise ConfigManagerError( + "Switch 2 wake radio is busy; no burst was started" + ) + if status.state_name == "failed": + raise ConfigManagerError("Switch 2 wake advertising burst failed") + time.sleep(min(0.05, max(0, deadline - time.monotonic()))) + + def read_macro_capture( device: UsbDevice, run_id: int = 0, @@ -4490,11 +4680,34 @@ def clear_pairings(device: UsbDevice, timeout: float) -> PairingSnapshot: return snapshot +def _windows_usb_backend() -> Any: + message = ( + "The Windows libusb runtime is unavailable. Install this project's " + "Windows dependencies (libusb-package) and ensure its libusb DLL can load." + ) + try: + package = importlib.import_module("libusb_package") + backend = package.get_libusb1_backend() + except usb.core.USBError: + raise + except (ImportError, OSError) as exc: + raise ConfigManagerError(message) from exc + if backend is None: + raise ConfigManagerError(message) + return backend + + def _candidate_devices() -> Iterable[UsbDevice]: + options = {"backend": _windows_usb_backend()} if sys.platform == "win32" else {} for vendor_id, product_id in USB_IDENTITIES: + # Windows libusb cannot control a hub root with its standard hub driver. + if sys.platform == "win32" and (vendor_id, product_id) == NATIVE_HUB_IDENTITY: + continue devices = cast( Iterable[UsbDevice] | None, - usb.core.find(find_all=True, idVendor=vendor_id, idProduct=product_id), + usb.core.find( + find_all=True, idVendor=vendor_id, idProduct=product_id, **options + ), ) if devices is not None: yield from devices @@ -4575,6 +4788,167 @@ def _enumeration_identity( return bus, address +def _native_hub_parent( + device: usb.core.Device, roots: Sequence[usb.core.Device] +) -> usb.core.Device | None: + # These are cached descriptors/topology, not string or control requests. + try: + parent = device.parent + except (AttributeError, NotImplementedError): + parent = None + if parent is None: + location = _physical_location(device) + if location is None: + return None + parent_location = (location[0], location[1][:-1]) + parents = [ + root for root in roots if _physical_location(root) == parent_location + ] + if len(parents) != 1: + return None + parent = parents[0] + if ( + _usb_identity(parent) != NATIVE_HUB_IDENTITY + or parent.bDeviceClass != 9 + or ( + _physical_location(parent) is None and _enumeration_identity(parent) is None + ) + ): + return None + return parent + + +def find_wake_pico( + bus: int | None, address: int | None, timeout: float = 3.0 +) -> UsbDevice: + """Find a wake-capable management path without opening Windows hub roots.""" + if sys.platform != "win32": + return find_pico(bus, address, timeout) + + backend = _windows_usb_backend() + deadline = time.monotonic() + timeout + last_error: Exception | None = None + transport_error: usb.core.USBError | None = None + native_found = False + + def selected(device: UsbDevice) -> bool: + return (bus is None or device.bus == bus) and ( + address is None or device.address == address + ) + + while True: + candidates = list( + cast( + Iterable[usb.core.Device], + usb.core.find(find_all=True, backend=backend), + ) + ) + roots = [ + device + for device in candidates + if _usb_identity(device) == NATIVE_HUB_IDENTITY and device.bDeviceClass == 9 + ] + native_found |= any(selected(root) for root in roots) + matches: dict[object, usb.core.Device] = {} + result = None + try: + for device in candidates: + identity = _usb_identity(device) + if identity == NATIVE_HUB_IDENTITY: + continue + native = identity in NATIVE_CHILD_IDENTITIES + if not native and identity not in USB_IDENTITIES: + continue + try: + if native: + if not _is_native_child(device): + continue + parent = _native_hub_parent(device, roots) + if parent is None: + if selected(device): + last_error = ConfigManagerError( + "native child parent hub cannot be identified safely" + ) + continue + if not (selected(device) or selected(parent)): + continue + native_found = True + key = ( + "native", + _physical_location(parent) or _enumeration_identity(parent), + ) + else: + if not selected(device): + continue + key = ("device", _enumeration_identity(device) or id(device)) + if key in matches: + continue + if native: + # Read cached descriptors; never configure, reset or detach. + try: + interface = device[0][(1, 0)] + except IndexError as exc: + raise ConfigManagerError( + "native child has no vendor Interface 1" + ) from exc + if ( + interface.bInterfaceNumber != 1 + or interface.bAlternateSetting != 0 + or interface.bInterfaceClass != 0xFF + or interface.bInterfaceSubClass != 0 + or interface.bInterfaceProtocol != 0 + ): + raise ConfigManagerError( + "native child has no vendor Interface 1" + ) + usb.util.claim_interface(device, 1) + info = read_info(device) + if native and info.active_mode != ACTIVE_MODE_NATIVE_HUB: + raise ConfigManagerError( + "native child did not identify native-hub project firmware" + ) + except (ConfigManagerError, usb.core.USBError) as exc: + last_error = exc + if isinstance(exc, usb.core.USBError) and not ( + native + and ( + exc.backend_error_code in (-5, -12) or exc.errno in (2, 38) + ) + ): + transport_error = exc + continue + matches[key] = device + if len(matches) == 1: + result = next(iter(matches.values())) + return result + if len(matches) > 1: + locations = ", ".join( + f"{device.bus}:{device.address}" for device in matches.values() + ) + raise ConfigManagerError( + f"multiple switch-pico devices found ({locations}); " + "select one with --bus and --address (parent hub or child)" + ) + finally: + for device in candidates: + if device is not result: + usb.util.dispose_resources(device) + if time.monotonic() >= deadline: + break + time.sleep(0.05) + if transport_error is not None: + raise transport_error + message = ( + f"matching USB devices were found, but none accepted the management request; " + f"last error: {last_error}" + if last_error is not None + else "no USB-connected switch-pico firmware found" + ) + if native_found: + message += ". " + WINDOWS_WAKE_DRIVER_HINT + raise ConfigManagerError(message) from last_error + + def _capture_reenumeration_snapshot( previous_device: UsbDevice, ) -> _ReenumerationSnapshot: @@ -4878,6 +5252,12 @@ def build_parser() -> argparse.ArgumentParser: ) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("status", help="show firmware and configuration status") + wake = commands.add_parser( + "wake", help="request one Switch 2 wake advertising burst" + ) + wake.add_argument( + "--json", action="store_true", help="print the wake outcome as JSON" + ) commands.add_parser( "diagnostics", help="show live Bluetooth and rumble pipeline counters" ) @@ -5057,7 +5437,9 @@ def main(argv: Sequence[str] | None = None) -> int: if args.timeout <= 0: print("error: --timeout must be positive", file=sys.stderr) return 2 - if args.command == "haptics-experiment" and not math.isfinite(args.timeout): + if args.command in ("haptics-experiment", "wake") and not math.isfinite( + args.timeout + ): print("error: --timeout must be finite", file=sys.stderr) return 2 if args.command == "config" and args.config_command == "reset" and not args.yes: @@ -5096,7 +5478,10 @@ def main(argv: Sequence[str] | None = None) -> int: return 0 if args.command == "profiles" and args.profile_command == "import": imported_profile = _load_profile(args.path) - device = find_pico(args.bus, args.address, args.timeout) + if args.command == "wake": + device = find_wake_pico(args.bus, args.address, args.timeout) + else: + device = find_pico(args.bus, args.address, args.timeout) if args.command == "status": info = read_info(device) configuration = read_configuration(device) @@ -5115,6 +5500,16 @@ def main(argv: Sequence[str] | None = None) -> int: print( f"Joy-Con2 player mode: {JOYCON_MODE_NAMES[configuration.joycon_mode]}" ) + elif args.command == "wake": + status = request_switch2_wake(device, args.timeout) + if args.json: + print(json.dumps(status.to_json_object(), indent=2)) + else: + print( + f"Switch 2 wake advertising burst completed " + f"(request {status.request_id})." + ) + print(SWITCH2_WAKE_EVIDENCE_NOTE) elif args.command == "diagnostics": diagnostics = read_runtime_diagnostics(device) print(f"Initialization stage: {diagnostics.initialization_stage}") diff --git a/src/switch_pico_bridge/switch_pico_uart.py b/src/switch_pico_bridge/switch_pico_uart.py index 18dce88..a74111c 100644 --- a/src/switch_pico_bridge/switch_pico_uart.py +++ b/src/switch_pico_bridge/switch_pico_uart.py @@ -36,17 +36,10 @@ RAD_TO_DEG = 180.0 / math.pi ACCEL_LSB_PER_G = 4096.0 GYRO_LSB_PER_RAD_S = 818.5 -try: - import sdl3 as _sdl3 # type: ignore[import-not-found] - - _sensor_accel = getattr(_sdl3, "SDL_SENSOR_ACCEL", 1) - _sensor_gyro = getattr(_sdl3, "SDL_SENSOR_GYRO", 2) -except ImportError: - _sensor_accel = 1 - _sensor_gyro = 2 - -SENSOR_ACCEL: int = _sensor_accel -SENSOR_GYRO: int = _sensor_gyro +# Stable SDL_SensorType ABI values. Importing SDL merely to obtain these IDs +# adds startup side effects to otherwise SDL-independent UART/USB scripts. +SENSOR_ACCEL: int = 1 +SENSOR_GYRO: int = 2 class SwitchButton(IntFlag): diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index b53cb4c..1c15b26 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -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(); diff --git a/tests/native_gamepad_backend_test.cpp b/tests/native_gamepad_backend_test.cpp index 5e4907e..84eaadb 100644 --- a/tests/native_gamepad_backend_test.cpp +++ b/tests/native_gamepad_backend_test.cpp @@ -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; } diff --git a/tests/native_hub_management_test.cpp b/tests/native_hub_management_test.cpp index f046513..07ae776 100644 --- a/tests/native_hub_management_test.cpp +++ b/tests/native_hub_management_test.cpp @@ -41,6 +41,10 @@ std::array 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 envelope(Operation op, const std::vector& payload) { std::vector bytes(kRequestHeaderSize + payload.size()); memcpy(bytes.data(), "SPMG", 4); @@ -114,6 +123,189 @@ void write_operation(Operation op, const std::vector& 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(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 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(Operation::kSwitch2Wake) || + (input && op == static_cast(Operation::kInfo))) continue; + const auto invalid = child_request(static_cast(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 identity_payload(uint8_t profile) { std::vector 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& expected) { std::vector(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(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(); diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index b6a990c..542949a 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -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", diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 98cb0c4..d08fc7d 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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(" 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(" 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(" 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(" 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(" 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, *, diff --git a/tests/test_native_gamepad_backend_native.py b/tests/test_native_gamepad_backend_native.py index 86fed9e..db17164 100644 --- a/tests/test_native_gamepad_backend_native.py +++ b/tests/test_native_gamepad_backend_native.py @@ -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", diff --git a/tests/test_native_hub_management_native.py b/tests/test_native_hub_management_native.py index fc3a660..875f326 100644 --- a/tests/test_native_hub_management_native.py +++ b/tests/test_native_hub_management_native.py @@ -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( diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 78a01fc..0088799 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -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 control_payload; std::vector 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 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 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 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; diff --git a/tools/switch2_usb_probe/bootsel.cpp b/tools/switch2_usb_probe/bootsel.cpp index 438d1ae..d03eb29 100644 --- a/tools/switch2_usb_probe/bootsel.cpp +++ b/tools/switch2_usb_probe/bootsel.cpp @@ -36,6 +36,12 @@ bool probe_management_vendor_control(uint8_t rhport, uint8_t stage, transfer.pending = false; transfer.validated = false; } +#endif +#if SWITCH2_PROBE_HUB && !SWITCH2_PROBE_NEUTRAL_INPUT + if (rhport != 0 && + usb_configuration_management_child_vendor_control(rhport, stage, request)) { + return true; + } #endif if (request == nullptr || request->bmRequestType != 0x40 || request->bRequest != static_cast(Operation::kBootselReboot) || diff --git a/tools/switch2_usb_probe/bootsel.h b/tools/switch2_usb_probe/bootsel.h index f4f7461..cbbb36f 100644 --- a/tools/switch2_usb_probe/bootsel.h +++ b/tools/switch2_usb_probe/bootsel.h @@ -9,8 +9,8 @@ extern "C" { #endif -// Core 0: private BOOTSEL on native root/children, plus full root management -// only outside the neutral experiment. Non-hub probes remain BOOTSEL-only. +// Core 0: private BOOTSEL on native root/children; outside neutral experiments, +// full root management and child Interface 1 INFO/WAKE. Non-hub: BOOTSEL-only. bool probe_management_vendor_control(uint8_t rhport, uint8_t stage, const tusb_control_request_t* request); // Core 0: service the existing reboot delay only after a validated status ACK. diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index 195a555..0bc1891 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -417,63 +417,63 @@ function(switch2_usb_probe_configure target) endif() if(SWITCH2_PROBE_NEUTRAL_INPUT) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.108-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace") + pico_set_program_version(${target} "0.110-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace") else() - pico_set_program_version(${target} "0.108-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair") + pico_set_program_version(${target} "0.110-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair") endif() elseif(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT) if(probe_controller_count GREATER 2) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.108-live-two-pair-trace") + pico_set_program_version(${target} "0.110-live-two-pair-trace") else() - pico_set_program_version(${target} "0.108-live-two-pair") + pico_set_program_version(${target} "0.110-live-two-pair") endif() elseif(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.108-native-digital-stick-trace") + pico_set_program_version(${target} "0.110-native-digital-stick-trace") else() - pico_set_program_version(${target} "0.108-native-digital-stick") + pico_set_program_version(${target} "0.110-native-digital-stick") endif() elseif(SWITCH2_PROBE_HUB) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.108-native-hub-profiles-trace") + pico_set_program_version(${target} "0.110-native-hub-profiles-trace") else() - pico_set_program_version(${target} "0.108-native-hub-profiles") + pico_set_program_version(${target} "0.110-native-hub-profiles") endif() elseif(SWITCH2_PROBE_JOIN_CHORD_GATE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.37-pair-chord-trace") + pico_set_program_version(${target} "0.110-pair-chord-trace") else() - pico_set_program_version(${target} "0.37-pair-chord") + pico_set_program_version(${target} "0.110-pair-chord") endif() elseif(SWITCH2_PROBE_COMPOSITE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.35-pair-trace") + pico_set_program_version(${target} "0.110-pair-trace") else() - pico_set_program_version(${target} "0.35-pair") + pico_set_program_version(${target} "0.110-pair") endif() elseif(SWITCH2_PROBE_OMIT_NATIVE_IMU) - pico_set_program_version(${target} "0.24-no-imu") + pico_set_program_version(${target} "0.110-no-imu") elseif(SWITCH2_PROBE_ZERO_NATIVE_IMU_PAYLOAD) - pico_set_program_version(${target} "0.24-zero-imu-payload") + pico_set_program_version(${target} "0.110-zero-imu-payload") elseif(SWITCH2_BRIDGE_WII_INPUT) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.33-wii-trace") + pico_set_program_version(${target} "0.110-wii-trace") else() - pico_set_program_version(${target} "0.33-wii") + pico_set_program_version(${target} "0.110-wii") endif() elseif(SWITCH_PICO_SWITCH2_USB_BRIDGE AND probe_joycon_left) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.34-left-trace") + pico_set_program_version(${target} "0.110-left-trace") else() - pico_set_program_version(${target} "0.34-left") + pico_set_program_version(${target} "0.110-left") endif() elseif(SWITCH_PICO_SWITCH2_USB_BRIDGE) if(SWITCH2_PROBE_TRACE_NATIVE_INPUT) - pico_set_program_version(${target} "0.25-trace") + pico_set_program_version(${target} "0.110-trace") else() - pico_set_program_version(${target} "0.25") + pico_set_program_version(${target} "0.110") endif() else() - pico_set_program_version(${target} "0.24") + pico_set_program_version(${target} "0.110") endif() endfunction() diff --git a/uv.lock b/uv.lock index f562a14..bf3da3d 100644 --- a/uv.lock +++ b/uv.lock @@ -538,6 +538,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + +[[package]] +name = "libusb-package" +version = "1.0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources", version = "6.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-resources", version = "7.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/95/d166eeefe0d9dd5833d5724a97b023ec380c3a2d364040ec0485fd57703c/libusb_package-1.0.30.0-py3-none-win32.whl", hash = "sha256:79728146e1f01e525786900b2ccd8298a8eca53cd94b37fb0885e56aa6f9d60a", size = 78769, upload-time = "2026-06-30T07:41:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/60/4a/ff49bd77f33af05ca26fee29d601beb1e19ca791bb86efece82a3833885c/libusb_package-1.0.30.0-py3-none-win_amd64.whl", hash = "sha256:90808da724c8939a333d931d0c7226372ca5fefd98e2f2f3ef79fd918aa37522", size = 90711, upload-time = "2026-06-30T07:41:09.318Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -947,6 +987,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "hidapi" }, + { name = "libusb-package", marker = "sys_platform == 'win32'" }, { name = "pysdl3" }, { name = "pyserial" }, { name = "pyusb" }, @@ -956,6 +997,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "hidapi" }, + { name = "libusb-package", marker = "sys_platform == 'win32'" }, { name = "pysdl3" }, { name = "pyserial" }, { name = "pyusb" }, @@ -1267,3 +1309,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +]