diff --git a/README.md b/README.md index b6209b1..2dff74b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Switch Pico Controller Bridge -Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from a Bluetooth controller through Bluepad32. +Raspberry Pi Pico firmware that emulates one or more Switch Pro controllers over USB. Input can come from the SDL3-to-UART computer bridge or, on Pico 2 W, directly from Bluetooth controllers through Bluepad32. ## What you get -- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a wired Switch Pro, accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend. +- **Firmware** (`switch-pico.cpp` + `switch_pro_driver.*`): acts as a Switch Pro controller (one on standard Pico, two on Pico 2 W AIO), accepting either UART bridge reports or the optional Pico 2 W Bluepad32 backend. - **Python bridge** (`switch_pico_bridge.controller_uart_bridge` / CLI `controller-uart-bridge`): reads SDL3 controllers on the host, sends reports over UART, and applies rumble locally. Hot‑plug friendly and cross‑platform (macOS/Windows/Linux). - **Colour override** (`controller_color_config.h`): compile‑time RGB overrides for body/buttons/grips as seen by the Switch. -- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts one Bluetooth controller and sends its controls, calibrated motion, and rumble through the same Switch Pro USB device without a computer. +- **Pico 2 W AIO firmware** (`firmware/switch-pico-aio.uf2`): hosts two concurrent Bluetooth controllers and sends their controls, calibrated motion, and rumble through two separate Switch Pro USB interfaces without a computer. ## Quick start 1. Flash the Pico with `firmware/switch-pico.uf2` (or build your own) using BOOTSEL drag-and-drop (see “Manual UF2 flashing” below). @@ -17,7 +17,11 @@ Raspberry Pi Pico firmware that emulates a Switch Pro controller over USB. Input ## Pico 2 W all-in-one Bluetooth option -The AIO build runs TinyUSB and Switch report generation on Core 0 while Bluepad32, BTstack, and the CYW43439 radio run on Core 1. A fixed state snapshot and bounded rumble queue are the only cross-core interfaces. +### Architecture + +The AIO build accepts two concurrent Bluetooth controllers on a single Pico 2 W. The device runs TinyUSB and Switch report generation on Core 0, while Bluepad32, BTstack, and the CYW43439 radio run on Core 1. Core 0 maintains two USB Pro HID interfaces (slots 0 and 1), and each Bluetooth connection is isolated in the Bluepad32 slot assigned when that connection becomes active. A fixed state snapshot and per-slot bounded rumble queue are the only cross-core synchronization points. + +Both USB interfaces are always present to the Switch. The Switch enumerates them as two separate Pro Controllers on the same physical device. Inputs and rumble are independent per controller. ### Build and flash @@ -42,21 +46,40 @@ The default `python3 build.py` command and `firmware/switch-pico.*` artifacts re Both `build.py --aio` and direct AIO CMake configuration apply `patches/bluepad32-sdl3-imu.patch` idempotently before compiling Bluepad32. The patch makes supported motion controllers use SDL3-equivalent axes and fixed-point units before conversion to Nintendo samples. It intentionally leaves the dependency worktree dirty; the committed submodule revision remains Bluepad32 4.2.0. -### Pair a controller +### Pairing two controllers 1. Flash and connect the Pico 2 W to the Switch. 2. Enable `System Settings → Controllers and Sensors → Pro Controller Wired Communication`. -3. Put one controller into Bluetooth pairing mode: +3. Put the first controller into Bluetooth pairing mode: - DualSense: hold Create + PS. - DualShock 4: hold Share + PS. - Switch Pro: press its sync button. - Xbox Bluetooth controller: hold its pair button. - 8BitDo: use a Bluetooth mode supported by Bluepad32; use Switch/S mode when motion is required. -4. Wait for the controller to connect. Pairing keys persist across Pico reboots. +4. Wait for the first controller to connect and become ready. The LED continues slow-blinking because the second slot remains open. Pairing keys persist across Pico reboots. +5. Put the second controller into pairing mode and wait for it to connect and become ready. The LED turns solid only after both controllers are active. -The Pico 2 W onboard LED reports Bluetooth state: a slow 0.5-second blink means scanning, a fast 0.1-second blink means a controller connected but is not ready, and solid means the controller is ready. A solid LED immediately after boot that never starts blinking indicates Bluepad32 initialization did not complete. +During initial setup, pairing order determines the initial slot assignment: the first controller paired occupies slot 0, and the second occupies slot 1. Pairing keys persist, so both controllers can reconnect after a Pico reboot without re-pairing. Slot numbers are not permanently bound to physical controllers: while one controller remains connected, a returning controller fills the other open slot; after a reboot or whenever both slots are empty, whichever persisted controller reconnects first receives slot 0, so the physical controllers can swap USB interfaces. -Only one wireless controller owns the emulated Pro Controller. Turn off or disconnect it before pairing another; scanning resumes automatically after disconnect. A disconnect immediately publishes neutral buttons, sticks, and motion. +### LED meanings and device state + +The Pico 2 W onboard LED reports the overall Bluetooth state: +- **Slow blink (0.5 s period)**: at least one slot is open and scanning for a Bluetooth controller. +- **Fast blink (0.1 s period)**: at least one controller is connected but not yet ready (handshake in progress). +- **Solid**: both slots are filled and both controllers are ready for input. +- **Solid immediately after boot that never starts blinking**: Bluepad32 initialization did not complete; check firmware flashing and UART logs. + +The LED transitions to slow blink as soon as any slot becomes empty (e.g., a controller is turned off or unpaired). Scanning resumes automatically. + +### Managing controller disconnect and reconnect + +Controllers can disconnect and reconnect independently: +- **Disconnect one controller**: that controller's slot becomes empty. The LED transitions to slow blink if both slots are no longer filled. The other controller continues sending input. +- **Reconnect while the other controller remains connected**: the returning controller fills the only open slot, preserving the current assignment. The LED transitions through fast blink and back to solid. +- **Reconnect after both slots become empty or after reboot**: reconnection/autoconnect order determines the assignments. The physical controllers can swap USB interfaces if their order changes. +- **Turn off or unpair a controller**: delete it from Bluetooth settings on the Pico or reset pairing entirely using Bluepad32 commands. It will no longer auto-reconnect; the slot remains open for a new controller. + +When a controller disconnects, the Pico immediately publishes neutral buttons, sticks, and motion for that slot. The other controller is unaffected. ### Controller capabilities @@ -69,6 +92,26 @@ Only one wireless controller owns the emulated Pro Controller. Turn off or disco Motion is normalized to 1024 units per degree/second and 8192 units per g in SDL3 axes, then converted to Nintendo axes and raw counts. The latest normalized sample is duplicated across the report's three nominal 5 ms slots; it remains pending until a regular `0x30` USB report successfully consumes it. +### Rumble per controller + +Rumble effects are per-slot and independent. The Switch sends rumble commands to a specific USB interface, and the Pico routes each command to the Bluetooth controller in the matching slot. Each slot has a critical-section-protected latest-value mailbox tagged with its connection generation; a newer pending command replaces the older one, and disconnect invalidates commands from the prior controller. + +### Hardware validation + +The dual-interface AIO build has been verified on a real Switch with two DualSense controllers: the Switch assigned two controller slots; buttons, sticks, calibrated motion, and rumble remained independent; disconnecting either controller left the other working; scanning resumed and the disconnected controller reconnected to the open slot. + +To reproduce the validation: + +1. **Verify USB enumeration**: Connect the Pico 2 W to a USB host (PC, Mac, or USB analyzer). Confirm that two HID devices are present (e.g., `lsusb -v` on Linux shows interface 0 and interface 1, both with Product ID 0x2009). +2. **Verify Bluetooth pairing**: Pair two controllers via Bluepad32. Confirm the LED transitions from scanning → fast blink → solid. +3. **Verify input on one controller**: Move sticks, press buttons, and check that the controller paired first during initial setup appears in slot 0. +4. **Verify input on two controllers**: Move sticks on the controller paired second during initial setup, and confirm its inputs appear in slot 1 while the first controller is unaffected. +5. **Verify disconnect and reconnect**: Turn off one controller while leaving the other connected. The LED reverts to slow blink. Turn the disconnected controller back on; it reconnects to the only open slot. Verify the occupied slot continues reporting the other controller's input. +6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates. +7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output). + +On the tested Linux host, both HID interfaces enumerated (`lsusb -t` showed interface 0 and 1), but `hid-nintendo` probes timed out (`-110`) while requesting controller information from this composite device and removed their transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for dual-interface AIO firmware. + Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license. ## Planned features diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index 39cbfdc..e641264 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -39,6 +39,7 @@ struct RumbleEnvelope { struct BackendSlot { SwitchInputState state; + // Non-null with active=false is a connected device still becoming ready. uni_hid_device_t* device; uint32_t state_generation; uint32_t connection_generation; @@ -83,14 +84,22 @@ int slot_for_device(const uni_hid_device_t* device) { return slot >= 0 && slot < kSlotCount ? slot : -1; } -bool all_slots_ready() { +ConnectionStatus compute_connection_status() { critical_section_enter_blocking(&g_state_lock); - bool ready = true; + bool all_ready = true; + bool any_connecting = false; for (const BackendSlot& slot : g_slots) { - ready = ready && slot.active && slot.device != nullptr; + const bool has_device = slot.device != nullptr; + all_ready = all_ready && slot.active && has_device; + any_connecting = any_connecting || (!slot.active && has_device); } critical_section_exit(&g_state_lock); - return ready; + + if (all_ready) { + return ConnectionStatus::Ready; + } + return any_connecting ? ConnectionStatus::Connecting + : ConnectionStatus::Scanning; } void publish_device_state(uint8_t slot, uni_hid_device_t* device, @@ -115,6 +124,8 @@ void publish_all_neutral() { ++slot.connection_generation; } critical_section_exit(&g_state_lock); + g_connection_status = ConnectionStatus::Initializing; + g_status_led_tick = 0; } constexpr int32_t clamp_axis(int32_t value) { @@ -287,6 +298,16 @@ void resume_connections() { uni_bt_allow_incoming_connections(true); uni_bt_start_scanning_and_autoconnect_unsafe(); } +void recompute_connection_status() { + g_connection_status = compute_connection_status(); + g_status_led_tick = 0; + if (g_connection_status == ConnectionStatus::Ready) { + uni_bt_stop_scanning_unsafe(); + uni_bt_allow_incoming_connections(false); + } else { + resume_connections(); + } +} void platform_init(int argc, const char** argv) { (void)argc; @@ -297,9 +318,7 @@ void platform_on_init_complete() { btstack_run_loop_set_timer_handler(&g_rumble_timer, process_rumble_timer); btstack_run_loop_set_timer(&g_rumble_timer, kRumblePollIntervalMs); btstack_run_loop_add_timer(&g_rumble_timer); - g_connection_status = ConnectionStatus::Scanning; - g_status_led_tick = 0; - resume_connections(); + recompute_connection_status(); } uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint16_t cod, uint8_t rssi) { @@ -307,14 +326,31 @@ uni_error_t platform_on_device_discovered(bd_addr_t addr, const char* name, uint (void)name; (void)cod; (void)rssi; - return all_slots_ready() ? UNI_ERROR_IGNORE_DEVICE : UNI_ERROR_SUCCESS; + return compute_connection_status() == ConnectionStatus::Ready + ? UNI_ERROR_IGNORE_DEVICE + : UNI_ERROR_SUCCESS; } void platform_on_device_connected(uni_hid_device_t* device) { - (void)device; - if (!all_slots_ready()) { - g_connection_status = ConnectionStatus::Connecting; - g_status_led_tick = 0; + const int slot_index = slot_for_device(device); + if (slot_index < 0) { + return; + } + + bool tracked_connection = false; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (!slot.active && slot.device == nullptr) { + slot.device = device; + slot.rumble_pending = false; + tracked_connection = true; + } else { + tracked_connection = slot.device == device; + } + critical_section_exit(&g_state_lock); + + if (tracked_connection) { + recompute_connection_status(); } } @@ -324,24 +360,24 @@ void platform_on_device_disconnected(uni_hid_device_t* device) { return; } - bool disconnected_active_slot = false; + bool disconnected_tracked_device = false; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; - if (slot.active && slot.device == device) { - slot.state = make_neutral_state(); + if (slot.device == device) { + if (slot.active) { + slot.state = make_neutral_state(); + ++slot.state_generation; + } slot.device = nullptr; slot.active = false; slot.rumble_pending = false; - ++slot.state_generation; ++slot.connection_generation; - disconnected_active_slot = true; + disconnected_tracked_device = true; } critical_section_exit(&g_state_lock); - if (disconnected_active_slot) { - g_connection_status = ConnectionStatus::Scanning; - g_status_led_tick = 0; - resume_connections(); + if (disconnected_tracked_device) { + recompute_connection_status(); } } @@ -358,13 +394,15 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { bool occupied_mismatch = false; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; - occupied_mismatch = slot.active && slot.device != device; - if (!occupied_mismatch && !slot.active) { - slot.state = make_neutral_state(); + occupied_mismatch = slot.device != nullptr && slot.device != device; + if (!occupied_mismatch) { slot.device = device; - slot.active = true; - slot.rumble_pending = false; - ++slot.state_generation; + if (!slot.active) { + slot.state = make_neutral_state(); + slot.active = true; + slot.rumble_pending = false; + ++slot.state_generation; + } } critical_section_exit(&g_state_lock); @@ -372,15 +410,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { return UNI_ERROR_NO_SLOTS; } - g_status_led_tick = 0; - if (all_slots_ready()) { - g_connection_status = ConnectionStatus::Ready; - uni_bt_stop_scanning_unsafe(); - uni_bt_allow_incoming_connections(false); - } else { - g_connection_status = ConnectionStatus::Scanning; - resume_connections(); - } + recompute_connection_status(); return UNI_ERROR_SUCCESS; } diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index efc50a0..9c1edc0 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index 882683d..d5b54a8 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 824a3f9..eee98b7 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -10,7 +10,10 @@ namespace { bool incoming_connections = false; int scan_starts = 0; int scan_stops = 0; +bool scanning_enabled = false; uni_platform* installed_platform = nullptr; +bool observed_status_led_on = false; +int observed_status_led_writes = 0; void require(bool condition, const char* message) { if (!condition) { @@ -50,10 +53,12 @@ void uni_bt_allow_incoming_connections(bool enabled) { void uni_bt_start_scanning_and_autoconnect_unsafe() { ++scan_starts; + scanning_enabled = true; } void uni_bt_stop_scanning_unsafe() { ++scan_stops; + scanning_enabled = false; } void uni_platform_set_custom(uni_platform* platform) { @@ -68,7 +73,11 @@ int cyw43_arch_init() { return 0; } -void cyw43_arch_gpio_put(int, bool) {} +void cyw43_arch_gpio_put(int, bool enabled) { + observed_status_led_on = enabled; + ++observed_status_led_writes; +} + void multicore_launch_core1(void (*)()) {} #include "../bluepad32_input_backend.cpp" @@ -82,10 +91,33 @@ void start_backend() { require(scan_starts == 1, "initialization must start scanning"); } +void tick_backend_timer(int ticks) { + for (int tick = 0; tick < ticks; ++tick) { + process_rumble_timer(&g_rumble_timer); + } +} + void test_ready_order(int first_slot) { start_backend(); uni_hid_device_t devices[2] = {device(0), device(1)}; const int second_slot = 1 - first_slot; + tick_backend_timer(99); + require(observed_status_led_on, + "scanning LED must stay on for the first slow-blink half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "scanning LED must turn off at the slow-blink half-cycle"); + + platform_on_device_connected(&devices[first_slot]); + tick_backend_timer(19); + require(observed_status_led_on, + "connecting LED must stay on for the first fast-blink half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "connecting LED must turn off at the fast-blink half-cycle"); + tick_backend_timer(20); + require(observed_status_led_on, + "connecting LED must turn on for the next fast-blink cycle"); require(platform_on_device_ready(&devices[first_slot]) == UNI_ERROR_SUCCESS, @@ -94,6 +126,12 @@ void test_ready_order(int first_slot) { "scanning must continue while one slot remains free"); require(incoming_connections, "incoming connections must remain enabled with one ready slot"); + tick_backend_timer(99); + require(observed_status_led_on, + "one ready slot must leave the LED in the slow scanning cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "one open slot must produce the scanning LED off transition"); SwitchInputState first{}; SwitchInputState second{}; @@ -109,6 +147,14 @@ void test_ready_order(int first_slot) { "scanning must stop exactly when both slots are ready"); require(!incoming_connections, "incoming connections must be disabled only when full"); + tick_backend_timer(1); + require(observed_status_led_on, + "both ready slots must turn the status LED on"); + const int ready_led_writes = observed_status_led_writes; + tick_backend_timer(200); + require(observed_status_led_on && + observed_status_led_writes == ready_led_writes, + "both ready slots must keep the status LED solid"); bd_addr_t address{}; require(platform_on_device_discovered(address, "extra", 0, 0) == @@ -146,12 +192,76 @@ void test_rejections() { void test_independent_lifecycle() { start_backend(); + + uni_hid_device_t aborted = device(0); + const uint32_t aborted_generation = g_slots[0].connection_generation; + platform_on_device_connected(&aborted); + require(g_slots[0].device == &aborted && !g_slots[0].active, + "connected device must remain identifiable while becoming ready"); + tick_backend_timer(19); + require(observed_status_led_on, + "a lone pending connection must use the fast LED on half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "a lone pending connection must use the fast LED off half-cycle"); + + const int starts_before_aborted_disconnect = scan_starts; + platform_on_device_disconnected(&aborted); + require(g_slots[0].device == nullptr && !g_slots[0].active, + "pre-ready disconnect must clear its pending slot identity"); + require(g_slots[0].connection_generation == aborted_generation + 1, + "pre-ready disconnect must invalidate its connection generation"); + require(g_connection_status == ConnectionStatus::Scanning && + scanning_enabled && incoming_connections && + scan_starts == starts_before_aborted_disconnect + 1, + "pre-ready disconnect with no peer must resume scanning"); + tick_backend_timer(99); + require(observed_status_led_on, + "pre-ready disconnect must restore the slow LED on half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "pre-ready disconnect must restore the slow LED off half-cycle"); + uni_hid_device_t first = device(0); uni_hid_device_t survivor = device(1); + platform_on_device_connected(&first); + platform_on_device_connected(&survivor); + require(g_slots[0].device == &first && !g_slots[0].active && + g_slots[1].device == &survivor && !g_slots[1].active, + "concurrent pending devices must retain independent identities"); + tick_backend_timer(19); + require(observed_status_led_on, + "concurrent pending devices must use the fast LED on half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "concurrent pending devices must use the fast LED off half-cycle"); + + const uint32_t first_pending_generation = + g_slots[0].connection_generation; + const int starts_before_first_pending_disconnect = scan_starts; + platform_on_device_disconnected(&first); + require(g_slots[0].device == nullptr && !g_slots[0].active && + g_slots[1].device == &survivor && !g_slots[1].active, + "pre-ready disconnect must preserve the other pending identity"); + require(g_slots[0].connection_generation == + first_pending_generation + 1, + "pending disconnect beside a peer must invalidate its generation"); + require(g_connection_status == ConnectionStatus::Connecting && + scanning_enabled && incoming_connections && + scan_starts == starts_before_first_pending_disconnect + 1, + "open slot must scan while another slot remains connecting"); + tick_backend_timer(19); + require(observed_status_led_on, + "surviving pending device must retain the fast LED on half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "surviving pending device must retain the fast LED off half-cycle"); + require(platform_on_device_ready(&survivor) == UNI_ERROR_SUCCESS, - "slot 1 must be accepted before slot 0"); + "surviving pending device must still become ready"); + platform_on_device_connected(&first); require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, - "slot 0 must complete the pair"); + "reconnected slot 0 device must complete the pair"); uni_controller_t data0{}; data0.klass = UNI_CONTROLLER_CLASS_GAMEPAD; @@ -239,6 +349,72 @@ void test_independent_lifecycle() { require(survivor.rumble_calls == 2 && survivor.last_low == 92 && survivor.last_high == 93, "survivor rumble must continue after peer replacement"); + + const int starts_before_slot_one_disconnect = scan_starts; + platform_on_device_disconnected(&survivor); + require(scan_starts == starts_before_slot_one_disconnect + 1 && + incoming_connections, + "slot 1 disconnect must resume scanning for its open slot"); + require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_x, + "slot 1 disconnect must preserve slot 0 state and activity"); + require(!bluepad32_input_backend_snapshot(1, &state1) && + !state1.button_b && state1.lx == 32768, + "slot 1 disconnect must neutralize only slot 1"); + + uni_controller_t continuing_slot_zero_data{}; + continuing_slot_zero_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + continuing_slot_zero_data.gamepad.buttons = BUTTON_B; + platform_on_controller_data(&replacement, &continuing_slot_zero_data); + require(bluepad32_input_backend_snapshot(0, &state0) && state0.button_a, + "slot 0 input must continue while slot 1 is disconnected"); + + const int slot_zero_calls_while_scanning = replacement.rumble_calls; + bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{115, 116}); + tick_backend_timer(99); + require(replacement.rumble_calls == slot_zero_calls_while_scanning + 1 && + replacement.last_low == 115 && replacement.last_high == 116, + "slot 0 rumble must continue while slot 1 is disconnected"); + require(observed_status_led_on, + "disconnect scanning must use the slow LED on half-cycle"); + tick_backend_timer(1); + require(!observed_status_led_on, + "disconnect scanning must reach the slow LED off half-cycle"); + + uni_hid_device_t first_slot_one_replacement = device(1); + require(platform_on_device_ready(&first_slot_one_replacement) == + UNI_ERROR_SUCCESS, + "slot 1 replacement must bind without disturbing slot 0"); + tick_backend_timer(1); + require(observed_status_led_on, + "replacing the open slot must return the LED to solid ready"); + const int replacement_ready_led_writes = observed_status_led_writes; + tick_backend_timer(100); + require(observed_status_led_on && + observed_status_led_writes == replacement_ready_led_writes, + "replacement pair must keep the ready LED solid"); + + bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{117, 118}); + platform_on_device_disconnected(&first_slot_one_replacement); + uni_hid_device_t second_slot_one_replacement = device(1); + require(platform_on_device_ready(&second_slot_one_replacement) == + UNI_ERROR_SUCCESS, + "a subsequent slot 1 replacement must bind to the freed slot"); + process_rumble_timer(&g_rumble_timer); + require(second_slot_one_replacement.rumble_calls == 0, + "slot 1 replacement must not receive prior-generation rumble"); + + const int slot_zero_calls_before_mailboxes = replacement.rumble_calls; + bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{119, 120}); + bluepad32_input_backend_queue_rumble(1, SwitchRumbleOutput{121, 122}); + bluepad32_input_backend_queue_rumble(0, SwitchRumbleOutput{123, 124}); + process_rumble_timer(&g_rumble_timer); + require(second_slot_one_replacement.rumble_calls == 1 && + second_slot_one_replacement.last_low == 121 && + second_slot_one_replacement.last_high == 122, + "slot 1 mailbox must dispatch only its latest queued value"); + require(replacement.rumble_calls == slot_zero_calls_before_mailboxes + 1 && + replacement.last_low == 123 && replacement.last_high == 124, + "slot 0 activity must not evict the slot 1 mailbox"); } } // namespace