Add graphical controller profile editor

This commit is contained in:
Joey Yakimowich-Payne 2026-09-03 21:55:02 -06:00
commit eb03d94183
29 changed files with 6965 additions and 308 deletions

View file

@ -1970,13 +1970,22 @@ void test_stateful_host_rumble_restore() {
#endif
}
void test_motion_hotkey() {
void test_motion_toggle_action() {
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");
"motion action test controllers did not become ready");
Bluepad32SlotSnapshot backend_snapshot{};
bluepad32_input_backend_snapshot(0, &backend_snapshot);
require(!bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation + 1u),
"stale connection toggled motion");
require(bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation),
"live connection did not toggle motion");
uni_controller_t input{};
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
@ -1985,24 +1994,8 @@ void test_motion_hotkey() {
ControllerState snapshot{};
require(read_controller_state(0, &snapshot) &&
snapshot.motion_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);
Bluepad32SlotSnapshot backend_snapshot{};
bluepad32_input_backend_snapshot(0, &backend_snapshot);
require(read_controller_state(0, &snapshot) &&
backend_snapshot.pre_hotkey_button_mask ==
kMotionHotkeyLogicalButtonMask &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 0 : 3) &&
!snapshot.dpad_up && !snapshot.button_right_shoulder &&
!snapshot.button_start,
"motion chord was not published pre-hotkey or suppressed "
"from normal output");
(kDefaultMotionEnabled ? 0 : 3),
"motion toggle did not change slot motion publication");
process_rumble_timer(&g_rumble_timer);
require(slot_zero.rumble_calls == 1 &&
@ -2020,12 +2013,6 @@ void test_motion_hotkey() {
: 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;
@ -2033,32 +2020,27 @@ void test_motion_hotkey() {
require(read_controller_state(1, &snapshot) &&
snapshot.motion_sample_count ==
(kDefaultMotionEnabled ? 3 : 0),
"slot 0 motion chord changed slot 1 motion state");
"slot 0 motion action 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;
require(bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation),
"second live motion toggle failed");
platform_on_controller_data(&slot_zero, &input);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_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");
"second motion toggle did not restore configured state");
platform_on_device_disconnected(&slot_zero);
require(!bluepad32_input_backend_toggle_motion(
0, backend_snapshot.connection_generation),
"disconnected generation toggled motion");
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].pre_hotkey_button_mask == 0 &&
!g_slots[0].feedback_pending,
"disconnect did not reset slot 0 motion hotkey state");
"disconnect did not reset slot 0 motion action state");
}
void test_protocol_neutral_analog_state() {
@ -2392,7 +2374,7 @@ int main(int argc, char** argv) {
} else if (scenario == "profile-feedback") {
test_profile_feedback_scheduler();
} else if (scenario == "motion-hotkey") {
test_motion_hotkey();
test_motion_toggle_action();
} else if (scenario == "analog-state") {
test_protocol_neutral_analog_state();
} else if (scenario == "rumble-mode") {

View file

@ -28,6 +28,9 @@ unsigned active_snapshot_count = 0;
std::array<ActivationAttempt, 32> activation_attempts{};
size_t activation_attempt_count = 0;
unsigned activation_busy_attempts = 0;
uint8_t last_motion_toggle_slot = 0;
uint32_t last_motion_toggle_connection_generation = 0;
unsigned motion_toggle_count = 0;
void require(bool condition, const char* message) {
if (!condition) {
@ -54,6 +57,9 @@ void prepare_profiles() {
activation_attempts = {};
activation_attempt_count = 0;
activation_busy_attempts = 0;
last_motion_toggle_slot = 0;
last_motion_toggle_connection_generation = 0;
motion_toggle_count = 0;
for (uint8_t slot = 0;
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
FakeProfileRow& row = rows[slot];
@ -390,8 +396,9 @@ void test_initial_profile_indication_once_per_connection() {
void test_default_switching_retry_commit_and_feedback() {
prepare_profiles();
ControllerProfile& initial_profile = rows[0].profiles[0];
initial_profile.macro_trigger = static_cast<uint8_t>(
ControllerProfileLogicalButton::kLeftShoulder);
initial_profile.macro_trigger_mask =
logical_button_bit(
ControllerProfileLogicalButton::kLeftShoulder);
initial_profile.macro_step_count = 2;
initial_profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
@ -526,8 +533,9 @@ void test_identity_promotion_preserves_held_switching() {
}
void configure_motion_suppression_probe(ControllerProfile* profile) {
profile->macro_trigger = static_cast<uint8_t>(
ControllerProfileLogicalButton::kDpadUp);
profile->macro_trigger_mask =
logical_button_bit(
ControllerProfileLogicalButton::kDpadUp);
profile->macro_step_count = 2;
profile->macro_steps[0].type =
ControllerProfileMacroStepType::kState;
@ -673,11 +681,50 @@ void test_switching_slot_isolation() {
}
void test_profile_motion_toggle_supports_trigger_chords() {
prepare_profiles();
const uint32_t custom_chord =
logical_button_bit(ControllerProfileLogicalButton::kSouth) |
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
rows[0].profiles[0].motion_toggle_chord = custom_chord;
Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9);
(void)runtime_transform(0, snapshot, 0);
require(motion_toggle_count == 0,
"profile refresh toggled motion without a chord");
apply_button_mask(
logical_button_bit(ControllerProfileLogicalButton::kSouth),
&snapshot);
snapshot.state.left_trigger = UINT16_MAX;
ControllerProfileTransformResult output =
runtime_transform(0, snapshot, 1);
require(motion_toggle_count == 1 &&
last_motion_toggle_slot == 0 &&
last_motion_toggle_connection_generation == 9 &&
controller_profile_extract_button_mask(output.state) == 0 &&
output.state.left_trigger == 0,
"trigger-backed motion chord did not toggle and consume inputs");
(void)runtime_transform(0, snapshot, 2);
require(motion_toggle_count == 1,
"held trigger-backed motion chord toggled twice");
snapshot = make_snapshot(0, 9);
(void)runtime_transform(0, snapshot, 3);
apply_button_mask(
logical_button_bit(ControllerProfileLogicalButton::kSouth),
&snapshot);
snapshot.state.left_trigger = UINT16_MAX;
(void)runtime_transform(0, snapshot, 4);
require(motion_toggle_count == 2,
"released trigger-backed motion chord did not re-arm");
}
void configure_synthetic_profile(uint8_t slot) {
ControllerProfile& profile = rows[slot].profiles[0];
profile = controller_profile_default(rows[slot].identity, 0);
profile.macro_trigger =
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
profile.macro_trigger_mask =
logical_button_bit(ControllerProfileLogicalButton::kSouth);
profile.macro_cancel =
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
profile.macro_step_count = 2;
@ -837,6 +884,14 @@ void test_runtime_slot_synthetic_isolation() {
} // namespace
bool bluepad32_input_backend_toggle_motion(
uint8_t slot, uint32_t connection_generation) {
last_motion_toggle_slot = slot;
last_motion_toggle_connection_generation = connection_generation;
++motion_toggle_count;
return true;
}
uint32_t profile_service_database_generation() {
return database_generation;
}
@ -889,6 +944,7 @@ int main() {
test_identity_promotion_preserves_held_switching();
test_switching_uses_pre_hotkey_buttons_only();
test_custom_switching_chord_and_wrap();
test_profile_motion_toggle_supports_trigger_chords();
test_switching_slot_isolation();
test_all_runtime_cancellation_causes();
test_runtime_slot_synthetic_isolation();

View file

@ -45,9 +45,9 @@ void test_profile_wire_schema() {
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
"default profile did not encode");
require(encoded[0] == 2 && encoded[1] == 0 &&
require(encoded[0] == 4 && encoded[1] == 0 &&
encoded[2] == 0 && encoded[3] == 1,
"profile header is not little-endian v2/256");
"profile header is not little-endian v4/256");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
require(encoded[4 + index] == index,
@ -64,9 +64,15 @@ void test_profile_wire_schema() {
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
"default trigger encoding changed");
require(encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
encoded[61] == 0 &&
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
encoded[71] == 0,
"default trigger mappings are not identity");
require(encoded[72] == 0xff && encoded[73] == 0xff &&
encoded[74] == 3 && encoded[78] == 0xff &&
encoded[79] == 0xff && encoded[80] == 1,
encoded[74] == 3 && encoded[78] == 0 &&
encoded[79] == 0 && encoded[80] == 1 &&
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON,
"default rumble or macro encoding changed");
for (uint8_t index = 0;
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
@ -77,12 +83,40 @@ void test_profile_wire_schema() {
ControllerProfile decoded{};
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
"default profile did not decode");
ControllerProfile action_profile = profile;
action_profile.switching_chord =
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
(1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kSouth));
action_profile.macro_trigger_mask =
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
action_profile.motion_toggle_chord =
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
action_profile.macro_cancel =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(
action_profile, action_encoded, sizeof(action_encoded)) &&
action_encoded[75] == 0x2d &&
action_encoded[81] ==
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
controller_profile_decode(
action_encoded, sizeof(action_encoded), &decoded) &&
decoded.switching_chord ==
action_profile.switching_chord &&
decoded.motion_toggle_chord ==
action_profile.motion_toggle_chord &&
decoded.macro_trigger_mask ==
action_profile.macro_trigger_mask &&
decoded.macro_cancel == action_profile.macro_cancel,
"trigger-backed action masks did not round-trip");
encoded[252] = 1;
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
"nonzero reserved profile byte was accepted");
ControllerProfile invalid = profile;
invalid.button_map[0] = 16;
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
require(!controller_profile_validate(invalid),
"invalid direct output was accepted");
invalid = profile;
@ -156,11 +190,14 @@ void test_legacy_profile_migration() {
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"migrated default profile did not encode");
for (size_t index = 0; index < sizeof(encoded); ++index) {
const bool schema_byte = index == 0;
const bool threshold_byte =
const bool migration_byte =
index == 0 ||
(index >= 58 && index < 60) ||
(index >= 68 && index < 70);
if (!schema_byte && !threshold_byte) {
index == 60 ||
(index >= 68 && index < 70) ||
index == 70 ||
index == 78 || index == 79 || index == 81;
if (!migration_byte) {
require(encoded[index] == kLegacyDefaultProfile[index],
"legacy default profile changed an unrelated byte");
}
@ -177,8 +214,12 @@ void test_legacy_profile_migration() {
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
encoded[69] ==
static_cast<uint8_t>(
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
"migrated default profile did not encode as v2");
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
encoded[78] == 0 && encoded[79] == 0 &&
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON,
"migrated default profile did not encode as v4");
require(controller_profile_decode(
kLegacyNarrowRawRangeProfile,
@ -196,11 +237,14 @@ void test_legacy_profile_migration() {
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"migrated narrow-raw-range profile did not encode");
for (size_t index = 0; index < sizeof(encoded); ++index) {
const bool schema_byte = index == 0;
const bool threshold_byte =
const bool migration_byte =
index == 0 ||
(index >= 58 && index < 60) ||
(index >= 68 && index < 70);
if (!schema_byte && !threshold_byte) {
index == 60 ||
(index >= 68 && index < 70) ||
index == 70 ||
index == 78 || index == 79 || index == 81;
if (!migration_byte) {
require(
encoded[index] == kLegacyNarrowRawRangeProfile[index],
"narrow-raw-range migration changed unrelated profile data");
@ -214,13 +258,68 @@ void test_legacy_profile_migration() {
require(migrated.triggers[0].digital_threshold == 0x1234 &&
migrated.triggers[1].digital_threshold == 0xabcd,
"legacy custom thresholds were not preserved");
require(migrated.triggers[0].output ==
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
migrated.triggers[1].output ==
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
"legacy trigger identity mappings were not restored");
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
"legacy custom-threshold profile did not re-encode");
const uint8_t legacy_macro_trigger =
kLegacyCustomThresholdProfile[78];
const uint16_t expected_macro_trigger =
legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
? static_cast<uint16_t>(1u << legacy_macro_trigger)
: 0;
require(migrated.macro_trigger_mask == expected_macro_trigger &&
migrated.macro_cancel ==
kLegacyCustomThresholdProfile[79],
"legacy macro trigger was not migrated to a chord");
for (size_t index = 1; index < sizeof(encoded); ++index) {
require(encoded[index] == kLegacyCustomThresholdProfile[index],
"legacy custom-threshold profile changed data");
const bool migration_byte =
index == 60 || index == 70 ||
index == 78 || index == 79 || index == 81;
if (!migration_byte) {
require(encoded[index] ==
kLegacyCustomThresholdProfile[index],
"legacy custom-threshold profile changed data");
}
}
const ControllerProfile previous_source =
controller_profile_default(controller_identity_global(), 0);
uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
require(controller_profile_encode(
previous_source, previous_encoded,
sizeof(previous_encoded)),
"previous-schema fixture did not encode");
previous_encoded[0] = static_cast<uint8_t>(
CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION);
previous_encoded[60] = 0;
previous_encoded[70] = 0;
previous_encoded[78] = static_cast<uint8_t>(
ControllerProfileLogicalButton::kSouth);
previous_encoded[79] = static_cast<uint8_t>(
ControllerProfileLogicalButton::kCapture);
previous_encoded[81] = 0;
previous_encoded[98] = 0;
previous_encoded[99] = 0;
require(controller_profile_decode(
previous_encoded, sizeof(previous_encoded), &migrated) &&
migrated.triggers[0].output ==
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
migrated.triggers[1].output ==
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
migrated.macro_trigger_mask ==
static_cast<uint16_t>(
1u << static_cast<uint8_t>(
ControllerProfileLogicalButton::kSouth)) &&
migrated.macro_cancel ==
static_cast<uint8_t>(
ControllerProfileLogicalButton::kCapture) &&
migrated.motion_toggle_chord == 0,
"v2 profile controls did not migrate to v4");
ControllerProfile current =
controller_profile_default(controller_identity_global(), 0);
current.triggers[0].digital_threshold = 0x8000;

View file

@ -63,6 +63,8 @@ uint16_t transform_left_trigger(
uint16_t value) {
ControllerProfile profile = default_profile();
profile.triggers[0] = configuration;
profile.triggers[0].output =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
ControllerState state{};
state.left_trigger = value;
return controller_profile_transform(state, profile).state.left_trigger;
@ -107,9 +109,9 @@ void test_button_masks_and_direct_mapping() {
"disabled button mapping still produced output");
ControllerProfile invalid = default_profile();
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
require(!controller_profile_validate(invalid),
"logical output 16 was accepted");
"logical output 18 was accepted");
invalid.button_map[0] = 0xfe;
require(!controller_profile_validate(invalid),
"logical output 0xfe was accepted");
@ -271,6 +273,8 @@ void test_trigger_boundaries_curves_and_thresholds() {
ControllerProfile profile = default_profile();
profile.triggers[0] = slow;
profile.triggers[0].output =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
profile.triggers[0].digital_threshold = 12345;
profile.triggers[1].digital_threshold = 54321;
ControllerState state{};
@ -282,6 +286,41 @@ void test_trigger_boundaries_curves_and_thresholds() {
"profile-owned digital thresholds were not returned");
}
void test_trigger_and_button_cross_mapping() {
ControllerProfile profile = default_profile();
profile.triggers[0].output =
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
profile.triggers[0].digital_threshold = 30000;
profile.triggers[1].output =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
profile.triggers[1].digital_threshold = 40000;
profile.button_map[
static_cast<uint8_t>(ControllerProfileLogicalButton::kEast)] =
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
ControllerState input{};
input.left_trigger = 29999;
input.right_trigger = 45000;
input.button_east = true;
ControllerProfileTransformResult output =
controller_profile_transform(input, profile);
require(!output.state.button_south &&
output.state.left_trigger == 45000 &&
output.state.right_trigger == UINT16_MAX &&
output.left_trigger_digital_threshold == 40000,
"cross-mapped triggers lost analog or button output");
input.left_trigger = 30000;
output = controller_profile_transform(input, profile);
require(output.state.button_south,
"trigger threshold did not produce its mapped button");
profile.triggers[0].output =
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
require(!controller_profile_validate(profile),
"two analog triggers targeting one output were accepted");
}
void test_default_whole_state_equivalence() {
ControllerState input{};
input.dpad_up = true;
@ -378,6 +417,7 @@ int main() {
test_stick_center_boundaries_and_inversion();
test_stick_curves_and_monotonicity();
test_trigger_boundaries_curves_and_thresholds();
test_trigger_and_button_cross_mapping();
test_default_whole_state_equivalence();
test_rumble_scaling_and_confirmation_policy();
return 0;

View file

@ -39,7 +39,7 @@ ControllerProfile profile_with_macro(
ControllerProfileLogicalButton::kCapture) {
ControllerProfile profile =
controller_profile_default(controller_identity_global(), 0);
profile.macro_trigger = button_index(trigger);
profile.macro_trigger_mask = button_bit(trigger);
profile.macro_cancel = button_index(cancel);
return profile;
}
@ -100,6 +100,54 @@ void test_immediate_press_release_dpad_and_explicit_end() {
"explicit end did not clear overrides and restore physical input");
}
void test_macro_trigger_chord_requires_every_button() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSouth);
profile.macro_trigger_mask =
button_bit(ControllerProfileLogicalButton::kSouth) |
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
profile.macro_cancel =
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
profile.macro_step_count = 2;
profile.macro_steps[0].type =
ControllerProfileMacroStepType::kState;
profile.macro_steps[0].override_flags =
kControllerProfileOverrideButtons;
profile.macro_steps[0].duration_ms = 100;
profile.macro_steps[0].output_button_mask =
button_bit(ControllerProfileLogicalButton::kNorth);
set_end(&profile, 1);
ControllerSyntheticInputContext context{};
ControllerState input =
state_with_buttons(
button_bit(ControllerProfileLogicalButton::kSouth));
ControllerProfileTransformResult output =
controller_synthetic_input_apply(&context, input, profile, 0);
require(!context.macro_active &&
has_button(output, ControllerProfileLogicalButton::kSouth),
"partial macro chord triggered or consumed a normal button");
input = state_with_buttons(
button_bit(ControllerProfileLogicalButton::kSouth));
input.left_trigger = UINT16_MAX;
output = controller_synthetic_input_apply(
&context, input, profile, 1);
require(context.macro_active &&
has_button(output, ControllerProfileLogicalButton::kNorth) &&
!has_button(output, ControllerProfileLogicalButton::kSouth) &&
output.state.left_trigger == 0,
"completed trigger-backed macro chord did not consume inputs");
input = controller_neutral_state();
input.right_trigger = UINT16_MAX;
output = controller_synthetic_input_apply(
&context, input, profile, 2);
require(!context.macro_active &&
output.state.right_trigger == 0,
"trigger-backed macro cancellation leaked or stayed active");
}
void test_optional_field_overrides_and_motion_preservation() {
ControllerProfile profile =
profile_with_macro(ControllerProfileLogicalButton::kSelect);
@ -454,6 +502,7 @@ void test_four_contexts_are_isolated() {
int main() {
test_immediate_press_release_dpad_and_explicit_end();
test_macro_trigger_chord_requires_every_button();
test_optional_field_overrides_and_motion_preservation();
test_zero_max_wait_and_scheduled_catch_up();
test_consumption_cancel_precedence_and_duplicate_contributors();

View file

@ -483,7 +483,7 @@ class FakeDevice:
def custom_profile() -> config_manager.ControllerProfile:
return config_manager.ControllerProfile(
button_map=(
1,
config_manager.LOGICAL_CONTROLS.index("left_trigger"),
0,
2,
3,
@ -506,14 +506,21 @@ def custom_profile() -> config_manager.ControllerProfile:
right_stick=config_manager.StickConfig(
789, -321, 500, 31000, 192, False, True
),
left_trigger=config_manager.TriggerConfig(100, 65000, 320, 32000),
right_trigger=config_manager.TriggerConfig(200, 64000, 224, 33000),
left_trigger=config_manager.TriggerConfig(
100, 65000, 320, 32000,
config_manager.LOGICAL_CONTROLS.index("right_trigger"),
),
right_trigger=config_manager.TriggerConfig(
200, 64000, 224, 33000,
config_manager.LOGICAL_BUTTONS.index("north"),
),
weak_rumble_scale=77,
strong_rumble_scale=201,
confirmation_policy=2,
switching_chord=(1 << 6) | (1 << 7),
macro_trigger=0,
macro_cancel=1,
switching_chord=(1 << 6) | (1 << 16),
motion_toggle_chord=(1 << 5) | (1 << 17),
macro_trigger_mask=(1 << 0) | (1 << 16) | (1 << 17),
macro_cancel=config_manager.LOGICAL_CONTROLS.index("right_trigger"),
macro_steps=(
config_manager.MacroStep(
0,
@ -1113,13 +1120,18 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
config_manager.PROFILE_SCHEMA_VERSION,
config_manager.PROFILE_SIZE,
)
assert encoded[75] == encoded[81] == 0
assert encoded[98:100] == b"\x00\x00"
assert encoded[75] == 0x2D
assert encoded[60:62] == bytes((17, 0))
assert encoded[70:72] == bytes((3, 0))
assert encoded[81] == config_manager.LOGICAL_CONTROLS.index("right_trigger")
assert struct.unpack_from("<H", encoded, 98)[0] == (
profile.motion_toggle_chord & 0xFFFF
)
assert encoded[252:] == bytes(4)
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
serialized = profile.to_json()
assert serialized.startswith('{\n "schema_version": 2,\n "size": 256,')
assert serialized.startswith('{\n "schema_version": 4,\n "size": 256,')
decoded = config_manager.ControllerProfile.from_json(serialized)
assert decoded == profile
assert decoded.to_json() == serialized
@ -1131,6 +1143,12 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
0,
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
)
legacy_default_wire[60:62] = b"\x00\x00"
legacy_default_wire[70:72] = b"\x00\x00"
legacy_default_wire[78] = config_manager.PROFILE_NONE_BUTTON
legacy_default_wire[79] = config_manager.PROFILE_NONE_BUTTON
legacy_default_wire[81] = 0
legacy_default_wire[98:100] = b"\x00\x00"
struct.pack_into(
"<HHHH",
legacy_default_wire,
@ -1180,22 +1198,37 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
)
legacy_custom_wire = bytearray(encoded)
previous_wire = bytearray(default_wire)
struct.pack_into(
"<H",
legacy_custom_wire,
previous_wire,
0,
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
config_manager.PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
)
assert (
config_manager.ControllerProfile.from_bytes(legacy_custom_wire)
== profile
previous_wire[60:62] = b"\x00\x00"
previous_wire[70:72] = b"\x00\x00"
previous_wire[78] = 0
previous_wire[79] = 1
previous_wire[81] = 0
previous_wire[98:100] = b"\x00\x00"
migrated_previous = config_manager.ControllerProfile.from_bytes(
previous_wire
)
assert migrated_previous.left_trigger.output == 16
assert migrated_previous.right_trigger.output == 17
assert migrated_previous.macro_trigger_mask == 1
assert migrated_previous.macro_cancel == 1
assert migrated_previous.motion_toggle_chord == 0
assert migrated_previous.to_bytes()[0] == config_manager.PROFILE_SCHEMA_VERSION
legacy_json_object = default_profile.to_json_object()
legacy_json_object["schema_version"] = (
config_manager.PROFILE_LEGACY_SCHEMA_VERSION
)
del legacy_json_object["motion_toggle_chord"]
del legacy_json_object["triggers"]["left"]["output"]
del legacy_json_object["triggers"]["right"]["output"]
legacy_json_object["macro"]["trigger"] = "south"
legacy_json_object["triggers"]["left"]["digital_threshold"] = (
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
)
@ -1212,15 +1245,31 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
assert migrated_json.right_trigger.digital_threshold == 33000
assert migrated_json.left_trigger.lower_deadzone == 30000
assert migrated_json.left_trigger.upper_saturation == 40000
assert migrated_json.left_trigger.output == 16
assert migrated_json.right_trigger.output == 17
assert migrated_json.macro_trigger_mask == 1
assert migrated_json.motion_toggle_chord == 0
def test_trigger_threshold_uses_transformed_output_domain() -> None:
for threshold in (0, 0xFFFF):
trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold)
assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger
trigger = config_manager.TriggerConfig(
30000, 40000, 256, threshold, 16
)
assert (
config_manager.TriggerConfig.from_bytes(
trigger.to_bytes(),
schema_version=config_manager.PROFILE_SCHEMA_VERSION,
source_index=0,
)
== trigger
)
assert (
config_manager.TriggerConfig.from_json_object(
trigger.to_json_object(), "trigger"
trigger.to_json_object(),
"trigger",
schema_version=config_manager.PROFILE_SCHEMA_VERSION,
source_index=0,
)
== trigger
)
@ -1242,7 +1291,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
config_manager.ConfigManagerError,
match="trigger digital_threshold",
):
config_manager.TriggerConfig(30000, 40000, 256, threshold)
config_manager.TriggerConfig(30000, 40000, 256, threshold, 16)
for lower_deadzone, upper_saturation in (
(40000, 40000),
@ -1253,7 +1302,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
match="lower_deadzone must be below upper_saturation",
):
config_manager.TriggerConfig(
lower_deadzone, upper_saturation, 256, 0
lower_deadzone, upper_saturation, 256, 0, 16
)
@ -1369,6 +1418,53 @@ def test_profile_cli_surfaces_late_storage_failure(
]
def test_profile_edit_cli_starts_local_web_editor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from switch_pico_bridge import profile_web
calls: list[dict[str, object]] = []
monkeypatch.setattr(
profile_web,
"run_profile_editor",
lambda **kwargs: calls.append(kwargs),
)
monkeypatch.setattr(
config_manager,
"_candidate_devices",
lambda: (_ for _ in ()).throw(
AssertionError("editor startup must not require a connected Pico")
),
)
assert (
config_manager.main(
[
"--bus",
"3",
"--address",
"7",
"--timeout",
"8",
"profiles",
"edit",
"--port",
"9000",
"--no-browser",
]
)
== 0
)
assert calls == [
{
"bus": 3,
"address": 7,
"timeout": 8.0,
"port": 9000,
"open_browser": False,
}
]
def test_profile_cli_json_round_trip_activate_and_reset(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@ -1485,9 +1581,9 @@ def test_malformed_profiles_are_rejected_before_usb(
tmp_path: Path,
) -> None:
malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes())
malformed_binary[75] = 1
malformed_binary[75] = 0xC0
with pytest.raises(
config_manager.ConfigManagerError, match="reserved fields"
config_manager.ConfigManagerError, match="action flags"
):
config_manager.ControllerProfile.from_bytes(malformed_binary)

212
tests/test_profile_web.py Normal file
View file

@ -0,0 +1,212 @@
from __future__ import annotations
import json
import threading
import urllib.error
import urllib.request
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
import pytest
from switch_pico_bridge import config_manager, profile_web
from tests.test_config_manager import FakeDevice, custom_profile
@contextmanager
def running_server(
monkeypatch: pytest.MonkeyPatch, device: FakeDevice
) -> Iterator[tuple[str, str]]:
server = profile_web.ProfileEditorServer(
("127.0.0.1", 0),
bus=None,
device_address=None,
timeout=1.0,
)
monkeypatch.setattr(server, "find_device", lambda: device)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", server.mutation_token
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def request_json(
url: str,
*,
method: str = "GET",
value: Any = None,
token: str | None = None,
) -> tuple[int, dict[str, Any]]:
data = None
headers: dict[str, str] = {}
if value is not None:
data = json.dumps(value).encode("utf-8")
headers["Content-Type"] = "application/json"
if token is not None:
headers["X-Switch-Pico-Token"] = token
request = urllib.request.Request(
url, data=data, headers=headers, method=method
)
try:
with urllib.request.urlopen(request, timeout=2) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read())
def test_editor_serves_assets_and_complete_schema(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with running_server(monkeypatch, FakeDevice()) as (base_url, _):
with urllib.request.urlopen(f"{base_url}/", timeout=2) as response:
page = response.read().decode("utf-8")
assert response.headers["Content-Security-Policy"]
with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response:
script_size = len(response.read())
assert response.headers["Content-Type"].startswith("text/javascript")
with urllib.request.urlopen(
f"{base_url}/assets/controller-switch-pro.svg", timeout=2
) as response:
artwork_size = len(response.read())
assert response.headers["Content-Type"] == "image/svg+xml"
status, schema = request_json(f"{base_url}/api/schema")
assert status == 200
assert "Profile Studio" in page
assert script_size > 1000
assert artwork_size > 10000
assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS)
assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS)
assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES)
assert schema["turbo_modes"] == list(config_manager.TURBO_MODES)
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
assert (
config_manager.ControllerProfile.from_json(
json.dumps(schema["default_profile"])
)
== config_manager.ControllerProfile.default()
)
def test_editor_identifies_connected_controller_artwork(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
switch = config_manager.ControllerIdentity(
True,
config_manager.TRANSPORT_CLASSIC,
0,
bytes.fromhex("010203040506"),
0x057E,
0x2009,
)
dualsense = config_manager.ControllerIdentity(
True,
config_manager.TRANSPORT_CLASSIC,
0,
bytes.fromhex("111213141516"),
0x054C,
0x0CE6,
)
device.profile_identities = [
device.global_identity,
switch,
dualsense,
device.stable_identity,
]
device.active_profiles = {
identity.to_bytes(): 0 for identity in device.profile_identities
}
with running_server(monkeypatch, device) as (base_url, _):
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert [
identity["controller"] for identity in listing["identities"]
] == [
{"model": "Generic controller", "style": "generic"},
{"model": "Nintendo Switch Pro Controller", "style": "switch"},
{"model": "Sony DualSense", "style": "playstation"},
{"model": "Xbox controller", "style": "xbox"},
]
def test_editor_reads_writes_and_activates_profiles_atomically(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
with running_server(monkeypatch, device) as (base_url, token):
status, listing = request_json(f"{base_url}/api/profiles")
assert status == 200
assert listing["identities"][1]["active_profile"] == 2
assert listing["identities"][1]["controller"] == {
"model": "Xbox controller",
"style": "xbox",
}
status, selected = request_json(f"{base_url}/api/profiles/1/3")
assert status == 200
assert selected["active"] is False
profile = custom_profile().to_json_object()
status, stored = request_json(
f"{base_url}/api/profiles/1/3",
method="PUT",
value=profile,
token=token,
)
assert status == 200
assert stored["stored_generation"] == 8
assert (
config_manager.ControllerProfile.from_bytes(
device.profiles[(device.stable_identity.to_bytes(), 2)]
)
== custom_profile()
)
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
status, activated = request_json(
f"{base_url}/api/profiles/1/3/activate",
method="POST",
token=token,
)
assert status == 200
assert activated["stored_generation"] == 9
assert device.active_profiles[device.stable_identity.to_bytes()] == 2
def test_editor_rejects_invalid_or_unauthorized_mutations(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
original = device.profiles[(device.global_identity.to_bytes(), 0)]
with running_server(monkeypatch, device) as (base_url, token):
status, unauthorized = request_json(
f"{base_url}/api/profiles/0/1",
method="PUT",
value=config_manager.ControllerProfile.default().to_json_object(),
)
assert status == 403
assert "token" in unauthorized["error"]
malformed = config_manager.ControllerProfile.default().to_json_object()
malformed["sticks"]["left"]["outer_saturation"] = 0
status, invalid = request_json(
f"{base_url}/api/profiles/0/1",
method="PUT",
value=malformed,
token=token,
)
assert status == 400
assert "outer_saturation" in invalid["error"]
assert device.profiles[(device.global_identity.to_bytes(), 0)] == original