Implement per-controller ABXY toggle
This commit is contained in:
parent
fa8ccf0b12
commit
94ccfc7220
8 changed files with 247 additions and 15 deletions
12
README.md
12
README.md
|
|
@ -79,6 +79,18 @@ The Pico 2 W onboard LED reports the overall Bluetooth state:
|
|||
- **Pair a new controller**: hold BOOTSEL until the LED double-blinks, then put the controller into its explicit Bluetooth pairing mode.
|
||||
- **Pairing window expires**: scanning and incoming connections stop; already connected controllers remain connected.
|
||||
|
||||
### Per-controller ABXY layout
|
||||
|
||||
Each connected AIO controller can toggle its own ABXY layout by pressing **L + R + Select + Start** together. On DualSense, use **L1 + R1 + Create + Options**. The controller gives one short rumble when the toggle is accepted; release the chord before toggling again.
|
||||
|
||||
- **Standard**: south→B, east→A, west→Y, north→X.
|
||||
- **Swapped**: south→A, east→B, west→X, north→Y.
|
||||
- The chord is consumed locally and is not forwarded to the Switch.
|
||||
- Other controller slots are unaffected.
|
||||
- Layout returns to the configured default after disconnect or reboot.
|
||||
|
||||
Edit `controller_hotkey_config.h` to change the chord, default layout, or confirmation pulse.
|
||||
|
||||
### Per-slot controller colors
|
||||
|
||||
Each AIO slot has one color shared by its emulated Switch Pro grips and its physical Bluetooth controller:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include "bluepad32_input_backend.h"
|
||||
#include "controller_hotkey_config.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stddef.h>
|
||||
|
|
@ -23,6 +24,20 @@ constexpr uint16_t kRumbleDurationMs = 50;
|
|||
constexpr uint32_t kRumblePollIntervalMs = 5;
|
||||
constexpr uint8_t kSlotCount = BLUEPAD32_INPUT_BACKEND_SLOT_COUNT;
|
||||
constexpr uint32_t kPairingWindowDurationMs = 60000;
|
||||
constexpr uint32_t kAbxyHotkeyButtonMask =
|
||||
SWITCH_ABXY_HOTKEY_BUTTON_MASK;
|
||||
constexpr uint32_t kAbxyHotkeyMiscMask = SWITCH_ABXY_HOTKEY_MISC_MASK;
|
||||
constexpr bool kDefaultSwapAbxy = SWITCH_ABXY_DEFAULT_SWAPPED != 0;
|
||||
constexpr uint32_t kAbxyFeedbackDurationMs =
|
||||
SWITCH_ABXY_FEEDBACK_DURATION_MS;
|
||||
constexpr uint8_t kAbxyFeedbackWeakMagnitude =
|
||||
SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE;
|
||||
constexpr uint8_t kAbxyFeedbackStrongMagnitude =
|
||||
SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE;
|
||||
|
||||
static_assert(kAbxyHotkeyButtonMask != 0);
|
||||
static_assert(kAbxyHotkeyMiscMask != 0);
|
||||
static_assert(kAbxyFeedbackDurationMs > 0);
|
||||
|
||||
static_assert(kSlotCount == 4);
|
||||
static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == kSlotCount);
|
||||
|
|
@ -56,6 +71,11 @@ struct BackendSlot {
|
|||
uint32_t connection_generation;
|
||||
bool active;
|
||||
bool rumble_pending;
|
||||
bool swap_abxy;
|
||||
bool hotkey_latched;
|
||||
bool feedback_pending;
|
||||
uint32_t feedback_generation;
|
||||
uint32_t feedback_until_ms;
|
||||
RumbleEnvelope pending_rumble;
|
||||
};
|
||||
|
||||
|
|
@ -243,7 +263,8 @@ bool has_motion(const uni_gamepad_t& gamepad) {
|
|||
return false;
|
||||
}
|
||||
|
||||
SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
|
||||
SwitchInputState map_gamepad(const uni_gamepad_t& gamepad,
|
||||
bool swap_abxy) {
|
||||
SwitchInputState state = make_neutral_state();
|
||||
|
||||
state.dpad_up = (gamepad.dpad & DPAD_UP) != 0;
|
||||
|
|
@ -256,6 +277,14 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
|
|||
state.button_a = (gamepad.buttons & BUTTON_B) != 0;
|
||||
state.button_y = (gamepad.buttons & BUTTON_X) != 0;
|
||||
state.button_x = (gamepad.buttons & BUTTON_Y) != 0;
|
||||
if (swap_abxy) {
|
||||
bool temporary = state.button_a;
|
||||
state.button_a = state.button_b;
|
||||
state.button_b = temporary;
|
||||
temporary = state.button_x;
|
||||
state.button_x = state.button_y;
|
||||
state.button_y = temporary;
|
||||
}
|
||||
state.button_l = (gamepad.buttons & BUTTON_SHOULDER_L) != 0;
|
||||
state.button_r = (gamepad.buttons & BUTTON_SHOULDER_R) != 0;
|
||||
state.button_zl = (gamepad.buttons & BUTTON_TRIGGER_L) != 0 || gamepad.brake >= kTriggerThreshold;
|
||||
|
|
@ -290,6 +319,36 @@ SwitchInputState map_gamepad(const uni_gamepad_t& gamepad) {
|
|||
|
||||
return state;
|
||||
}
|
||||
struct LayoutDecision {
|
||||
bool swap_abxy;
|
||||
bool suppress_hotkey;
|
||||
};
|
||||
|
||||
LayoutDecision update_layout_hotkey(uint8_t slot_index,
|
||||
uni_hid_device_t* device,
|
||||
const uni_gamepad_t& gamepad) {
|
||||
const bool chord_pressed =
|
||||
(gamepad.buttons & kAbxyHotkeyButtonMask) ==
|
||||
kAbxyHotkeyButtonMask &&
|
||||
(gamepad.misc_buttons & kAbxyHotkeyMiscMask) ==
|
||||
kAbxyHotkeyMiscMask;
|
||||
LayoutDecision decision{kDefaultSwapAbxy, false};
|
||||
|
||||
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) {
|
||||
slot.swap_abxy = !slot.swap_abxy;
|
||||
slot.feedback_pending = true;
|
||||
slot.feedback_generation = slot.connection_generation;
|
||||
}
|
||||
slot.hotkey_latched = chord_pressed;
|
||||
decision = {slot.swap_abxy, chord_pressed};
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
return decision;
|
||||
}
|
||||
|
||||
|
||||
bool pairing_window_active_at(uint32_t now_ms) {
|
||||
return g_pairing_window_open &&
|
||||
|
|
@ -379,25 +438,48 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
|
||||
RumbleEnvelope envelope{};
|
||||
uni_hid_device_t* device = nullptr;
|
||||
bool dispatch = false;
|
||||
bool feedback_dispatch = false;
|
||||
bool host_dispatch = false;
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
if (slot.rumble_pending) {
|
||||
if (slot.feedback_pending) {
|
||||
feedback_dispatch =
|
||||
slot.active && slot.device != nullptr &&
|
||||
slot.feedback_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;
|
||||
}
|
||||
}
|
||||
|
||||
const bool feedback_active =
|
||||
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
|
||||
if (!feedback_dispatch && !feedback_active &&
|
||||
slot.rumble_pending) {
|
||||
envelope = slot.pending_rumble;
|
||||
slot.rumble_pending = false;
|
||||
dispatch = envelope.slot == slot_index && slot.active &&
|
||||
slot.device != nullptr &&
|
||||
envelope.connection_generation ==
|
||||
slot.connection_generation;
|
||||
if (dispatch) {
|
||||
host_dispatch =
|
||||
envelope.slot == slot_index && slot.active &&
|
||||
slot.device != nullptr &&
|
||||
envelope.connection_generation ==
|
||||
slot.connection_generation;
|
||||
if (host_dispatch) {
|
||||
device = slot.device;
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
|
||||
if (dispatch &&
|
||||
device->report_parser.play_dual_rumble != nullptr) {
|
||||
if (feedback_dispatch) {
|
||||
device->report_parser.play_dual_rumble(
|
||||
device, 0, kAbxyFeedbackDurationMs,
|
||||
kAbxyFeedbackWeakMagnitude,
|
||||
kAbxyFeedbackStrongMagnitude);
|
||||
} else if (host_dispatch &&
|
||||
device->report_parser.play_dual_rumble != nullptr) {
|
||||
device->report_parser.play_dual_rumble(
|
||||
device, 0, kRumbleDurationMs,
|
||||
envelope.rumble.high_frequency_magnitude,
|
||||
|
|
@ -464,6 +546,10 @@ 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;
|
||||
tracked_connection = true;
|
||||
} else {
|
||||
tracked_connection = slot.device == device;
|
||||
|
|
@ -492,6 +578,10 @@ 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;
|
||||
++slot.connection_generation;
|
||||
disconnected_tracked_device = true;
|
||||
}
|
||||
|
|
@ -523,6 +613,10 @@ 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;
|
||||
++slot.state_generation;
|
||||
became_active = true;
|
||||
}
|
||||
|
|
@ -541,14 +635,23 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
|
|||
return UNI_ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
void platform_on_controller_data(uni_hid_device_t* device, uni_controller_t* controller) {
|
||||
void platform_on_controller_data(uni_hid_device_t* device,
|
||||
uni_controller_t* controller) {
|
||||
const int slot_index = slot_for_device(device);
|
||||
if (slot_index < 0 || controller == nullptr ||
|
||||
controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) {
|
||||
return;
|
||||
}
|
||||
|
||||
uni_gamepad_t gamepad = controller->gamepad;
|
||||
const LayoutDecision layout = update_layout_hotkey(
|
||||
static_cast<uint8_t>(slot_index), device, gamepad);
|
||||
if (layout.suppress_hotkey) {
|
||||
gamepad.buttons &= ~kAbxyHotkeyButtonMask;
|
||||
gamepad.misc_buttons &= ~kAbxyHotkeyMiscMask;
|
||||
}
|
||||
publish_device_state(static_cast<uint8_t>(slot_index), device,
|
||||
map_gamepad(controller->gamepad));
|
||||
map_gamepad(gamepad, layout.swap_abxy));
|
||||
}
|
||||
|
||||
const uni_property_t* platform_get_property(uni_property_idx_t index) {
|
||||
|
|
@ -621,6 +724,7 @@ void bluepad32_input_backend_init() {
|
|||
slot = {};
|
||||
slot.state = make_neutral_state();
|
||||
slot.pending_rumble.slot = slot_index;
|
||||
slot.swap_abxy = kDefaultSwapAbxy;
|
||||
g_consumed_generation[slot_index] = 0;
|
||||
g_last_snapshot_generation[slot_index] = 0;
|
||||
}
|
||||
|
|
|
|||
17
controller_hotkey_config.h
Normal file
17
controller_hotkey_config.h
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Compile-time AIO controller hotkey configuration.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Bluepad32 button masks. Default chord: L + R + SELECT + START.
|
||||
#define SWITCH_ABXY_HOTKEY_BUTTON_MASK \
|
||||
(BUTTON_SHOULDER_L | BUTTON_SHOULDER_R)
|
||||
#define SWITCH_ABXY_HOTKEY_MISC_MASK \
|
||||
(MISC_BUTTON_SELECT | MISC_BUTTON_START)
|
||||
|
||||
// 0 starts each new connection in Nintendo positional layout; 1 starts swapped.
|
||||
#define SWITCH_ABXY_DEFAULT_SWAPPED 0
|
||||
|
||||
// Local confirmation pulse sent only to the controller that toggled.
|
||||
#define SWITCH_ABXY_FEEDBACK_DURATION_MS 120
|
||||
#define SWITCH_ABXY_FEEDBACK_WEAK_MAGNITUDE 0x80
|
||||
#define SWITCH_ABXY_FEEDBACK_STRONG_MAGNITUDE 0x80
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -702,6 +702,102 @@ void test_slot_lighting() {
|
|||
"controller without RGB support did not receive its slot LED");
|
||||
}
|
||||
|
||||
void require_south_button_mapping(const SwitchInputState& state,
|
||||
bool swapped,
|
||||
const char* message) {
|
||||
require(state.button_a == swapped && state.button_b == !swapped &&
|
||||
!state.button_x && !state.button_y,
|
||||
message);
|
||||
}
|
||||
|
||||
void test_abxy_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,
|
||||
"ABXY test controllers did not become ready");
|
||||
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
input.gamepad.buttons = BUTTON_A;
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
SwitchInputState snapshot{};
|
||||
require(bluepad32_input_backend_snapshot(0, &snapshot),
|
||||
"slot 0 ABXY state was not published");
|
||||
require_south_button_mapping(
|
||||
snapshot, kDefaultSwapAbxy,
|
||||
"slot 0 did not start in the configured ABXY layout");
|
||||
|
||||
input.gamepad.buttons =
|
||||
kAbxyHotkeyButtonMask | BUTTON_A;
|
||||
input.gamepad.misc_buttons = kAbxyHotkeyMiscMask;
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
require(bluepad32_input_backend_snapshot(0, &snapshot),
|
||||
"toggled slot 0 state was not published");
|
||||
require_south_button_mapping(
|
||||
snapshot, !kDefaultSwapAbxy,
|
||||
"hotkey did not toggle slot 0 ABXY mapping");
|
||||
require(!snapshot.button_l && !snapshot.button_r &&
|
||||
!snapshot.button_minus && !snapshot.button_plus,
|
||||
"hotkey chord leaked into the Switch report");
|
||||
|
||||
bluepad32_input_backend_queue_rumble(
|
||||
0, SwitchRumbleOutput{0x11, 0x22});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(slot_zero.rumble_calls == 1 &&
|
||||
slot_zero.last_high == kAbxyFeedbackWeakMagnitude &&
|
||||
slot_zero.last_low == kAbxyFeedbackStrongMagnitude &&
|
||||
g_slots[0].rumble_pending,
|
||||
"ABXY confirmation did not take priority over host rumble");
|
||||
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_slots[0].swap_abxy == !kDefaultSwapAbxy &&
|
||||
slot_zero.rumble_calls == 1,
|
||||
"held hotkey toggled or rumbled more than once");
|
||||
|
||||
input.gamepad = {};
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
input.gamepad.buttons = kAbxyHotkeyButtonMask | BUTTON_A;
|
||||
input.gamepad.misc_buttons = kAbxyHotkeyMiscMask;
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_slots[0].swap_abxy == kDefaultSwapAbxy &&
|
||||
slot_zero.rumble_calls == 2,
|
||||
"released hotkey did not re-arm for a second toggle");
|
||||
|
||||
now_ms = kAbxyFeedbackDurationMs - 1;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(slot_zero.rumble_calls == 2 && g_slots[0].rumble_pending,
|
||||
"host rumble interrupted ABXY confirmation");
|
||||
now_ms = kAbxyFeedbackDurationMs;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(slot_zero.rumble_calls == 3 &&
|
||||
slot_zero.last_high == 0x22 &&
|
||||
slot_zero.last_low == 0x11 &&
|
||||
!g_slots[0].rumble_pending,
|
||||
"deferred host rumble did not resume after confirmation");
|
||||
|
||||
uni_controller_t peer_input{};
|
||||
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
peer_input.gamepad.buttons = BUTTON_A;
|
||||
platform_on_controller_data(&slot_one, &peer_input);
|
||||
require(bluepad32_input_backend_snapshot(1, &snapshot),
|
||||
"slot 1 ABXY state was not published");
|
||||
require_south_button_mapping(
|
||||
snapshot, kDefaultSwapAbxy,
|
||||
"slot 0 hotkey changed slot 1 layout");
|
||||
|
||||
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].swap_abxy == kDefaultSwapAbxy &&
|
||||
!g_slots[0].hotkey_latched &&
|
||||
!g_slots[0].feedback_pending,
|
||||
"disconnect did not reset slot 0 hotkey state");
|
||||
}
|
||||
|
||||
void test_flash_core_start_contract() {
|
||||
bluepad32_input_backend_init();
|
||||
flash_core_init_result = false;
|
||||
|
|
@ -753,6 +849,8 @@ int main(int argc, char** argv) {
|
|||
test_pairing_window_policy();
|
||||
} else if (scenario == "slot-lighting") {
|
||||
test_slot_lighting();
|
||||
} else if (scenario == "abxy-hotkey") {
|
||||
test_abxy_hotkey();
|
||||
} else if (scenario == "flash-core-start") {
|
||||
test_flash_core_start_contract();
|
||||
} else if (scenario == "flash-core-failure") {
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ enum {
|
|||
BUTTON_TRIGGER_R = 1 << 7,
|
||||
BUTTON_THUMB_L = 1 << 8,
|
||||
BUTTON_THUMB_R = 1 << 9,
|
||||
MISC_BUTTON_SELECT = 1 << 0,
|
||||
MISC_BUTTON_START = 1 << 1,
|
||||
MISC_BUTTON_SYSTEM = 1 << 2,
|
||||
MISC_BUTTON_SYSTEM = 1 << 0,
|
||||
MISC_BUTTON_SELECT = 1 << 1,
|
||||
MISC_BUTTON_START = 1 << 2,
|
||||
MISC_BUTTON_CAPTURE = 1 << 3,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"lifecycle",
|
||||
"pairing-policy",
|
||||
"slot-lighting",
|
||||
"abxy-hotkey",
|
||||
"flash-core-start",
|
||||
"flash-core-failure",
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue