feat: give Joy-Con pairs independent persistent profile banks
This commit is contained in:
parent
8451b1eec9
commit
34f8a50c19
25 changed files with 1644 additions and 117 deletions
|
|
@ -14,6 +14,8 @@
|
|||
#include "parser/uni_switch2_pairing.h"
|
||||
#include "platform/pico/controller_color_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
#include "pico/critical_section.h"
|
||||
#include "profile/profile_storage.h"
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -648,7 +650,26 @@ void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
|
|||
}
|
||||
|
||||
#include "core/controller_identity.cpp"
|
||||
namespace {
|
||||
unsigned state_lock_depth = 0;
|
||||
|
||||
void tracked_state_lock_enter(critical_section_t* lock) {
|
||||
critical_section_enter_blocking(lock);
|
||||
++state_lock_depth;
|
||||
}
|
||||
|
||||
void tracked_state_lock_exit(critical_section_t* lock) {
|
||||
require(state_lock_depth != 0, "state lock exit must match an enter");
|
||||
--state_lock_depth;
|
||||
critical_section_exit(lock);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
#define critical_section_enter_blocking tracked_state_lock_enter
|
||||
#define critical_section_exit tracked_state_lock_exit
|
||||
#include "input/bluepad32_input_backend.cpp"
|
||||
#undef critical_section_enter_blocking
|
||||
#undef critical_section_exit
|
||||
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
namespace {
|
||||
|
|
@ -701,6 +722,61 @@ void haptics_transport_probe_send(uint32_t, uint32_t, bool) {}
|
|||
#endif
|
||||
|
||||
namespace {
|
||||
uint8_t profile_flash[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE]{};
|
||||
ProfileStorage runtime_profile_storage;
|
||||
bool runtime_profile_storage_initialized = false;
|
||||
bool fail_profile_program = false;
|
||||
unsigned pair_observation_count = 0;
|
||||
void (*before_pair_seed)(const ControllerIdentity&) = nullptr;
|
||||
|
||||
bool runtime_profile_read(void*, uint8_t arena, size_t offset,
|
||||
uint8_t* output, size_t size) {
|
||||
require(state_lock_depth == 0, "profile reads must not hold the input state lock");
|
||||
if (arena >= PROFILE_STORAGE_ARENA_COUNT ||
|
||||
offset > PROFILE_STORAGE_ARENA_SIZE ||
|
||||
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
|
||||
return false;
|
||||
}
|
||||
memcpy(output, &profile_flash[arena][offset], size);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool runtime_profile_erase(void*, uint8_t arena) {
|
||||
require(state_lock_depth == 0, "profile erases must not hold the input state lock");
|
||||
if (arena >= PROFILE_STORAGE_ARENA_COUNT) return false;
|
||||
memset(profile_flash[arena], 0xff, PROFILE_STORAGE_ARENA_SIZE);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool runtime_profile_program(void*, uint8_t arena, size_t offset,
|
||||
const uint8_t* page, size_t size) {
|
||||
require(state_lock_depth == 0, "profile writes must not hold the input state lock");
|
||||
if (fail_profile_program || arena >= PROFILE_STORAGE_ARENA_COUNT ||
|
||||
size != PROFILE_STORAGE_PAGE_SIZE ||
|
||||
offset % PROFILE_STORAGE_PAGE_SIZE != 0 ||
|
||||
offset > PROFILE_STORAGE_ARENA_SIZE - size) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0; index < size; ++index) {
|
||||
profile_flash[arena][offset + index] &= page[index];
|
||||
}
|
||||
return memcmp(&profile_flash[arena][offset], page, size) == 0;
|
||||
}
|
||||
|
||||
ProfileStorageIo runtime_profile_io() {
|
||||
return {nullptr, PROFILE_STORAGE_ARENA_SIZE,
|
||||
PROFILE_STORAGE_SECTOR_SIZE, PROFILE_STORAGE_PAGE_SIZE,
|
||||
runtime_profile_read, runtime_profile_erase, runtime_profile_program};
|
||||
}
|
||||
|
||||
void initialize_runtime_profile_storage() {
|
||||
if (runtime_profile_storage_initialized) return;
|
||||
memset(profile_flash, 0xff, sizeof(profile_flash));
|
||||
require(runtime_profile_storage.initialize(runtime_profile_io()),
|
||||
"runtime profile catalog must initialize");
|
||||
runtime_profile_storage_initialized = true;
|
||||
}
|
||||
|
||||
void require_clear_completion_pending() {
|
||||
if (expected_pending_clear_token != 0) {
|
||||
require(!bluepad32_input_backend_clear_pairings_completed(
|
||||
|
|
@ -752,8 +828,23 @@ bool profile_service_observe_identity_on_storage_core(
|
|||
"profile identity observation fixture overflow");
|
||||
observed_profile_identities[observed_profile_identity_count++] =
|
||||
identity;
|
||||
if (runtime_profile_storage_initialized) {
|
||||
const ProfileStorageResult result = runtime_profile_storage.ensure_identity(identity);
|
||||
return result == ProfileStorageResult::kOk ||
|
||||
result == ProfileStorageResult::kUnchanged;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool profile_service_observe_joycon_pair_on_storage_core(
|
||||
const ControllerIdentity& identity) {
|
||||
require(state_lock_depth == 0, "pair admission must release the input state lock");
|
||||
++pair_observation_count;
|
||||
if (before_pair_seed != nullptr) before_pair_seed(identity);
|
||||
initialize_runtime_profile_storage();
|
||||
const ProfileStorageResult result = runtime_profile_storage.ensure_joycon_pair(identity);
|
||||
return result == ProfileStorageResult::kOk ||
|
||||
result == ProfileStorageResult::kUnchanged;
|
||||
}
|
||||
void configuration_service_snapshot(ConfigurationServiceSnapshot* output) {
|
||||
*output = {};
|
||||
output->state = ConfigurationServiceState::kReady;
|
||||
|
|
@ -840,6 +931,10 @@ void dispatch_identity_event(uint8_t event_type,
|
|||
packet[2] = static_cast<uint8_t>(controller.conn.handle);
|
||||
packet[3] = static_cast<uint8_t>(controller.conn.handle >> 8);
|
||||
switch (event_type) {
|
||||
case SM_EVENT_IDENTITY_RESOLVING_STARTED:
|
||||
case SM_EVENT_IDENTITY_RESOLVING_FAILED:
|
||||
packet_size = 11;
|
||||
break;
|
||||
case SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED:
|
||||
packet_size = sizeof(packet);
|
||||
packet[4] = BD_ADDR_TYPE_LE_RANDOM;
|
||||
|
|
@ -968,6 +1063,32 @@ Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
|
|||
return result;
|
||||
}
|
||||
|
||||
void require_pair_owner(const ControllerIdentity& identity,
|
||||
const uni_hid_device_t& left,
|
||||
const uni_hid_device_t& right) {
|
||||
ControllerIdentity left_member{};
|
||||
ControllerIdentity right_member{};
|
||||
require(controller_identity_is_joycon_pair(identity) &&
|
||||
controller_identity_joycon_pair_members(
|
||||
identity, &left_member, &right_member) &&
|
||||
controller_identity_equal(left_member, identity_for_device(&left)) &&
|
||||
controller_identity_equal(right_member, identity_for_device(&right)) &&
|
||||
!controller_identity_equal(identity, left_member) &&
|
||||
!controller_identity_equal(identity, right_member),
|
||||
"logical owner must distinguish both typed physical members from their solo owners");
|
||||
}
|
||||
|
||||
void require_active_profile(const ControllerIdentity& identity,
|
||||
uint8_t expected_index, uint8_t expected_scale) {
|
||||
const ProfileStorageIdentityIndex* owner = runtime_profile_storage.find(identity);
|
||||
ControllerProfile profile{};
|
||||
require(owner != nullptr && owner->active_profile == expected_index &&
|
||||
runtime_profile_storage.get(identity, owner->active_profile, &profile) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
profile.weak_rumble_scale == expected_scale,
|
||||
"live owner must resolve its independent persisted active profile");
|
||||
}
|
||||
|
||||
ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) {
|
||||
ControllerRumbleOutput rumble{17, 29};
|
||||
rumble.hd.actuators[0].sample_count = 3;
|
||||
|
|
@ -1315,6 +1436,18 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
|
||||
uni_hid_device_t right = switch2_device(
|
||||
right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
|
||||
left.conn.btaddr[0] = 0xc1;
|
||||
left.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
|
||||
initialize_runtime_profile_storage();
|
||||
const ControllerIdentity left_identity = identity_for_device(&left);
|
||||
const ControllerIdentity right_identity = identity_for_device(&right);
|
||||
ControllerProfile left_profile = controller_profile_default(left_identity, 2);
|
||||
left_profile.weak_rumble_scale = 37;
|
||||
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
runtime_profile_storage.activate(left_identity, 2) == ProfileStorageResult::kOk &&
|
||||
runtime_profile_storage.activate(right_identity, 5) == ProfileStorageResult::kOk,
|
||||
"solo owners must have independently selected profiles before pairing");
|
||||
uni_hid_device_t& first = right_first ? right : left;
|
||||
uni_hid_device_t& second = right_first ? left : right;
|
||||
ready_switch2(first);
|
||||
|
|
@ -1366,13 +1499,23 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
|
||||
Bluepad32SlotSnapshot merged = slot_snapshot(0);
|
||||
require(merged.active && !slot_snapshot(1).active &&
|
||||
merged.connection_generation != solo.connection_generation &&
|
||||
controller_identity_equal(merged.identity, identity_for_device(&left)),
|
||||
"either connection order must merge into first output with left profile owner");
|
||||
merged.connection_generation != solo.connection_generation,
|
||||
"either connection order must merge into the first-ready output");
|
||||
require_pair_owner(merged.identity, left, right);
|
||||
require_active_profile(merged.identity, 2, 37);
|
||||
ControllerProfile pair_profile = controller_profile_default(merged.identity, 7);
|
||||
pair_profile.weak_rumble_scale = 95;
|
||||
require(runtime_profile_storage.set(merged.identity, 7, pair_profile) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
runtime_profile_storage.activate(merged.identity, 7) == ProfileStorageResult::kOk,
|
||||
"the merged bank must be independently editable");
|
||||
require_active_profile(left_identity, 2, 37);
|
||||
require_active_profile(right_identity, 5, UINT8_MAX);
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.controller_layout ==
|
||||
Bluepad32ControllerLayout::kJoyCon2MergedPair &&
|
||||
playtest.state.motion_sample_count == 0,
|
||||
playtest.state.motion_sample_count == 0 &&
|
||||
controller_identity_equal(playtest.identity, merged.identity),
|
||||
"playtest must detect a live companion before any merged motion report");
|
||||
Bluepad32CaptureSnapshot capture{};
|
||||
require(bluepad32_input_backend_capture_page(0, 0, &capture) &&
|
||||
|
|
@ -1391,6 +1534,28 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
require(left.rumble_calls + right.rumble_calls == calls_after_merge &&
|
||||
left.player_leds == 1 && right.player_leds == 1,
|
||||
"old-generation solo feedback must not reach the pair");
|
||||
require(!bluepad32_input_backend_identify(left_identity) &&
|
||||
!bluepad32_input_backend_identify(right_identity) &&
|
||||
bluepad32_input_backend_identify(merged.identity),
|
||||
"identify must match the pair owner, not either physical solo owner");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(left.last_low == UINT8_MAX && right.last_low == UINT8_MAX &&
|
||||
left.player_leds == 1 && right.player_leds == 1,
|
||||
"identifying the pair owner must reach both physical halves");
|
||||
now_ms += 150;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, left,
|
||||
left.switch2_identity_address_type, left.conn.btaddr);
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_SUCCEEDED, right,
|
||||
right.switch2_identity_address_type, right.conn.btaddr);
|
||||
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
|
||||
"physical identity publication must not replace an enrolled composite owner");
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_FAILED, left,
|
||||
left.switch2_identity_address_type, left.conn.btaddr);
|
||||
dispatch_identity_event(SM_EVENT_IDENTITY_RESOLVING_STARTED, right,
|
||||
right.switch2_identity_address_type, right.conn.btaddr);
|
||||
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
|
||||
"physical identity resolution resets must not demote a live pair to global");
|
||||
platform_on_controller_data(&left, &left_data);
|
||||
platform_on_controller_data(&right, &right_data);
|
||||
merged = slot_snapshot(0);
|
||||
|
|
@ -1456,6 +1621,10 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
!detached.state.dpad_up && !detached.state.button_east &&
|
||||
(right_first ? detached.state.button_south : detached.state.button_west),
|
||||
"either half detach must immediately publish only the rotated survivor and own profile");
|
||||
require_active_profile(detached.identity, right_first ? 5 : 2,
|
||||
right_first ? UINT8_MAX : 37);
|
||||
require(!bluepad32_input_backend_identify(merged.identity),
|
||||
"an offline pair bank must not identify its surviving solo member");
|
||||
bluepad32_input_backend_playtest_snapshot(0, &playtest);
|
||||
require(playtest.controller_layout ==
|
||||
(right_first ? Bluepad32ControllerLayout::kJoyCon2RightSolo
|
||||
|
|
@ -1477,12 +1646,21 @@ void test_switch2_pair_lifecycle(bool right_first) {
|
|||
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons,
|
||||
"late detached input and generation-bound feedback must be ignored");
|
||||
uni_hid_device_t replacement = switch2_device(lost.idx, lost.product_id);
|
||||
memcpy(replacement.conn.btaddr, lost.conn.btaddr, sizeof(bd_addr_t));
|
||||
replacement.switch2_identity_address_type = lost.switch2_identity_address_type;
|
||||
left_profile.weak_rumble_scale = 61;
|
||||
require(runtime_profile_storage.set(left_identity, 2, left_profile) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"changing the source solo must remain independent while detached");
|
||||
ready_switch2(replacement);
|
||||
platform_on_device_disconnected(&lost);
|
||||
require(slot_snapshot(0).active && !slot_snapshot(1).active &&
|
||||
slot_snapshot(0).state.extra_buttons == survivor.switch2_extra_buttons &&
|
||||
slot_snapshot(0).connection_generation != detached.connection_generation,
|
||||
"replacement must re-pair without stale presses or a late old disconnect");
|
||||
require(controller_identity_equal(slot_snapshot(0).identity, merged.identity),
|
||||
"rejoining the same typed physical members must restore the same pair owner");
|
||||
require_active_profile(slot_snapshot(0).identity, 7, 95);
|
||||
const uint32_t clear_token = bluepad32_input_backend_clear_pairings();
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
Bluepad32PairingSnapshot cleared{};
|
||||
|
|
@ -1506,11 +1684,11 @@ void test_switch2_multiple_pairs() {
|
|||
ready_switch2(left1);
|
||||
require(slot_snapshot(0).active && slot_snapshot(1).active &&
|
||||
!slot_snapshot(2).active && !slot_snapshot(3).active &&
|
||||
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left0)) &&
|
||||
controller_identity_equal(slot_snapshot(1).identity, identity_for_device(&left1)) &&
|
||||
g_connection_policy_state == ConnectionPolicyState::Paused &&
|
||||
!scanning_enabled && !incoming_connections,
|
||||
"two deterministic pairs must consume four physical resources but only two outputs");
|
||||
require_pair_owner(slot_snapshot(0).identity, left0, right0);
|
||||
require_pair_owner(slot_snapshot(1).identity, left1, right1);
|
||||
uni_controller_t data{};
|
||||
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
data.gamepad.buttons = BUTTON_A;
|
||||
|
|
@ -1552,6 +1730,128 @@ void test_switch2_multiple_pairs() {
|
|||
"remapped ordinary physical index must isolate input and feedback from both pairs");
|
||||
}
|
||||
|
||||
void test_switch2_pair_admission_failure(bool right_first) {
|
||||
start_pairing_backend();
|
||||
initialize_runtime_profile_storage();
|
||||
auto left = switch2_device(right_first ? 1 : 0, UNI_SW2_JOYCON_L_PID);
|
||||
auto right = switch2_device(right_first ? 0 : 1, UNI_SW2_JOYCON_R_PID);
|
||||
auto& first = right_first ? right : left;
|
||||
auto& second = right_first ? left : right;
|
||||
ready_switch2(first);
|
||||
const ControllerIdentity solo_identity = identity_for_device(&first);
|
||||
require(runtime_profile_storage.activate(solo_identity, 3) == ProfileStorageResult::kOk,
|
||||
"the first solo must have an existing independent active profile");
|
||||
uni_controller_t data{};
|
||||
data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
data.gamepad.buttons = BUTTON_TRIGGER_L | BUTTON_TRIGGER_R;
|
||||
platform_on_controller_data(&first, &data);
|
||||
const auto solo = slot_snapshot(0);
|
||||
require(bluepad32_input_backend_capture_start(
|
||||
0, solo.connection_generation, CaptureOptions{}),
|
||||
"the first solo must remain recordable during pair admission");
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, solo.connection_generation, 2, ControllerProfileConfirmationPolicy::kRumble);
|
||||
const int initial_rumble_calls = first.rumble_calls;
|
||||
platform_on_device_connected(&second);
|
||||
second.switch2_identity_valid = false;
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
|
||||
pair_observation_count == 0 &&
|
||||
first.rumble_calls == initial_rumble_calls,
|
||||
"an unstable member must reject pairing before storage or solo output changes");
|
||||
second.switch2_identity_valid = true;
|
||||
first.switch2_identity_valid = false;
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
|
||||
pair_observation_count == 0 &&
|
||||
controller_identity_equal(slot_snapshot(0).identity, solo_identity),
|
||||
"an unresolved first-ready member must not merge using its stale solo owner");
|
||||
first.switch2_identity_valid = true;
|
||||
require(runtime_profile_storage.ensure_identity(identity_for_device(&second)) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"preexisting member rows must allow exercising failure of the actual pair seed");
|
||||
before_pair_seed = [](const ControllerIdentity&) {
|
||||
const auto first_solo = slot_snapshot(0);
|
||||
Bluepad32CaptureSnapshot capture{};
|
||||
require(first_solo.active &&
|
||||
first_solo.identity.transport == ControllerTransport::kBle &&
|
||||
!slot_snapshot(1).active &&
|
||||
bluepad32_input_backend_capture_page(0, 0, &capture) &&
|
||||
capture.state == CaptureState::kRecording,
|
||||
"persistent pair setup must precede any visible solo topology change");
|
||||
};
|
||||
fail_profile_program = true;
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED &&
|
||||
pair_observation_count == 1,
|
||||
"pair seed I/O failure must explicitly reject the second ready callback");
|
||||
const auto rejected = slot_snapshot(0);
|
||||
require(rejected.active && !slot_snapshot(1).active &&
|
||||
rejected.connection_generation == solo.connection_generation &&
|
||||
controller_identity_equal(rejected.identity, solo_identity) &&
|
||||
rejected.state.left_trigger == solo.state.left_trigger &&
|
||||
first.rumble_calls == initial_rumble_calls &&
|
||||
first.player_leds == 1 && second.player_leds == 0,
|
||||
"seed failure must leave the first solo's owner, input, generation and outputs intact");
|
||||
ControllerIdentity pair_identity{};
|
||||
require(controller_identity_make_joycon_pair(
|
||||
identity_for_device(&left), identity_for_device(&right), &pair_identity) &&
|
||||
runtime_profile_storage.find(pair_identity) == nullptr,
|
||||
"a failed pair seed must not expose a partial persistent owner");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(first.last_low == UINT8_MAX && second.rumble_calls == 0,
|
||||
"a rejected merge must retain the first solo's queued generation-bound feedback");
|
||||
fail_profile_program = false;
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_INIT_FAILED,
|
||||
"a retry before catalog replay must not expose a failed seed");
|
||||
require(runtime_profile_storage.initialize(runtime_profile_io()),
|
||||
"storage replay must recover intact member banks after admission failure");
|
||||
require_active_profile(solo_identity, 3, UINT8_MAX);
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS,
|
||||
"a replayed catalog must allow the pending physical member to merge on retry");
|
||||
before_pair_seed = nullptr;
|
||||
require_pair_owner(slot_snapshot(0).identity, left, right);
|
||||
require_active_profile(slot_snapshot(0).identity, right_first ? 0 : 3, UINT8_MAX);
|
||||
}
|
||||
|
||||
void test_switch2_pair_member_replacement() {
|
||||
start_pairing_backend();
|
||||
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
|
||||
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
right.conn.btaddr[0] = 0xc2;
|
||||
ready_switch2(left);
|
||||
ready_switch2(right);
|
||||
const ControllerIdentity original = slot_snapshot(0).identity;
|
||||
require(runtime_profile_storage.activate(original, 6) == ProfileStorageResult::kOk,
|
||||
"the original physical pair must have a distinct saved selection");
|
||||
platform_on_device_disconnected(&right);
|
||||
auto replacement = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
replacement.conn.btaddr[5] = 0x35;
|
||||
ready_switch2(replacement);
|
||||
const ControllerIdentity replaced = slot_snapshot(0).identity;
|
||||
require_pair_owner(replaced, left, replacement);
|
||||
require(!controller_identity_equal(original, replaced) &&
|
||||
!bluepad32_input_backend_identify(original),
|
||||
"swapping only the right member must select a different pair bank and owner");
|
||||
require_active_profile(replaced, 0, UINT8_MAX);
|
||||
require(runtime_profile_storage.activate(replaced, 4) == ProfileStorageResult::kOk,
|
||||
"the replacement pair bank must be independently selectable");
|
||||
platform_on_device_disconnected(&replacement);
|
||||
ready_switch2(right);
|
||||
require(controller_identity_equal(slot_snapshot(0).identity, original),
|
||||
"restoring the original right member must restore the original pair key");
|
||||
require_active_profile(slot_snapshot(0).identity, 6, UINT8_MAX);
|
||||
require_active_profile(replaced, 4, UINT8_MAX);
|
||||
platform_on_device_disconnected(&right);
|
||||
auto typed_right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
memcpy(typed_right.conn.btaddr, right.conn.btaddr, sizeof(bd_addr_t));
|
||||
typed_right.switch2_identity_address_type = BD_ADDR_TYPE_LE_RANDOM;
|
||||
ready_switch2(typed_right);
|
||||
const ControllerIdentity retyped = slot_snapshot(0).identity;
|
||||
require_pair_owner(retyped, left, typed_right);
|
||||
require(!controller_identity_equal(retyped, original),
|
||||
"a member's address type must participate in the pair key even with the same MAC");
|
||||
require_active_profile(retyped, 0, UINT8_MAX);
|
||||
require_active_profile(original, 6, UINT8_MAX);
|
||||
}
|
||||
|
||||
void test_switch2_admission() {
|
||||
start_backend();
|
||||
require(!switch_pico_switch2_pairing_allowed(),
|
||||
|
|
@ -1763,9 +2063,9 @@ void test_switch2_mate_reconnect() {
|
|||
"mate setup must pause background scanning while reserving physical capacity");
|
||||
require(platform_on_device_ready(&left) == UNI_ERROR_SUCCESS &&
|
||||
slot_snapshot(0).active && !slot_snapshot(1).active &&
|
||||
controller_identity_equal(slot_snapshot(0).identity, identity_for_device(&left)) &&
|
||||
!scanning_enabled && !classic_scanning_enabled,
|
||||
"ready remembered mate must merge without a pairing window and stop background scanning");
|
||||
require_pair_owner(slot_snapshot(0).identity, left, right);
|
||||
auto ordinary = device(2, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
platform_on_device_connected(&ordinary);
|
||||
require(platform_on_device_ready(&ordinary) == UNI_ERROR_SUCCESS &&
|
||||
|
|
@ -4001,6 +4301,12 @@ int main(int argc, char** argv) {
|
|||
test_switch2_pair_lifecycle(true);
|
||||
} else if (scenario == "switch2-multiple-pairs") {
|
||||
test_switch2_multiple_pairs();
|
||||
} else if (scenario == "switch2-pair-failure-left") {
|
||||
test_switch2_pair_admission_failure(false);
|
||||
} else if (scenario == "switch2-pair-failure-right") {
|
||||
test_switch2_pair_admission_failure(true);
|
||||
} else if (scenario == "switch2-pair-replacement") {
|
||||
test_switch2_pair_member_replacement();
|
||||
} else if (scenario == "switch2-admission") {
|
||||
test_switch2_admission();
|
||||
} else if (scenario == "switch2-radio-policy") {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ enum uni_error_t {
|
|||
UNI_ERROR_IGNORE_DEVICE = 1,
|
||||
UNI_ERROR_INVALID_CONTROLLER = 2,
|
||||
UNI_ERROR_NO_SLOTS = 3,
|
||||
UNI_ERROR_INIT_FAILED = 4,
|
||||
};
|
||||
|
||||
enum {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,84 @@ bool read_encoded_database(void*, size_t offset, uint8_t* output,
|
|||
return true;
|
||||
}
|
||||
|
||||
void test_pair_identity_wire_and_member_validation() {
|
||||
ControllerIdentity left = identity(1);
|
||||
left.transport = ControllerTransport::kBle;
|
||||
left.product_id = 0x2067;
|
||||
const uint8_t left_address[6] = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15};
|
||||
memcpy(left.address, left_address, sizeof(left_address));
|
||||
ControllerIdentity right = left;
|
||||
right.product_id = 0x2066;
|
||||
right.address_type = 1;
|
||||
right.address[0] = 0xc0;
|
||||
ControllerIdentity pair{};
|
||||
uint8_t encoded[CONTROLLER_IDENTITY_ENCODED_SIZE]{};
|
||||
const uint8_t expected[14] = {
|
||||
5, 3, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
|
||||
0xc0, 0x11, 0x12, 0x13, 0x14, 0x15};
|
||||
require(controller_identity_make_joycon_pair(left, right, &pair) &&
|
||||
controller_identity_encode(pair, encoded, sizeof(encoded)) &&
|
||||
memcmp(encoded, expected, sizeof(expected)) == 0,
|
||||
"pair identity did not encode both typed members");
|
||||
ControllerIdentity decoded{};
|
||||
ControllerIdentity decoded_left{};
|
||||
ControllerIdentity decoded_right{};
|
||||
require(controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
controller_identity_equal(pair, decoded) &&
|
||||
controller_identity_joycon_pair_members(
|
||||
decoded, &decoded_left, &decoded_right) &&
|
||||
controller_identity_equal(left, decoded_left) &&
|
||||
controller_identity_equal(right, decoded_right),
|
||||
"pair identity round trip lost a member or its model");
|
||||
decoded.partner_address[5] ^= 1;
|
||||
require(!controller_identity_equal(pair, decoded),
|
||||
"pair equality ignored the right address");
|
||||
for (uint8_t flags = 0; flags < 16; ++flags) {
|
||||
if (flags == 1 || flags == 5) {
|
||||
continue;
|
||||
}
|
||||
memcpy(encoded, expected, sizeof(encoded));
|
||||
encoded[0] = flags;
|
||||
require(!controller_identity_decode(encoded, sizeof(encoded), &decoded),
|
||||
"pair decoder accepted unstable, reserved, or nonstatic flags");
|
||||
}
|
||||
require(!controller_identity_make_joycon_pair(right, left, &decoded),
|
||||
"pair helper accepted reversed models");
|
||||
right.product_id = 0x2069;
|
||||
require(!controller_identity_make_joycon_pair(left, right, &decoded),
|
||||
"pair helper accepted a non-Joy-Con member");
|
||||
right.product_id = 0x2066;
|
||||
right.address_type = 2;
|
||||
require(!controller_identity_make_joycon_pair(left, right, &decoded),
|
||||
"pair helper accepted a noncanonical identity address type");
|
||||
right.address_type = left.address_type;
|
||||
memcpy(right.address, left.address, sizeof(right.address));
|
||||
require(!controller_identity_make_joycon_pair(left, right, &decoded),
|
||||
"pair helper accepted one physical typed address twice");
|
||||
left.address[0] = right.address[0] = 0xc0;
|
||||
right.address_type = 1;
|
||||
require(controller_identity_make_joycon_pair(left, right, &decoded),
|
||||
"pair helper conflated public and static-random address namespaces");
|
||||
left.partner_address[0] = 1;
|
||||
require(!controller_identity_encode(left, encoded, sizeof(encoded)) &&
|
||||
!controller_identity_make_joycon_pair(left, right, &decoded),
|
||||
"ordinary member accepted stray partner fields");
|
||||
left.partner_address[0] = 0;
|
||||
const uint8_t ordinary[14] = {
|
||||
1, 2, 0, 0, 0xc0, 0x11, 0x12, 0x13, 0x14, 0x15,
|
||||
0x7e, 0x05, 0x67, 0x20};
|
||||
require(controller_identity_encode(left, encoded, sizeof(encoded)) &&
|
||||
memcmp(encoded, ordinary, sizeof(encoded)) == 0 &&
|
||||
controller_identity_decode(encoded, sizeof(encoded), &decoded) &&
|
||||
controller_identity_equal(left, decoded),
|
||||
"ordinary identity wire encoding changed");
|
||||
const uint8_t global[14]{};
|
||||
require(controller_identity_encode(controller_identity_global(), encoded,
|
||||
sizeof(encoded)) &&
|
||||
memcmp(encoded, global, sizeof(encoded)) == 0,
|
||||
"global identity wire encoding changed");
|
||||
}
|
||||
|
||||
void test_profile_wire_schema() {
|
||||
const ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
|
|
@ -605,6 +683,7 @@ void test_extra_control_schema_round_trip_and_output_limits() {
|
|||
|
||||
} // namespace
|
||||
int main() {
|
||||
test_pair_identity_wire_and_member_validation();
|
||||
test_profile_wire_schema();
|
||||
test_legacy_profile_migration();
|
||||
test_database_round_trip_and_capacity();
|
||||
|
|
|
|||
|
|
@ -12,9 +12,13 @@ namespace {
|
|||
|
||||
struct FakeFlash {
|
||||
uint8_t bytes[PROFILE_STORAGE_ARENA_COUNT][PROFILE_STORAGE_ARENA_SIZE];
|
||||
bool fail_program_once = false;
|
||||
};
|
||||
|
||||
FakeFlash flash{};
|
||||
bool observe_pair_during_program = false;
|
||||
ControllerIdentity pending_pair{};
|
||||
ControllerIdentity pending_left{};
|
||||
|
||||
void require(bool condition, const char *message) {
|
||||
if (!condition) {
|
||||
|
|
@ -53,6 +57,20 @@ bool fake_program(void *context, uint8_t arena, size_t offset,
|
|||
offset + size > PROFILE_STORAGE_ARENA_SIZE) {
|
||||
return false;
|
||||
}
|
||||
if (observe_pair_during_program) {
|
||||
ProfileServiceActiveProfileSnapshot pair_snapshot{};
|
||||
ProfileServiceActiveProfileSnapshot left_snapshot{};
|
||||
profile_service_active_profile_snapshot(pending_pair, &pair_snapshot);
|
||||
profile_service_active_profile_snapshot(pending_left, &left_snapshot);
|
||||
require(!pair_snapshot.valid && left_snapshot.valid &&
|
||||
left_snapshot.profile_index == 7 &&
|
||||
left_snapshot.profile.weak_rumble_scale == 27,
|
||||
"pair seed exposed incomplete profiles while writing flash");
|
||||
}
|
||||
if (storage->fail_program_once) {
|
||||
storage->fail_program_once = false;
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0; index < size; ++index) {
|
||||
storage->bytes[arena][offset + index] &= page[index];
|
||||
}
|
||||
|
|
@ -426,6 +444,123 @@ void test_catalog1_selected_and_active_snapshots_migrate() {
|
|||
"old selection/activation snapshots lost migrated content or defaults");
|
||||
}
|
||||
|
||||
void test_pair_publication_failure_recovery_and_independence() {
|
||||
memset(flash.bytes, 0xff, sizeof(flash.bytes));
|
||||
ControllerIdentity left = stable_identity();
|
||||
left.transport = ControllerTransport::kBle;
|
||||
left.vendor_id = 0x057e;
|
||||
left.product_id = 0x2067;
|
||||
ControllerIdentity right = left;
|
||||
right.product_id = 0x2066;
|
||||
right.address[5] = 8;
|
||||
ControllerIdentity pair{};
|
||||
require(controller_identity_make_joycon_pair(left, right, &pair),
|
||||
"service pair identity is invalid");
|
||||
ProfileStorage setup;
|
||||
require(setup.initialize(fake_io()), "service pair fixture did not initialize");
|
||||
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
||||
ControllerProfile profile = controller_profile_default(left, slot);
|
||||
profile.weak_rumble_scale = 20 + slot;
|
||||
const char name[2] = {'L', static_cast<char>('0' + slot)};
|
||||
require(setup.set(left, slot, profile) == ProfileStorageResult::kOk &&
|
||||
setup.set_profile_name(left, slot, name, sizeof(name)) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"service solo fixture did not persist");
|
||||
}
|
||||
require(setup.activate(left, 7) == ProfileStorageResult::kOk &&
|
||||
setup.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk,
|
||||
"service solo activation did not persist");
|
||||
profile_service_initialize_on_storage_core();
|
||||
require(!active_snapshot(pair).valid &&
|
||||
!profile_service_observe_identity_on_storage_core(pair) &&
|
||||
profile_service_select(pair, 0) == ConfigurationTransactionStatus::kMalformed &&
|
||||
profile_service_activate(100, pair, 0) ==
|
||||
ConfigurationTransactionStatus::kMalformed &&
|
||||
profile_service_activate_internal(0x80000001u, pair, 0) ==
|
||||
ConfigurationTransactionStatus::kMalformed &&
|
||||
profile_service_reset(101, pair, CONTROLLER_PROFILE_ALL) ==
|
||||
ConfigurationTransactionStatus::kMalformed &&
|
||||
profile_service_set_metadata(102, pair, 7, "Premature", 9) ==
|
||||
ConfigurationTransactionStatus::kMalformed &&
|
||||
profile_service_begin(103, pair, 0, CONTROLLER_PROFILE_SCHEMA_VERSION,
|
||||
CONTROLLER_PROFILE_ENCODED_SIZE, 0) ==
|
||||
ConfigurationTransactionStatus::kMalformed,
|
||||
"service admitted an unseeded pair or supplied its fallback profile");
|
||||
ProfileServiceListSnapshot before{};
|
||||
profile_service_list_snapshot(&before);
|
||||
pending_pair = pair;
|
||||
pending_left = left;
|
||||
observe_pair_during_program = true;
|
||||
flash.fail_program_once = true;
|
||||
require(!profile_service_observe_joycon_pair_on_storage_core(pair),
|
||||
"failed seed published a ready pair");
|
||||
observe_pair_during_program = false;
|
||||
ProfileServiceListSnapshot failed{};
|
||||
profile_service_list_snapshot(&failed);
|
||||
const auto solo_after_failure = active_snapshot(left);
|
||||
require(failed.count == before.count &&
|
||||
failed.metadata.state == ProfileServiceState::kStorageError &&
|
||||
failed.metadata.generation == before.metadata.generation &&
|
||||
!active_snapshot(pair).valid && solo_after_failure.valid &&
|
||||
solo_after_failure.profile_index == 7 &&
|
||||
solo_after_failure.profile.weak_rumble_scale == 27 &&
|
||||
!profile_service_observe_joycon_pair_on_storage_core(pair),
|
||||
"rejected pair replaced the last committed solo publication");
|
||||
profile_service_initialize_on_storage_core();
|
||||
observe_pair_during_program = true;
|
||||
require(profile_service_observe_joycon_pair_on_storage_core(pair),
|
||||
"pair seed did not retry after recovery");
|
||||
observe_pair_during_program = false;
|
||||
ProfileServiceListSnapshot seeded{};
|
||||
profile_service_list_snapshot(&seeded);
|
||||
require(seeded.count == before.count + 2 &&
|
||||
seeded.metadata.state == ProfileServiceState::kReady &&
|
||||
active_snapshot(pair).valid &&
|
||||
active_snapshot(pair).profile_index == 7 &&
|
||||
active_snapshot(pair).profile.weak_rumble_scale == 27,
|
||||
"seeded pair list and active profile were not published together");
|
||||
require(profile_service_select(pair, 7) ==
|
||||
ConfigurationTransactionStatus::kCommitted,
|
||||
"seeded pair could not be selected");
|
||||
ProfileServiceMetadataSnapshot metadata{};
|
||||
profile_service_metadata_snapshot(&metadata);
|
||||
require(metadata.valid && metadata.alias[0] == '\0' &&
|
||||
strcmp(metadata.profile_names[7], "L7") == 0,
|
||||
"service did not expose independent seeded metadata");
|
||||
auto profile = controller_profile_default(pair, 7);
|
||||
profile.weak_rumble_scale = 81;
|
||||
write_profile(104, pair, 7, profile, 9000);
|
||||
require(active_snapshot(pair).profile.weak_rumble_scale == 81 &&
|
||||
active_snapshot(left).profile.weak_rumble_scale == 27 &&
|
||||
profile_service_activate(105, pair, 3) ==
|
||||
ConfigurationTransactionStatus::kPending,
|
||||
"pair edit changed the solo active profile");
|
||||
profile_service_task_on_storage_core(10000);
|
||||
require(active_snapshot(pair).profile_index == 3 &&
|
||||
active_snapshot(left).profile_index == 7,
|
||||
"pair activation changed the solo selection");
|
||||
require(profile_service_set_metadata(106, pair, 7, "Pair", 4) ==
|
||||
ConfigurationTransactionStatus::kPending,
|
||||
"pair metadata edit did not queue");
|
||||
profile_service_task_on_storage_core(11000);
|
||||
require(profile_service_observe_joycon_pair_on_storage_core(pair),
|
||||
"existing pair reconnect failed");
|
||||
profile_service_metadata_snapshot(&metadata);
|
||||
require(metadata.valid && strcmp(metadata.profile_names[7], "Pair") == 0 &&
|
||||
profile_service_select(left, 7) ==
|
||||
ConfigurationTransactionStatus::kCommitted,
|
||||
"pair reconnect overwrote pair-owned metadata");
|
||||
profile_service_metadata_snapshot(&metadata);
|
||||
require(metadata.valid && strcmp(metadata.profile_names[7], "L7") == 0 &&
|
||||
strcmp(metadata.alias, "Solo") == 0,
|
||||
"pair metadata edit changed solo metadata");
|
||||
profile_service_initialize_on_storage_core();
|
||||
require(profile_service_observe_joycon_pair_on_storage_core(pair) &&
|
||||
active_snapshot(pair).profile_index == 3 &&
|
||||
active_snapshot(left).profile_index == 7,
|
||||
"service reload reseeded the pair active selection");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ProfileStorageIo pico_profile_storage_io() { return fake_io(); }
|
||||
|
|
@ -435,6 +570,7 @@ int main() {
|
|||
test_profile_bounds_and_transaction_namespace();
|
||||
test_schema7_validation_and_atomic_selection();
|
||||
test_catalog1_selected_and_active_snapshots_migrate();
|
||||
test_pair_publication_failure_recovery_and_independence();
|
||||
std::cout << "profile service tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ struct FakeFlash {
|
|||
uint8_t corrupt_arena = 0;
|
||||
size_t corrupt_offset = 0;
|
||||
bool corrupt_next_program = false;
|
||||
int fail_read_after_programs = -1;
|
||||
};
|
||||
|
||||
FakeFlash flash{};
|
||||
|
|
@ -45,6 +46,10 @@ bool fake_read(void *context, uint8_t arena, size_t offset, uint8_t *output,
|
|||
size > PROFILE_STORAGE_ARENA_SIZE - offset) {
|
||||
return false;
|
||||
}
|
||||
if (storage->fail_read_after_programs >= 0 &&
|
||||
storage->programs >= storage->fail_read_after_programs) {
|
||||
return false;
|
||||
}
|
||||
memcpy(output, &storage->bytes[arena][offset], size);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -116,6 +121,22 @@ ControllerIdentity identity(uint8_t suffix) {
|
|||
return result;
|
||||
}
|
||||
|
||||
ControllerIdentity joycon(uint8_t suffix, bool left) {
|
||||
ControllerIdentity result = identity(suffix);
|
||||
result.transport = ControllerTransport::kBle;
|
||||
result.vendor_id = 0x057e;
|
||||
result.product_id = left ? 0x2067 : 0x2066;
|
||||
return result;
|
||||
}
|
||||
|
||||
ControllerIdentity joycon_pair(const ControllerIdentity &left,
|
||||
const ControllerIdentity &right) {
|
||||
ControllerIdentity pair{};
|
||||
require(controller_identity_make_joycon_pair(left, right, &pair),
|
||||
"pair fixture identity is invalid");
|
||||
return pair;
|
||||
}
|
||||
|
||||
void write_u16(uint8_t *output, uint16_t value) {
|
||||
output[0] = static_cast<uint8_t>(value);
|
||||
output[1] = static_cast<uint8_t>(value >> 8);
|
||||
|
|
@ -279,7 +300,7 @@ void install_populated_catalog(uint16_t version, bool fill_arena = false) {
|
|||
index <= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY; ++index) {
|
||||
const ControllerIdentity id = catalog_identity(index);
|
||||
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
||||
const ControllerProfile profile = catalog_profile(index, slot, version == 2);
|
||||
const ControllerProfile profile = catalog_profile(index, slot, version != 1);
|
||||
uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
if (version == 1) {
|
||||
encode_schema5(profile, payload);
|
||||
|
|
@ -558,14 +579,14 @@ void test_legacy_migration_is_atomic_and_complete() {
|
|||
|
||||
void test_populated_catalog_publication_power_loss() {
|
||||
static FakeFlash baseline;
|
||||
for (uint16_t version = 1; version <= 2; ++version) {
|
||||
for (uint16_t version = 1; version <= 3; ++version) {
|
||||
erase_all();
|
||||
install_populated_catalog(version, true);
|
||||
baseline = flash;
|
||||
ProfileStorage completed;
|
||||
require(completed.initialize(fake_io()), "populated catalog did not load");
|
||||
require_populated_catalog(completed, version == 2);
|
||||
if (version == 2) {
|
||||
require_populated_catalog(completed, version != 1);
|
||||
if (version == 3) {
|
||||
require(completed.set_alias(catalog_identity(0), "New", 3) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"populated catalog could not compact all 187 live records");
|
||||
|
|
@ -578,7 +599,7 @@ void test_populated_catalog_publication_power_loss() {
|
|||
flash = baseline;
|
||||
flash.torn_page_bytes = torn_bytes;
|
||||
ProfileStorage interrupted;
|
||||
if (version == 2) {
|
||||
if (version == 3) {
|
||||
require(interrupted.initialize(fake_io()),
|
||||
"catalog2 power-loss baseline did not load");
|
||||
}
|
||||
|
|
@ -587,9 +608,9 @@ void test_populated_catalog_publication_power_loss() {
|
|||
} else {
|
||||
flash.fail_after_programs = cut;
|
||||
}
|
||||
if (version == 1) {
|
||||
if (version != 3) {
|
||||
require(!interrupted.initialize(fake_io()),
|
||||
"incomplete catalog1 migration silently initialized empty");
|
||||
"incomplete old catalog migration silently initialized empty");
|
||||
} else {
|
||||
require(interrupted.set_alias(catalog_identity(0), "New", 3) ==
|
||||
ProfileStorageResult::kIoError,
|
||||
|
|
@ -604,7 +625,7 @@ void test_populated_catalog_publication_power_loss() {
|
|||
ProfileStorage recovered;
|
||||
require(recovered.initialize(fake_io()),
|
||||
"catalog did not recover after an interrupted publication");
|
||||
require_populated_catalog(recovered, version == 2);
|
||||
require_populated_catalog(recovered, version != 1);
|
||||
require(recovered.snapshot().generation >= 1248,
|
||||
"catalog publication regressed the stored generation");
|
||||
}
|
||||
|
|
@ -738,7 +759,7 @@ void test_legacy_high_generation_remains_mutable() {
|
|||
|
||||
void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
|
||||
erase_all();
|
||||
install_populated_catalog(2);
|
||||
install_populated_catalog(3);
|
||||
ProfileStorage storage;
|
||||
require(storage.initialize(fake_io()), "schema6 catalog did not load");
|
||||
require_populated_catalog(storage, true);
|
||||
|
|
@ -779,6 +800,219 @@ void test_schema6_read_migration_is_lazy_and_edit_preserves_metadata() {
|
|||
"editing a schema6 profile lost its active index, alias, or name");
|
||||
}
|
||||
|
||||
void require_pair_bank(const ProfileStorage &storage,
|
||||
const ControllerIdentity &owner, uint8_t base,
|
||||
uint8_t active, char name_prefix) {
|
||||
const auto *entry = storage.find(owner);
|
||||
require(entry != nullptr && entry->active_profile == active,
|
||||
"pair bank active selection changed");
|
||||
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
||||
ControllerProfile actual{};
|
||||
char name[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(storage.get(owner, slot, &actual) == ProfileStorageResult::kOk &&
|
||||
actual.weak_rumble_scale == base + slot &&
|
||||
storage.get_profile_name(owner, slot, name, sizeof(name)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
name[0] == name_prefix && name[1] == '0' + slot &&
|
||||
name[2] == '\0',
|
||||
"pair bank profile or name bled into another owner");
|
||||
}
|
||||
}
|
||||
|
||||
void write_pair_bank(ProfileStorage &storage, const ControllerIdentity &owner,
|
||||
uint8_t base, char name_prefix) {
|
||||
for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_COUNT; ++slot) {
|
||||
ControllerProfile profile = controller_profile_default(owner, slot);
|
||||
profile.weak_rumble_scale = base + slot;
|
||||
const char name[2] = {name_prefix, static_cast<char>('0' + slot)};
|
||||
require(storage.set(owner, slot, profile) == ProfileStorageResult::kOk &&
|
||||
storage.set_profile_name(owner, slot, name, sizeof(name)) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"pair bank edit failed");
|
||||
}
|
||||
}
|
||||
|
||||
void test_pair_seed_independence_reconnect_and_compaction() {
|
||||
erase_all();
|
||||
const auto left = joycon(1, true);
|
||||
const auto right = joycon(2, false);
|
||||
const auto pair = joycon_pair(left, right);
|
||||
ProfileStorage storage;
|
||||
require(storage.initialize(fake_io()), "pair catalog did not initialize");
|
||||
write_pair_bank(storage, left, 20, 'L');
|
||||
write_pair_bank(storage, right, 40, 'R');
|
||||
require(storage.set_alias(left, "Solo", 4) == ProfileStorageResult::kOk &&
|
||||
storage.activate(left, 7) == ProfileStorageResult::kOk &&
|
||||
storage.activate(right, 3) == ProfileStorageResult::kOk,
|
||||
"solo bank setup failed");
|
||||
const int programs = flash.programs;
|
||||
const int erases = flash.erases;
|
||||
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
|
||||
flash.programs == programs + 2 && flash.erases == erases,
|
||||
"first pair did not seed with one atomic append");
|
||||
require_pair_bank(storage, pair, 20, 7, 'L');
|
||||
char alias[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(storage.get_alias(pair, alias, sizeof(alias)) ==
|
||||
ProfileStorageResult::kOk && alias[0] == '\0' &&
|
||||
storage.get_alias(left, alias, sizeof(alias)) ==
|
||||
ProfileStorageResult::kOk && strcmp(alias, "Solo") == 0,
|
||||
"pair inherited or changed the solo alias");
|
||||
write_pair_bank(storage, left, 60, 'S');
|
||||
require(storage.activate(left, 1) == ProfileStorageResult::kOk,
|
||||
"left selection edit failed");
|
||||
ProfileStorage replayed;
|
||||
require(replayed.initialize(fake_io()), "pair seed did not replay");
|
||||
require_pair_bank(replayed, pair, 20, 7, 'L');
|
||||
require_pair_bank(replayed, left, 60, 1, 'S');
|
||||
require_pair_bank(replayed, right, 40, 3, 'R');
|
||||
write_pair_bank(replayed, pair, 80, 'P');
|
||||
require(replayed.activate(pair, 6) == ProfileStorageResult::kOk &&
|
||||
replayed.set_alias(pair, "Together", 8) == ProfileStorageResult::kOk,
|
||||
"pair selection or alias edit failed");
|
||||
const uint32_t generation = replayed.snapshot().generation;
|
||||
require(replayed.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged &&
|
||||
replayed.snapshot().generation == generation,
|
||||
"pair reconnect reseeded or wrote the bank");
|
||||
const auto other_pair = joycon_pair(left, joycon(3, false));
|
||||
require(replayed.ensure_joycon_pair(other_pair) == ProfileStorageResult::kOk,
|
||||
"different right member did not receive a separate bank");
|
||||
require_pair_bank(replayed, other_pair, 60, 1, 'S');
|
||||
require_pair_bank(replayed, pair, 80, 6, 'P');
|
||||
require_pair_bank(replayed, left, 60, 1, 'S');
|
||||
require_pair_bank(replayed, right, 40, 3, 'R');
|
||||
const uint8_t old_arena = replayed.snapshot().active_bank;
|
||||
for (size_t write = 0; write <= PROFILE_STORAGE_RECORD_CAPACITY; ++write) {
|
||||
require(replayed.activate(left, (write + 2) % CONTROLLER_PROFILE_COUNT) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"pair compaction trigger failed");
|
||||
}
|
||||
require(replayed.snapshot().active_bank != old_arena,
|
||||
"pair catalog never compacted");
|
||||
ProfileStorage compacted;
|
||||
require(compacted.initialize(fake_io()) &&
|
||||
compacted.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
||||
"materialized pair did not reconnect after compaction");
|
||||
require_pair_bank(compacted, pair, 80, 6, 'P');
|
||||
require_pair_bank(compacted, other_pair, 60, 1, 'S');
|
||||
require_pair_bank(compacted, right, 40, 3, 'R');
|
||||
require(compacted.get_alias(pair, alias, sizeof(alias)) ==
|
||||
ProfileStorageResult::kOk && strcmp(alias, "Together") == 0,
|
||||
"compaction lost pair-owned alias");
|
||||
require(compacted.reset(pair, CONTROLLER_PROFILE_ALL) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"pair reset failed");
|
||||
require_pair_bank(compacted, other_pair, 60, 1, 'S');
|
||||
require_pair_bank(compacted, right, 40, 3, 'R');
|
||||
}
|
||||
|
||||
void test_pair_capacity_and_unseeded_mutations() {
|
||||
erase_all();
|
||||
const auto left = joycon(1, true);
|
||||
const auto right = joycon(2, false);
|
||||
const auto pair = joycon_pair(left, right);
|
||||
ProfileStorage storage;
|
||||
require(storage.initialize(fake_io()), "pair capacity catalog did not initialize");
|
||||
ControllerProfile profile = controller_profile_default(pair, 0);
|
||||
profile.weak_rumble_scale = 42;
|
||||
require(storage.ensure_identity(pair) == ProfileStorageResult::kInvalidArgument &&
|
||||
storage.set(pair, 0, profile) == ProfileStorageResult::kInvalidArgument &&
|
||||
storage.activate(pair, 7) == ProfileStorageResult::kInvalidArgument &&
|
||||
storage.set_alias(pair, "Pair", 4) == ProfileStorageResult::kInvalidArgument &&
|
||||
storage.set_profile_name(pair, 7, "Pair", 4) ==
|
||||
ProfileStorageResult::kInvalidArgument &&
|
||||
storage.reset(pair, CONTROLLER_PROFILE_ALL) ==
|
||||
ProfileStorageResult::kInvalidArgument &&
|
||||
storage.find(pair) == nullptr,
|
||||
"mutation implicitly created an unseeded pair");
|
||||
for (uint8_t index = 1; index <= 14; ++index) {
|
||||
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
|
||||
"capacity setup failed");
|
||||
}
|
||||
const int programs = flash.programs;
|
||||
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kFull &&
|
||||
storage.find(left) == nullptr && storage.find(right) == nullptr &&
|
||||
storage.find(pair) == nullptr && flash.programs == programs,
|
||||
"capacity preflight changed owners before rejecting the pair");
|
||||
erase_all();
|
||||
require(storage.initialize(fake_io()), "exact-fit catalog did not initialize");
|
||||
for (uint8_t index = 1; index <= 13; ++index) {
|
||||
require(storage.ensure_identity(identity(index)) == ProfileStorageResult::kOk,
|
||||
"exact-fit setup failed");
|
||||
}
|
||||
require(storage.ensure_joycon_pair(pair) == ProfileStorageResult::kOk &&
|
||||
storage.identity_count() == CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1 &&
|
||||
storage.find(left) != nullptr && storage.find(right) != nullptr &&
|
||||
storage.get(pair, 7, &profile) == ProfileStorageResult::kOk &&
|
||||
profile.weak_rumble_scale ==
|
||||
controller_profile_default(left, 7).weak_rumble_scale &&
|
||||
storage.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
||||
"exact-capacity pair did not create both members and the default bank");
|
||||
}
|
||||
|
||||
void test_pair_seed_interruption_and_ambiguous_readback() {
|
||||
erase_all();
|
||||
const auto left = joycon(1, true);
|
||||
const auto right = joycon(2, false);
|
||||
const auto pair = joycon_pair(left, right);
|
||||
ProfileStorage initial;
|
||||
require(initial.initialize(fake_io()), "seed fault catalog did not initialize");
|
||||
write_pair_bank(initial, left, 20, 'L');
|
||||
require(initial.activate(left, 7) == ProfileStorageResult::kOk,
|
||||
"seed fault active setup failed");
|
||||
static FakeFlash baseline;
|
||||
baseline = flash;
|
||||
// Right row and seed each use two pages. Exercise every cut, including a
|
||||
// fully committed header whose program call nevertheless reports failure.
|
||||
for (size_t torn_bytes : {size_t{0}, size_t{64}, size_t{256}}) {
|
||||
for (int cut = 0; cut < 4; ++cut) {
|
||||
flash = baseline;
|
||||
ProfileStorage interrupted;
|
||||
require(interrupted.initialize(fake_io()), "seed fault baseline did not load");
|
||||
flash.fail_after_programs = flash.programs + cut;
|
||||
flash.torn_page_bytes = torn_bytes;
|
||||
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
|
||||
interrupted.find(pair) == nullptr,
|
||||
"interrupted seed exposed a partially initialized bank");
|
||||
flash.fail_after_programs = -1;
|
||||
require(interrupted.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
|
||||
interrupted.activate(left, 0) == ProfileStorageResult::kIoError,
|
||||
"ambiguous seed allowed retry or source mutation before replay");
|
||||
flash.torn_page_bytes = 0;
|
||||
ProfileStorage recovered;
|
||||
require(recovered.initialize(fake_io()), "interrupted seed did not recover");
|
||||
const auto result = recovered.ensure_joycon_pair(pair);
|
||||
require(result == ProfileStorageResult::kOk ||
|
||||
result == ProfileStorageResult::kUnchanged,
|
||||
"seed retry failed after durable replay");
|
||||
require_pair_bank(recovered, pair, 20, 7, 'L');
|
||||
require_pair_bank(recovered, left, 20, 7, 'L');
|
||||
}
|
||||
}
|
||||
flash = baseline;
|
||||
ProfileStorage ambiguous;
|
||||
require(ambiguous.initialize(fake_io()) &&
|
||||
ambiguous.ensure_identity(right) == ProfileStorageResult::kOk,
|
||||
"readback fault setup failed");
|
||||
flash.fail_read_after_programs = flash.programs + 2;
|
||||
require(ambiguous.ensure_joycon_pair(pair) == ProfileStorageResult::kIoError &&
|
||||
ambiguous.find(pair) == nullptr,
|
||||
"unacknowledged seed was published in memory");
|
||||
ProfileStorage unreadable;
|
||||
require(!unreadable.initialize(fake_io()), "unreadable seed replay failed open");
|
||||
flash.fail_read_after_programs = -1;
|
||||
require(ambiguous.activate(left, 0) == ProfileStorageResult::kIoError,
|
||||
"unacknowledged seed did not freeze source changes");
|
||||
ProfileStorage recovered;
|
||||
require(recovered.initialize(fake_io()) &&
|
||||
recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
||||
"committed unacknowledged seed was reseeded");
|
||||
require_pair_bank(recovered, pair, 20, 7, 'L');
|
||||
write_pair_bank(recovered, left, 60, 'S');
|
||||
require(recovered.ensure_joycon_pair(pair) == ProfileStorageResult::kUnchanged,
|
||||
"recovered pair was reseeded after source edits");
|
||||
require_pair_bank(recovered, pair, 20, 7, 'L');
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -795,6 +1029,9 @@ int main() {
|
|||
test_late_second_page_program_is_not_reused();
|
||||
test_unreadable_legacy_data_is_not_erased();
|
||||
test_schema6_read_migration_is_lazy_and_edit_preserves_metadata();
|
||||
test_pair_seed_independence_reconnect_and_compaction();
|
||||
test_pair_capacity_and_unseeded_mutations();
|
||||
test_pair_seed_interruption_and_ambiguous_readback();
|
||||
std::cout << "profile storage tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(
|
||||
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
|
||||
|
|
@ -97,6 +99,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"switch2-forward",
|
||||
"switch2-reverse",
|
||||
"switch2-multiple-pairs",
|
||||
"switch2-pair-failure-left",
|
||||
"switch2-pair-failure-right",
|
||||
"switch2-pair-replacement",
|
||||
"switch2-admission",
|
||||
"switch2-radio-policy",
|
||||
"switch2-radio-settling",
|
||||
|
|
|
|||
|
|
@ -819,7 +819,7 @@ def test_native_rumble_configuration_rejects_invalid_lists(malformation: str) ->
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"malformation", ("duplicate", "overflow", "global", "ble", "vendor", "product")
|
||||
"malformation", ("duplicate", "overflow", "global", "ble", "pair", "vendor", "product")
|
||||
)
|
||||
def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
|
||||
malformation: str,
|
||||
|
|
@ -833,6 +833,11 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
|
|||
),
|
||||
"global": (config_manager.ControllerIdentity.global_fallback(),),
|
||||
"ble": (replace(identity, transport=config_manager.TRANSPORT_BLE),),
|
||||
"pair": (
|
||||
config_manager.ControllerIdentity.from_bytes(
|
||||
bytes.fromhex("0503102030405060C12233445566")
|
||||
),
|
||||
),
|
||||
"vendor": (replace(identity, vendor_id=0x045E),),
|
||||
"product": (replace(identity, product_id=0x2019),),
|
||||
}[malformation]
|
||||
|
|
@ -1478,6 +1483,129 @@ def test_requested_and_active_mode_response_validation() -> None:
|
|||
config_manager.read_info(device)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("left_type", "right_type", "flags"), [(0, 0, 1), (1, 0, 3), (0, 1, 5), (1, 1, 7)]
|
||||
)
|
||||
def test_joycon_pair_wire_round_trip_preserves_both_typed_members(
|
||||
left_type: int, right_type: int, flags: int,
|
||||
) -> None:
|
||||
left = config_manager.ControllerIdentity(
|
||||
True, config_manager.TRANSPORT_BLE, left_type,
|
||||
bytes.fromhex("C10203040506"), 0x057E, 0x2067,
|
||||
)
|
||||
right = config_manager.ControllerIdentity(
|
||||
True, config_manager.TRANSPORT_BLE, right_type,
|
||||
bytes.fromhex("D11213141516"), 0x057E, 0x2066,
|
||||
)
|
||||
pair = config_manager.ControllerIdentity.make_joycon_pair(left, right)
|
||||
wire = bytes((flags, 3)) + left.address + right.address
|
||||
assert pair.to_bytes() == wire
|
||||
assert config_manager.ControllerIdentity.from_bytes(wire) == pair
|
||||
assert pair.joycon_pair_members() == (left, right)
|
||||
assert pair.to_bytes() not in (left.to_bytes(), right.to_bytes())
|
||||
other_right = replace(right, address=bytes.fromhex("D11213141517"))
|
||||
assert config_manager.ControllerIdentity.make_joycon_pair(left, other_right) != pair
|
||||
|
||||
|
||||
def test_joycon_pair_distinguishes_address_types_without_normalizing_members() -> None:
|
||||
left = config_manager.ControllerIdentity(
|
||||
True, config_manager.TRANSPORT_BLE, 0,
|
||||
bytes.fromhex("C10203040506"), 0x057E, 0x2067,
|
||||
)
|
||||
right = replace(left, address_type=1, product_id=0x2066)
|
||||
pair = config_manager.ControllerIdentity.make_joycon_pair(left, right)
|
||||
assert pair.to_bytes() == bytes.fromhex("0503C10203040506C10203040506")
|
||||
assert pair.joycon_pair_members() == (left, right)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerIdentity.make_joycon_pair(left, replace(right, address_type=0))
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerIdentity.make_joycon_pair(right, left)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wire", [
|
||||
"0003102030405060C12233445566", # Stable flag missing.
|
||||
"0D03102030405060C12233445566", # Reserved flag.
|
||||
"0303102030405060C12233445566", # Left random address is not static.
|
||||
"0503C12233445566102030405060", # Right random address is not static.
|
||||
"0103102030405060102030405060", # Duplicate typed members.
|
||||
])
|
||||
def test_joycon_pair_rejects_malformed_wire(wire: str) -> None:
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerIdentity.from_bytes(bytes.fromhex(wire))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("malformation", [
|
||||
"global", "classic", "vendor", "model", "address_type", "random_address",
|
||||
])
|
||||
def test_joycon_pair_requires_legitimate_ble_members(malformation: str) -> None:
|
||||
pair = config_manager.ControllerIdentity.from_bytes(
|
||||
bytes.fromhex("0503102030405060C12233445566")
|
||||
)
|
||||
left, right = pair.joycon_pair_members()
|
||||
invalid = {
|
||||
"global": config_manager.ControllerIdentity.global_fallback(),
|
||||
"classic": replace(right, transport=config_manager.TRANSPORT_CLASSIC),
|
||||
"vendor": replace(right, vendor_id=0x045E),
|
||||
"model": replace(right, product_id=0x2069),
|
||||
"address_type": replace(right, address_type=3),
|
||||
"random_address": replace(right, address=bytes.fromhex("412233445566")),
|
||||
}[malformation]
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.ControllerIdentity.make_joycon_pair(left, invalid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fields", [
|
||||
{"stable": False},
|
||||
{"vendor_id": 0x045E},
|
||||
{"product_id": 0x2066},
|
||||
{"address_type": 2},
|
||||
{"partner_address_type": 3},
|
||||
])
|
||||
def test_joycon_pair_rejects_inconsistent_in_memory_identity(fields: dict[str, object]) -> None:
|
||||
pair = config_manager.ControllerIdentity.from_bytes(
|
||||
bytes.fromhex("0503102030405060C12233445566")
|
||||
)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
replace(pair, **fields)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fields", [
|
||||
{"partner_address_type": 1},
|
||||
{"partner_address": bytes.fromhex("C12233445566")},
|
||||
])
|
||||
def test_physical_identity_cannot_hide_pair_members(fields: dict[str, object]) -> None:
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
replace(native_rumble_identity(), **fields)
|
||||
|
||||
|
||||
def test_pairing_inventory_rejects_logical_profile_owners() -> None:
|
||||
device = FakeDevice()
|
||||
device.records = [(config_manager.TRANSPORT_JOYCON_PAIR, 0, bytes.fromhex("102030405060"))]
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.read_pairings(device)
|
||||
|
||||
|
||||
def test_profile_cli_lists_both_pair_addresses_but_native_inventory_omits_pair(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
pair = config_manager.ControllerIdentity.from_bytes(
|
||||
bytes.fromhex("0503102030405060C12233445566")
|
||||
)
|
||||
device.profile_identities.append(pair)
|
||||
device.active_profiles[pair.to_bytes()] = 4
|
||||
monkeypatch.setattr(config_manager, "find_pico", lambda *_: device)
|
||||
assert config_manager.main(["profiles", "list"]) == 0
|
||||
listing = capsys.readouterr().out
|
||||
assert pair.address_text in listing
|
||||
assert pair.partner_address_text in listing
|
||||
assert pair.transport_text in listing
|
||||
assert config_manager.main(["config", "native-rumble", "list"]) == 0
|
||||
physical = capsys.readouterr().out
|
||||
assert pair.partner_address_text not in physical
|
||||
assert device.stable_identity.address_text in physical
|
||||
|
||||
|
||||
def test_identity_and_profile_binary_json_round_trip() -> None:
|
||||
identity = config_manager.ControllerIdentity(
|
||||
True,
|
||||
|
|
|
|||
|
|
@ -235,6 +235,145 @@ def test_editor_identifies_connected_controller_artwork(
|
|||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def joycon_pair_device() -> FakeDevice:
|
||||
device = FakeDevice()
|
||||
pair = config_manager.ControllerIdentity.from_bytes(
|
||||
bytes.fromhex("0503102030405060C12233445566")
|
||||
)
|
||||
left, right = pair.joycon_pair_members()
|
||||
device.profile_identities = [device.global_identity, left, right, pair]
|
||||
device.stable_identity = pair
|
||||
device.active_profiles = {
|
||||
identity.to_bytes(): index for index, identity in enumerate(device.profile_identities)
|
||||
}
|
||||
default_profile = config_manager.ControllerProfile.default().to_bytes()
|
||||
device.profiles = {
|
||||
(identity.to_bytes(), index): default_profile
|
||||
for identity in device.profile_identities
|
||||
for index in range(config_manager.PROFILE_CAPACITY)
|
||||
}
|
||||
device.playtest_layout = 3
|
||||
device.playtest_motion = None
|
||||
return device
|
||||
|
||||
|
||||
def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks(
|
||||
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
|
||||
) -> None:
|
||||
device = joycon_pair_device
|
||||
_, left, right, pair = device.profile_identities
|
||||
device.playtest_connected = False
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
owners = listing["identities"]
|
||||
assert [owner["key"] for owner in owners] == [
|
||||
identity.to_bytes().hex() for identity in device.profile_identities
|
||||
]
|
||||
assert [owner["controller"]["layout"] for owner in owners[1:]] == [
|
||||
"joycon2-left", "joycon2-right", "joycon2-pair",
|
||||
]
|
||||
owner = owners[3]
|
||||
assert owner["controller"]["model"] != owners[1]["controller"]["model"]
|
||||
assert "50:60" in owner["label"] and "55:66" in owner["label"]
|
||||
assert owner["identity"]["members"]["left"]["address"] == left.address_text
|
||||
assert owner["identity"]["members"]["left"]["address_type"] == 0
|
||||
assert owner["identity"]["members"]["right"]["address"] == right.address_text
|
||||
assert owner["identity"]["members"]["right"]["address_type"] == 1
|
||||
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == {
|
||||
"c", "left_sl", "left_sr", "right_sl", "right_sr",
|
||||
}
|
||||
status, offline = request_json(f"{base_url}/api/profiles/3/8/playtest")
|
||||
assert status == 200
|
||||
assert offline["connected"] is False
|
||||
assert offline["controller"] == owner["controller"]
|
||||
assert offline["source_controls"] == owner["source_controls"]
|
||||
|
||||
expected = {
|
||||
index: config_manager.ControllerProfile.default().to_json_object()
|
||||
for index in (1, 2, 3)
|
||||
}
|
||||
for bank, output in ((3, "north"), (1, "east"), (2, "west")):
|
||||
expected[bank]["button_map"]["south"] = output
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/profiles/{bank}/8", method="PUT",
|
||||
value=expected[bank], token=token,
|
||||
)
|
||||
assert status == 200
|
||||
for other_bank, expected_profile in expected.items():
|
||||
status, stored = request_json(f"{base_url}/api/profiles/{other_bank}/8")
|
||||
assert status == 200
|
||||
assert stored["profile"] == expected_profile
|
||||
for path, value in (
|
||||
("profiles/3/8/name", "Pair platformer"),
|
||||
("identities/3/alias", "Couch pair"),
|
||||
):
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/{path}", method="PUT", value={"value": value}, token=token,
|
||||
)
|
||||
assert status == 200
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/profiles/3/8/activate", method="POST", token=token,
|
||||
)
|
||||
assert status == 200
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert [owner["active_profile"] for owner in listing["identities"]] == [1, 2, 3, 8]
|
||||
assert listing["identities"][3]["label"] == "Couch pair"
|
||||
assert listing["identities"][3]["key"] == pair.to_bytes().hex()
|
||||
for bank in (1, 2, 3):
|
||||
status, stored = request_json(f"{base_url}/api/profiles/{bank}/8")
|
||||
assert status == 200
|
||||
assert stored["name"] == ("Pair platformer" if bank == 3 else "")
|
||||
assert stored["alias"] == ("Couch pair" if bank == 3 else "")
|
||||
|
||||
device.playtest_connected = True
|
||||
for bank in (1, 2, 3):
|
||||
status, live = request_json(f"{base_url}/api/profiles/{bank}/8/playtest")
|
||||
assert status == 200
|
||||
assert live["owner_key"] == device.profile_identities[bank].to_bytes().hex()
|
||||
assert live["identity_key"] == pair.to_bytes().hex()
|
||||
assert (live["identity_key"] == live["owner_key"]) == (bank == 3)
|
||||
assert live["label"] == "Couch pair"
|
||||
other_pair = config_manager.ControllerIdentity.make_joycon_pair(
|
||||
left, replace(right, address=bytes.fromhex("C12233445567"))
|
||||
)
|
||||
device.stable_identity = other_pair
|
||||
status, other_live = request_json(f"{base_url}/api/profiles/3/8/playtest")
|
||||
assert status == 200
|
||||
assert other_live["owner_key"] == pair.to_bytes().hex()
|
||||
assert other_live["identity_key"] == other_pair.to_bytes().hex()
|
||||
assert other_live["owner_key"] != other_live["identity_key"]
|
||||
|
||||
|
||||
def test_capture_cannot_bind_pair_input_to_a_solo_owner(
|
||||
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
|
||||
) -> None:
|
||||
device = joycon_pair_device
|
||||
left = device.profile_identities[1]
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
||||
assert status == 200
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/profiles/1/1/capture/start", method="POST", token=token,
|
||||
value={
|
||||
"owner_key": left.to_bytes().hex(),
|
||||
"capture_id": "solo-bank-paired-input",
|
||||
"slot": device.playtest_slot,
|
||||
"connection_generation": device.playtest_connection_generation,
|
||||
"macro_index": 0,
|
||||
"profile": config_manager.ControllerProfile.default().to_json_object(),
|
||||
"channels": 1,
|
||||
"max_events": 8,
|
||||
"axis_quantum": 512,
|
||||
"trigger_quantum": 1024,
|
||||
"max_duration_ms": 1000,
|
||||
},
|
||||
)
|
||||
assert status == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner_index", [0, 1])
|
||||
def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
|
||||
monkeypatch: pytest.MonkeyPatch, owner_index: int,
|
||||
|
|
@ -254,6 +393,7 @@ def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
|
|||
assert sample["controller"]["layout"] == expected
|
||||
assert sample["owner_key"] == device.profile_identities[owner_index].to_bytes().hex()
|
||||
assert sample["identity_key"] == left.to_bytes().hex()
|
||||
assert sample["identity"]["is_joycon_pair"] is False
|
||||
expected_extras = (
|
||||
{"c", "left_sl", "left_sr", "right_sl", "right_sr"}
|
||||
if code == 3 else {"left_sl", "left_sr"}
|
||||
|
|
@ -261,6 +401,9 @@ def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
|
|||
assert set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == expected_extras
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert [owner["key"] for owner in listing["identities"]] == [
|
||||
device.global_identity.to_bytes().hex(), left.to_bytes().hex(),
|
||||
]
|
||||
assert listing["identities"][1]["controller"]["layout"] == "joycon2-left"
|
||||
device.playtest_connected = False
|
||||
status, offline = request_json(f"{base_url}/api/profiles/{owner_index}/1/playtest")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue