diff --git a/README.md b/README.md index 6bfcc24..1ad5c6d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,18 @@ Each connected AIO controller can toggle its own ABXY layout by pressing **L + R Edit `controller_hotkey_config.h` to change the chord, default layout, or confirmation pulse. +### Per-controller motion toggle + +Press **D-pad Up + R + Start** together to disable or re-enable motion for one controller. On DualSense, use **D-pad Up + R1 + Options**. + +- A longer rumble confirms motion disabled. +- A shorter rumble confirms motion enabled. +- The chord is consumed locally and is not forwarded to the Switch. +- Other controller slots are unaffected. +- Motion returns to enabled after disconnect or reboot. + +Edit `controller_hotkey_config.h` to change the chord, default state, or feedback patterns. + ### Per-slot controller colors Each AIO slot has one color shared by its emulated Switch Pro grips and its physical Bluetooth controller: diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index 5d3f401..c3c9b07 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -34,10 +34,35 @@ constexpr uint8_t kAbxyFeedbackWeakMagnitude = SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE; constexpr uint8_t kAbxyFeedbackStrongMagnitude = SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE; +constexpr uint32_t kMotionHotkeyDpadMask = + SWITCH_MOTION_HOTKEY_DPAD_MASK; +constexpr uint32_t kMotionHotkeyButtonMask = + SWITCH_MOTION_HOTKEY_BUTTON_MASK; +constexpr uint32_t kMotionHotkeyMiscMask = + SWITCH_MOTION_HOTKEY_MISC_MASK; +constexpr bool kDefaultMotionEnabled = + SWITCH_MOTION_DEFAULT_ENABLED != 0; +constexpr uint16_t kMotionDisabledFeedbackDurationMs = + SWITCH_MOTION_DISABLED_FEEDBACK_DURATION_MS; +constexpr uint8_t kMotionDisabledFeedbackWeakMagnitude = + SWITCH_MOTION_DISABLED_FEEDBACK_WEAK_MAGNITUDE; +constexpr uint8_t kMotionDisabledFeedbackStrongMagnitude = + SWITCH_MOTION_DISABLED_FEEDBACK_STRONG_MAGNITUDE; +constexpr uint16_t kMotionEnabledFeedbackDurationMs = + SWITCH_MOTION_ENABLED_FEEDBACK_DURATION_MS; +constexpr uint8_t kMotionEnabledFeedbackWeakMagnitude = + SWITCH_MOTION_ENABLED_FEEDBACK_WEAK_MAGNITUDE; +constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude = + SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE; static_assert(kAbxyHotkeyButtonMask != 0); static_assert(kAbxyHotkeyMiscMask != 0); static_assert(kAbxyFeedbackDurationMs > 0); +static_assert(kMotionHotkeyDpadMask != 0); +static_assert(kMotionHotkeyButtonMask != 0); +static_assert(kMotionHotkeyMiscMask != 0); +static_assert(kMotionDisabledFeedbackDurationMs > 0); +static_assert(kMotionEnabledFeedbackDurationMs > 0); static_assert(kSlotCount == 4); static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount); @@ -62,6 +87,13 @@ struct RumbleEnvelope { uint32_t connection_generation; SwitchRumbleOutput rumble; }; +struct FeedbackEnvelope { + uint32_t connection_generation; + uint16_t duration_ms; + uint8_t weak_magnitude; + uint8_t strong_magnitude; +}; + struct BackendSlot { SwitchInputState state; @@ -72,11 +104,13 @@ struct BackendSlot { bool active; bool rumble_pending; bool swap_abxy; - bool hotkey_latched; + bool abxy_hotkey_latched; + bool motion_enabled; + bool motion_hotkey_latched; bool feedback_pending; - uint32_t feedback_generation; uint32_t feedback_until_ms; RumbleEnvelope pending_rumble; + FeedbackEnvelope pending_feedback; }; critical_section_t g_state_lock; @@ -229,7 +263,8 @@ constexpr int16_t clamp_int16(int64_t value) { return static_cast(value); } -constexpr int64_t divide_round_nearest(int64_t numerator, int64_t denominator) { +constexpr int64_t divide_round_nearest(int64_t numerator, + int64_t denominator) { if (numerator >= 0) { return (numerator + denominator / 2) / denominator; } @@ -264,7 +299,8 @@ bool has_motion(const uni_gamepad_t& gamepad) { } SwitchInputState map_gamepad(const uni_gamepad_t& gamepad, - bool swap_abxy) { + bool swap_abxy, + bool motion_enabled) { SwitchInputState state = make_neutral_state(); state.dpad_up = (gamepad.dpad & DPAD_UP) != 0; @@ -302,7 +338,7 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad, state.rx = scale_stick(gamepad.axis_rx); state.ry = scale_stick(gamepad.axis_ry); - if (has_motion(gamepad)) { + if (motion_enabled && has_motion(gamepad)) { // Dependency patches normalize both arrays to SDL3 PlayStation axes. SwitchImuSample sample{}; sample.accel_x = convert_accel(-static_cast(gamepad.accel[2])); @@ -319,31 +355,87 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad, return state; } -struct LayoutDecision { +struct HotkeyDecision { bool swap_abxy; - bool suppress_hotkey; + bool motion_enabled; + uint32_t suppress_dpad; + uint32_t suppress_buttons; + uint32_t suppress_misc_buttons; }; -LayoutDecision update_layout_hotkey(uint8_t slot_index, - uni_hid_device_t* device, - const uni_gamepad_t& gamepad) { - const bool chord_pressed = +void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, + uint8_t weak_magnitude, + uint8_t strong_magnitude) { + slot.feedback_pending = true; + slot.pending_feedback = { + slot.connection_generation, duration_ms, weak_magnitude, + strong_magnitude}; +} +void reset_slot_hotkeys(BackendSlot& slot) { + slot.swap_abxy = kDefaultSwapAbxy; + slot.abxy_hotkey_latched = false; + slot.motion_enabled = kDefaultMotionEnabled; + slot.motion_hotkey_latched = false; + slot.feedback_pending = false; + slot.feedback_until_ms = 0; + slot.pending_feedback = {}; +} + + +HotkeyDecision update_controller_hotkeys( + uint8_t slot_index, uni_hid_device_t* device, + const uni_gamepad_t& gamepad) { + const bool abxy_pressed = (gamepad.buttons & kAbxyHotkeyButtonMask) == kAbxyHotkeyButtonMask && (gamepad.misc_buttons & kAbxyHotkeyMiscMask) == kAbxyHotkeyMiscMask; - LayoutDecision decision{kDefaultSwapAbxy, false}; + const bool motion_pressed = + (gamepad.dpad & kMotionHotkeyDpadMask) == + kMotionHotkeyDpadMask && + (gamepad.buttons & kMotionHotkeyButtonMask) == + kMotionHotkeyButtonMask && + (gamepad.misc_buttons & kMotionHotkeyMiscMask) == + kMotionHotkeyMiscMask; + HotkeyDecision decision{ + kDefaultSwapAbxy, kDefaultMotionEnabled, 0, 0, 0}; critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device == device) { - if (chord_pressed && !slot.hotkey_latched) { + if (abxy_pressed && !slot.abxy_hotkey_latched) { slot.swap_abxy = !slot.swap_abxy; - slot.feedback_pending = true; - slot.feedback_generation = slot.connection_generation; + queue_local_feedback( + slot, static_cast(kAbxyFeedbackDurationMs), + kAbxyFeedbackWeakMagnitude, + kAbxyFeedbackStrongMagnitude); + } else if (motion_pressed && !slot.motion_hotkey_latched) { + slot.motion_enabled = !slot.motion_enabled; + if (slot.motion_enabled) { + queue_local_feedback( + slot, kMotionEnabledFeedbackDurationMs, + kMotionEnabledFeedbackWeakMagnitude, + kMotionEnabledFeedbackStrongMagnitude); + } else { + queue_local_feedback( + slot, kMotionDisabledFeedbackDurationMs, + kMotionDisabledFeedbackWeakMagnitude, + kMotionDisabledFeedbackStrongMagnitude); + } + } + slot.abxy_hotkey_latched = abxy_pressed; + slot.motion_hotkey_latched = motion_pressed; + decision.swap_abxy = slot.swap_abxy; + decision.motion_enabled = slot.motion_enabled; + if (abxy_pressed) { + decision.suppress_buttons |= kAbxyHotkeyButtonMask; + decision.suppress_misc_buttons |= kAbxyHotkeyMiscMask; + } + if (motion_pressed) { + decision.suppress_dpad |= kMotionHotkeyDpadMask; + decision.suppress_buttons |= kMotionHotkeyButtonMask; + decision.suppress_misc_buttons |= kMotionHotkeyMiscMask; } - slot.hotkey_latched = chord_pressed; - decision = {slot.swap_abxy, chord_pressed}; } critical_section_exit(&g_state_lock); return decision; @@ -437,6 +529,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) { for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { RumbleEnvelope envelope{}; + FeedbackEnvelope feedback{}; uni_hid_device_t* device = nullptr; bool feedback_dispatch = false; bool host_dispatch = false; @@ -444,15 +537,17 @@ void process_rumble_timer(btstack_timer_source_t* timer) { critical_section_enter_blocking(&g_state_lock); BackendSlot& slot = g_slots[slot_index]; if (slot.feedback_pending) { + feedback = slot.pending_feedback; feedback_dispatch = slot.active && slot.device != nullptr && - slot.feedback_generation == slot.connection_generation && + feedback.connection_generation == + slot.connection_generation && slot.device->report_parser.play_dual_rumble != nullptr; slot.feedback_pending = false; if (feedback_dispatch) { device = slot.device; slot.feedback_until_ms = - now_ms + kAbxyFeedbackDurationMs; + now_ms + feedback.duration_ms; } } @@ -475,9 +570,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) { if (feedback_dispatch) { device->report_parser.play_dual_rumble( - device, 0, kAbxyFeedbackDurationMs, - kAbxyFeedbackWeakMagnitude, - kAbxyFeedbackStrongMagnitude); + device, 0, feedback.duration_ms, + feedback.weak_magnitude, feedback.strong_magnitude); } else if (host_dispatch && device->report_parser.play_dual_rumble != nullptr) { device->report_parser.play_dual_rumble( @@ -546,10 +640,7 @@ void platform_on_device_connected(uni_hid_device_t* device) { if (!slot.active && slot.device == nullptr) { slot.device = device; slot.rumble_pending = false; - slot.swap_abxy = kDefaultSwapAbxy; - slot.hotkey_latched = false; - slot.feedback_pending = false; - slot.feedback_until_ms = 0; + reset_slot_hotkeys(slot); tracked_connection = true; } else { tracked_connection = slot.device == device; @@ -578,10 +669,7 @@ void platform_on_device_disconnected(uni_hid_device_t* device) { slot.device = nullptr; slot.active = false; slot.rumble_pending = false; - slot.swap_abxy = kDefaultSwapAbxy; - slot.hotkey_latched = false; - slot.feedback_pending = false; - slot.feedback_until_ms = 0; + reset_slot_hotkeys(slot); ++slot.connection_generation; disconnected_tracked_device = true; } @@ -613,10 +701,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) { slot.state = make_neutral_state(); slot.active = true; slot.rumble_pending = false; - slot.swap_abxy = kDefaultSwapAbxy; - slot.hotkey_latched = false; - slot.feedback_pending = false; - slot.feedback_until_ms = 0; + reset_slot_hotkeys(slot); ++slot.state_generation; became_active = true; } @@ -644,14 +729,15 @@ void platform_on_controller_data(uni_hid_device_t* device, } uni_gamepad_t gamepad = controller->gamepad; - const LayoutDecision layout = update_layout_hotkey( + const HotkeyDecision hotkeys = update_controller_hotkeys( static_cast(slot_index), device, gamepad); - if (layout.suppress_hotkey) { - gamepad.buttons &= ~kAbxyHotkeyButtonMask; - gamepad.misc_buttons &= ~kAbxyHotkeyMiscMask; - } - publish_device_state(static_cast(slot_index), device, - map_gamepad(gamepad, layout.swap_abxy)); + gamepad.dpad &= ~hotkeys.suppress_dpad; + gamepad.buttons &= ~hotkeys.suppress_buttons; + gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons; + publish_device_state( + static_cast(slot_index), device, + map_gamepad(gamepad, hotkeys.swap_abxy, + hotkeys.motion_enabled)); } const uni_property_t* platform_get_property(uni_property_idx_t index) { @@ -724,7 +810,7 @@ void bluepad32_input_backend_init() { slot = {}; slot.state = make_neutral_state(); slot.pending_rumble.slot = slot_index; - slot.swap_abxy = kDefaultSwapAbxy; + reset_slot_hotkeys(slot); g_consumed_generation[slot_index] = 0; g_last_snapshot_generation[slot_index] = 0; } diff --git a/controller_hotkey_config.h b/controller_hotkey_config.h index 27f9110..883d657 100644 --- a/controller_hotkey_config.h +++ b/controller_hotkey_config.h @@ -8,6 +8,12 @@ #define SWITCH_ABXY_HOTKEY_MISC_MASK \ (MISC_BUTTON_SELECT | MISC_BUTTON_START) +// Motion toggle chord: D-pad Up + R + START / Options. +#define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP +#define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R +#define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START +#define SWITCH_MOTION_DEFAULT_ENABLED 1 + // 0 starts each new connection in Nintendo positional layout; 1 starts swapped. #define SWITCH_ABXY_DEFAULT_SWAPPED 0 @@ -15,3 +21,11 @@ #define SWITCH_ABXY_FEEDBACK_DURATION_MS 120 #define SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE 0x80 #define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0x80 + +// A longer pulse confirms disabled; a shorter pulse confirms enabled. +#define SWITCH_MOTION_DISABLED_FEEDBACK_DURATION_MS 180 +#define SWITCH_MOTION_DISABLED_FEEDBACK_WEAK_MAGNITUDE 0xA0 +#define SWITCH_MOTION_DISABLED_FEEDBACK_STRONG_MAGNITUDE 0xA0 +#define SWITCH_MOTION_ENABLED_FEEDBACK_DURATION_MS 80 +#define SWITCH_MOTION_ENABLED_FEEDBACK_WEAK_MAGNITUDE 0x60 +#define SWITCH_MOTION_ENABLED_FEEDBACK_STRONG_MAGNITUDE 0x60 diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index fa20039..562ab9b 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 6bee36a..9ce74a1 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 a5ecfb8..86bcadd 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -39,11 +39,12 @@ void require(bool condition, const char* message) { } } -void play_rumble(uni_hid_device_t* device, uint16_t, uint16_t, - uint8_t high, uint8_t low) { +void play_rumble(uni_hid_device_t* device, uint16_t, + uint16_t duration_ms, uint8_t high, uint8_t low) { ++device->rumble_calls; device->last_high = high; device->last_low = low; + device->last_rumble_duration_ms = duration_ms; } void set_lightbar(uni_hid_device_t* device, uint8_t red, uint8_t green, uint8_t blue) { @@ -793,11 +794,96 @@ void test_abxy_hotkey() { uni_hid_device_t replacement = device(0); require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && g_slots[0].swap_abxy == kDefaultSwapAbxy && - !g_slots[0].hotkey_latched && + !g_slots[0].abxy_hotkey_latched && !g_slots[0].feedback_pending, "disconnect did not reset slot 0 hotkey state"); } +void test_motion_hotkey() { + start_pairing_backend(); + uni_hid_device_t slot_zero = device(0); + uni_hid_device_t slot_one = device(1); + require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS && + platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS, + "motion hotkey test controllers did not become ready"); + + uni_controller_t input{}; + input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + input.gamepad.accel[0] = 8192; + platform_on_controller_data(&slot_zero, &input); + SwitchInputState snapshot{}; + require(bluepad32_input_backend_snapshot(0, &snapshot) && + snapshot.imu_sample_count == + (kDefaultMotionEnabled ? 3 : 0), + "slot 0 did not start with configured motion state"); + + input.gamepad.dpad = kMotionHotkeyDpadMask; + input.gamepad.buttons = kMotionHotkeyButtonMask; + input.gamepad.misc_buttons = kMotionHotkeyMiscMask; + platform_on_controller_data(&slot_zero, &input); + require(bluepad32_input_backend_snapshot(0, &snapshot) && + snapshot.imu_sample_count == + (kDefaultMotionEnabled ? 0 : 3) && + !snapshot.dpad_up && !snapshot.button_r && + !snapshot.button_plus, + "motion chord did not toggle motion or suppress its inputs"); + + process_rumble_timer(&g_rumble_timer); + require(slot_zero.rumble_calls == 1 && + slot_zero.last_rumble_duration_ms == + (kDefaultMotionEnabled + ? kMotionDisabledFeedbackDurationMs + : kMotionEnabledFeedbackDurationMs) && + slot_zero.last_high == + (kDefaultMotionEnabled + ? kMotionDisabledFeedbackWeakMagnitude + : kMotionEnabledFeedbackWeakMagnitude) && + slot_zero.last_low == + (kDefaultMotionEnabled + ? kMotionDisabledFeedbackStrongMagnitude + : kMotionEnabledFeedbackStrongMagnitude), + "motion toggle did not send distinct state feedback"); + + platform_on_controller_data(&slot_zero, &input); + process_rumble_timer(&g_rumble_timer); + require(g_slots[0].motion_enabled == !kDefaultMotionEnabled && + slot_zero.rumble_calls == 1, + "held motion chord toggled or rumbled more than once"); + + uni_controller_t peer_input{}; + peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; + peer_input.gamepad.accel[0] = 8192; + platform_on_controller_data(&slot_one, &peer_input); + require(bluepad32_input_backend_snapshot(1, &snapshot) && + snapshot.imu_sample_count == + (kDefaultMotionEnabled ? 3 : 0), + "slot 0 motion chord changed slot 1 motion state"); + + input.gamepad = {}; + platform_on_controller_data(&slot_zero, &input); + input.gamepad.accel[0] = 8192; + input.gamepad.dpad = kMotionHotkeyDpadMask; + input.gamepad.buttons = kMotionHotkeyButtonMask; + input.gamepad.misc_buttons = kMotionHotkeyMiscMask; + platform_on_controller_data(&slot_zero, &input); + require(bluepad32_input_backend_snapshot(0, &snapshot) && + snapshot.imu_sample_count == + (kDefaultMotionEnabled ? 3 : 0), + "released motion chord did not re-arm or restore motion"); + process_rumble_timer(&g_rumble_timer); + require(g_slots[0].motion_enabled == kDefaultMotionEnabled && + slot_zero.rumble_calls == 2, + "second motion chord did not restore configured state"); + + platform_on_device_disconnected(&slot_zero); + uni_hid_device_t replacement = device(0); + require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && + g_slots[0].motion_enabled == kDefaultMotionEnabled && + !g_slots[0].motion_hotkey_latched && + !g_slots[0].feedback_pending, + "disconnect did not reset slot 0 motion hotkey state"); +} + void test_flash_core_start_contract() { bluepad32_input_backend_init(); flash_core_init_result = false; @@ -851,6 +937,8 @@ int main(int argc, char** argv) { test_slot_lighting(); } else if (scenario == "abxy-hotkey") { test_abxy_hotkey(); + } else if (scenario == "motion-hotkey") { + test_motion_hotkey(); } else if (scenario == "flash-core-start") { test_flash_core_start_contract(); } else if (scenario == "flash-core-failure") { diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index ee3397a..d8ee451 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -112,6 +112,7 @@ struct uni_hid_device_t { int rumble_calls; uint8_t last_high; uint8_t last_low; + uint16_t last_rumble_duration_ms; int lightbar_calls; uint8_t lightbar_red; uint8_t lightbar_green; diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 30dced8..957b0b4 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -38,6 +38,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "pairing-policy", "slot-lighting", "abxy-hotkey", + "motion-hotkey", "flash-core-start", "flash-core-failure", ):