Add Profile Studio Set A enhancements

This commit is contained in:
Joey Yakimowich-Payne 2026-09-04 21:49:50 -06:00
commit 8818f1f6e9
22 changed files with 1745 additions and 118 deletions

View file

@ -767,6 +767,19 @@ void test_rejections() {
"valid device must occupy its indexed slot");
require(platform_on_device_ready(&collision) == UNI_ERROR_NO_SLOTS,
"different device cannot replace an occupied slot");
g_slots[0].identity.stable = true;
g_slots[0].identity.transport = ControllerTransport::kClassic;
g_slots[0].identity.address[5] = 1;
Bluepad32SlotSnapshot identity_snapshot{};
bluepad32_input_backend_snapshot(0, &identity_snapshot);
require(bluepad32_input_backend_identify(
identity_snapshot.identity) &&
g_slots[0].pending_profile_feedback_count == 1 &&
!bluepad32_input_backend_identify(
controller_identity_global()),
"Identify did not target only the selected live controller");
g_slots[0].pending_profile_feedback_count = 0;
g_slots[0].pending_profile_feedback[0] = {};
uni_controller_t collision_data{};
collision_data.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
@ -785,12 +798,15 @@ void test_rejections() {
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,
"valid slot input must remain observable");
slot_zero.controller.battery = 201;
g_last_snapshot_generation[0] = 0;
Bluepad32PlaytestSnapshot playtest{};
bluepad32_input_backend_playtest_snapshot(0, &playtest);
require(playtest.active && playtest.state_generation != 0 &&
playtest.state.motion_sample_count == 3,
"playtest snapshot did not expose current raw input");
playtest.state.motion_sample_count == 3 &&
playtest.battery == 201 &&
(playtest.capabilities & 0x08u) != 0,
"playtest snapshot did not expose input capabilities");
bluepad32_input_backend_report_sent(0);
require(read_controller_state(0, &snapshot) &&
snapshot.motion_sample_count == 3,

View file

@ -98,6 +98,7 @@ struct uni_gamepad_t {
struct uni_controller_t {
int klass;
uni_gamepad_t gamepad;
uint8_t battery;
};
struct uni_hid_device_t;
@ -138,6 +139,7 @@ struct uni_hid_device_t {
uni_bt_conn_t conn;
int idx;
bool gamepad;
uni_controller_t controller;
uni_report_parser_t report_parser;
int rumble_calls;
uint8_t last_high;

View file

@ -188,14 +188,46 @@ void test_eight_profile_transactions_and_active_cache() {
active.profile.strong_rumble_scale == UINT8_MAX,
"reset-all did not restore defaults and activation");
require(profile_service_set_metadata(
5, connected, CONTROLLER_PROFILE_ALL,
"Desk pad", 8) ==
ConfigurationTransactionStatus::kPending,
"controller alias was not queued");
profile_service_task_on_storage_core(6000);
require(transaction_snapshot().transaction.status ==
ConfigurationTransactionStatus::kCommitted,
"controller alias did not persist");
require(profile_service_set_metadata(
6, connected, 6, "Desktop", 7) ==
ConfigurationTransactionStatus::kPending,
"profile name was not queued");
profile_service_task_on_storage_core(7000);
require(profile_service_select(connected, 6) ==
ConfigurationTransactionStatus::kCommitted,
"named profile was not selected");
ProfileServiceMetadataSnapshot metadata{};
profile_service_metadata_snapshot(&metadata);
require(metadata.valid && strcmp(metadata.alias, "Desk pad") == 0 &&
strcmp(metadata.profile_names[6], "Desktop") == 0,
"profile metadata snapshot lost persisted values");
ProfileStorage reloaded;
ControllerProfile persisted{};
char stored_metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(
reloaded.initialize(fake_io()) && reloaded.find(connected) != nullptr &&
reloaded.find(connected)->active_profile == 6 &&
reloaded.get(connected, 6, &persisted) == ProfileStorageResult::kOk &&
persisted.weak_rumble_scale == 61,
"service mutations did not survive catalog reload");
persisted.weak_rumble_scale == 61 &&
reloaded.get_alias(connected, stored_metadata,
sizeof(stored_metadata)) ==
ProfileStorageResult::kOk &&
strcmp(stored_metadata, "Desk pad") == 0 &&
reloaded.get_profile_name(connected, 6, stored_metadata,
sizeof(stored_metadata)) ==
ProfileStorageResult::kOk &&
strcmp(stored_metadata, "Desktop") == 0,
"service mutations and metadata did not survive catalog reload");
}
void test_profile_bounds_and_transaction_namespace() {

View file

@ -249,12 +249,55 @@ void test_interrupted_and_corrupt_append_recovery() {
"corrupt newest record displaced the previous record");
}
void test_profile_names_and_aliases_recover() {
erase_all();
const ControllerIdentity stable = identity(7);
ProfileStorage storage;
require(storage.initialize(fake_io()), "metadata catalog did not initialize");
require(storage.set_alias(stable, "Player one", 10) ==
ProfileStorageResult::kOk &&
storage.set_profile_name(stable, 0, "Zelda", 5) ==
ProfileStorageResult::kOk &&
storage.set_profile_name(stable, 7, "Desktop", 7) ==
ProfileStorageResult::kOk,
"profile metadata did not append");
char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(storage.get_alias(stable, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Player one") == 0,
"controller alias did not read back");
flash.fail_after_programs = flash.programs + 1;
require(storage.set_alias(stable, "Interrupted", 11) ==
ProfileStorageResult::kIoError,
"interrupted alias append reported success");
flash.fail_after_programs = -1;
ProfileStorage reloaded;
require(reloaded.initialize(fake_io()) &&
reloaded.get_alias(stable, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Player one") == 0,
"interrupted alias displaced the previous value");
require(reloaded.get_profile_name(stable, 0, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Zelda") == 0 &&
reloaded.get_profile_name(stable, 7, value, sizeof(value)) ==
ProfileStorageResult::kOk &&
strcmp(value, "Desktop") == 0,
"profile names did not survive reload");
}
void test_compaction_preserves_latest_records() {
erase_all();
const ControllerIdentity global = controller_identity_global();
ProfileStorage storage;
require(storage.initialize(fake_io()), "catalog did not initialize");
ControllerProfile profile = controller_profile_default(global, 0);
require(storage.set_alias(global, "Fallback", 8) ==
ProfileStorageResult::kOk &&
storage.set_profile_name(global, 0, "Compacted", 9) ==
ProfileStorageResult::kOk,
"compaction metadata did not append");
for (uint16_t write = 1; write <= 260; ++write) {
profile.weak_rumble_scale = static_cast<uint8_t>(write);
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
@ -264,11 +307,19 @@ void test_compaction_preserves_latest_records() {
"full arena did not compact into its peer");
ProfileStorage reloaded;
ControllerProfile recovered{};
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
require(reloaded.initialize(fake_io()) &&
reloaded.get(global, 0, &recovered) ==
ProfileStorageResult::kOk &&
recovered.weak_rumble_scale == static_cast<uint8_t>(260),
"compaction did not preserve the latest profile");
recovered.weak_rumble_scale == static_cast<uint8_t>(260) &&
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "Fallback") == 0 &&
reloaded.get_profile_name(global, 0, metadata,
sizeof(metadata)) ==
ProfileStorageResult::kOk &&
strcmp(metadata, "Compacted") == 0,
"compaction did not preserve profiles and metadata");
}
void test_legacy_migration_is_atomic_and_complete() {
@ -306,6 +357,7 @@ int main() {
test_empty_catalog_and_eight_profiles();
test_identity_capacity_and_defaults();
test_interrupted_and_corrupt_append_recovery();
test_profile_names_and_aliases_recover();
test_compaction_preserves_latest_records();
test_legacy_migration_is_atomic_and_complete();
std::cout << "profile storage tests passed\n";

View file

@ -95,6 +95,15 @@ class FakeDevice:
for identity in self.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
self.profile_aliases = {
identity.to_bytes(): "" for identity in self.profile_identities
}
self.profile_names = {
(identity.to_bytes(), index): ""
for identity in self.profile_identities
for index in range(config_manager.PROFILE_CAPACITY)
}
self.identified_identities: list[bytes] = []
self.selected_profile = (self.global_identity.to_bytes(), 0)
self.profile_generation = 7
self.profile_transaction_id = 0
@ -111,6 +120,8 @@ class FakeDevice:
self.profile_chunk_sizes: list[int] = []
self.pending_profile_mutation: tuple[int, bytes, int] | None = None
self.profile_transaction_pending_reads = 0
self.playtest_battery = 251
self.playtest_capabilities = 0x0F
self.profile_status_responses: list[tuple[int, int]] = []
self.playtest_connected = True
self.playtest_slot = 1
@ -143,8 +154,29 @@ class FakeDevice:
def _profile_list_payload(self) -> bytes:
payload = bytearray([len(self.profile_identities)])
for identity in self.profile_identities:
payload.extend(identity.to_bytes())
payload.extend((self.active_profiles[identity.to_bytes()], 0))
identity_bytes = identity.to_bytes()
alias = self.profile_aliases.get(identity_bytes, "").encode("utf-8")
payload.extend(identity_bytes)
payload.extend((self.active_profiles[identity_bytes], 0, len(alias)))
payload.extend(alias)
payload.extend(bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(alias)))
return bytes(payload)
def _profile_metadata_payload(self) -> bytes:
identity, _ = self.selected_profile
values = [
self.profile_aliases.get(identity, ""),
*(
self.profile_names.get((identity, index), "")
for index in range(config_manager.PROFILE_CAPACITY)
),
]
payload = bytearray()
for value in values:
encoded = value.encode("utf-8")
payload.extend((len(encoded),))
payload.extend(encoded)
payload.extend(bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(encoded)))
return bytes(payload)
def _profile_transaction_payload(self) -> bytes:
@ -182,8 +214,10 @@ class FakeDevice:
*self.playtest_triggers
)
payload[38] = 1 if self.playtest_motion is not None else 0
payload[39] = self.playtest_battery
payload[40] = self.playtest_capabilities
if self.playtest_motion is not None:
struct.pack_into("<hhhhhh", payload, 40, *self.playtest_motion)
struct.pack_into("<hhhhhh", payload, 42, *self.playtest_motion)
return bytes(payload), flags
def _queue_profile_mutation(self, operation: int, payload: bytes) -> None:
@ -334,6 +368,13 @@ class FakeDevice:
schema=config_manager.PROFILE_PLAYTEST_SCHEMA_VERSION,
generation=self.playtest_state_generation,
)
if request == config_manager.OP_PROFILE_METADATA_READ:
return make_response(
request,
self._profile_metadata_payload(),
schema=config_manager.PROFILE_METADATA_SCHEMA_VERSION,
generation=self.profile_generation,
)
if request == config_manager.OP_PROFILE_TRANSACTION_STATUS:
if self.profile_transaction_status == config_manager.STATUS_PENDING:
if self.profile_transaction_pending_reads:
@ -513,6 +554,26 @@ class FakeDevice:
<= self.profile_transaction_index
< config_manager.PROFILE_CAPACITY
)
elif request == config_manager.OP_PROFILE_METADATA_SET:
self.profile_transaction_id = struct.unpack_from("<I", payload)[0]
identity = payload[4:18]
profile_index = payload[18]
value_size = payload[19]
value = payload[20 : 20 + value_size].decode("utf-8")
assert len(payload) == 20 + value_size
if profile_index == config_manager.PROFILE_NONE_BUTTON:
self.profile_aliases[identity] = value
else:
self.profile_names[(identity, profile_index)] = value
self.profile_transaction_identity = identity
self.profile_transaction_index = profile_index
self.profile_transaction_payload = bytearray()
self.profile_transaction_expected_size = 0
self.profile_transaction_expected_crc = 0
self.profile_generation += 1
self.profile_transaction_status = config_manager.STATUS_OK
elif request == config_manager.OP_PROFILE_IDENTIFY:
self.identified_identities.append(payload)
else:
raise AssertionError(f"unexpected OUT request {request}")
return len(encoded)
@ -1456,6 +1517,32 @@ def test_profile_list_select_read_and_chunked_commit() -> None:
)
def test_profile_metadata_and_identify_round_trip() -> None:
device = FakeDevice()
identity = device.stable_identity
alias_status = config_manager.set_profile_metadata(
device, identity, config_manager.PROFILE_NONE_BUTTON,
"Desk pad", 1.0,
)
name_status = config_manager.set_profile_metadata(
device, identity, 7, "Desktop", 1.0,
)
metadata = config_manager.read_profile_metadata(device, identity, 7)
assert alias_status.status == config_manager.STATUS_OK
assert name_status.stored_generation == alias_status.stored_generation + 1
assert metadata.alias == "Desk pad"
assert metadata.profile_names[7] == "Desktop"
assert config_manager.list_profiles(device)[1].alias == "Desk pad"
config_manager.identify_controller(device, identity)
assert device.identified_identities == [identity.to_bytes()]
with pytest.raises(
config_manager.ConfigManagerError,
match="no controller to identify",
):
config_manager.identify_controller(device, device.global_identity)
def test_profile_playtest_decodes_raw_controller_state() -> None:
device = FakeDevice()
playtest = config_manager.read_profile_playtest(device)
@ -1469,6 +1556,8 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
left_stick=(-1234, 2345),
right_stick=(-30000, 30000),
triggers=(123, 65000),
battery=251,
capabilities=0x0F,
motion=(1, -2, 3, -4, 5, -6),
)
assert playtest.to_json_object()["buttons"] == [
@ -1489,6 +1578,8 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
left_stick=(0, 0),
right_stick=(0, 0),
triggers=(0, 0),
battery=0,
capabilities=0,
motion=None,
)
device.playtest_connected = True

View file

@ -188,6 +188,10 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
]
assert playtest["left_stick"] == {"x": -1234, "y": 2345}
assert playtest["triggers"] == {"left": 123, "right": 65000}
assert playtest["battery"] == 100
assert playtest["capabilities"] == [
"rumble", "lightbar", "player_leds", "motion"
]
status, selected = request_json(f"{base_url}/api/profiles/1/8")
assert status == 200
@ -209,6 +213,53 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
== custom_profile()
)
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
status, renamed = request_json(
f"{base_url}/api/profiles/1/8/name",
method="PUT",
value={"value": "Desktop"},
token=token,
)
assert status == 200
assert renamed["stored_generation"] == 9
assert device.profile_names[
(device.stable_identity.to_bytes(), 7)
] == "Desktop"
status, aliased = request_json(
f"{base_url}/api/identities/1/alias",
method="PUT",
value={"value": "Desk pad"},
token=token,
)
assert status == 200
assert aliased["label"] == "Desk pad"
status, identified = request_json(
f"{base_url}/api/identities/1/identify",
method="POST",
token=token,
)
assert status == 200
assert identified == {"identified": True}
assert device.identified_identities == [
device.stable_identity.to_bytes()
]
status, copied = request_json(
f"{base_url}/api/profiles/1/8/copy",
method="POST",
value={"identity_index": 1, "profile_number": 4},
token=token,
)
assert status == 200
assert copied["stored_generation"] == 12
assert device.profiles[
(device.stable_identity.to_bytes(), 3)
] == custom_profile().to_bytes()
assert device.profile_names[
(device.stable_identity.to_bytes(), 3)
] == "Desktop"
status, activated = request_json(
f"{base_url}/api/profiles/1/8/activate",
@ -217,7 +268,7 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
)
assert status == 200
assert activated["stored_generation"] == 9
assert activated["stored_generation"] == 13
assert device.active_profiles[device.stable_identity.to_bytes()] == 7

View file

@ -14,6 +14,7 @@ ConfigurationServiceSnapshot current_configuration{};
ProfileServiceListSnapshot current_profile_list{};
ProfileServiceSelectedSnapshot current_profile_selected{};
ProfileServiceTransactionSnapshot current_profile_transaction{};
ProfileServiceMetadataSnapshot current_profile_metadata{};
Bluepad32PlaytestSnapshot current_playtest[
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe;
@ -52,6 +53,11 @@ uint32_t profile_reset_transaction_id = 0;
uint32_t profile_commit_transaction_id = 0;
bool profile_activate_requested = false;
uint32_t profile_activate_transaction_id = 0;
bool profile_metadata_requested = false;
uint32_t profile_metadata_transaction_id = 0;
uint8_t profile_metadata_index = 0;
std::string profile_metadata_value;
bool identify_requested = false;
void require(bool condition, const char* message) {
if (!condition) {
@ -454,12 +460,12 @@ void test_profile_vendor_requests() {
Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize);
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() == kResponseHeaderSize + 33 &&
control_payload.size() == kResponseHeaderSize + 97 &&
control_payload[5] ==
static_cast<uint8_t>(Operation::kProfileList) &&
control_payload[10] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
control_payload[kResponseHeaderSize] == 2 &&
control_payload[kResponseHeaderSize + 31] == 2,
control_payload[kResponseHeaderSize + 63] == 2,
"profile list response was not encoded");
current_profile_selected = {};
@ -502,6 +508,8 @@ void test_profile_vendor_requests() {
current_playtest[2].state.right_stick_y = INT16_MAX;
current_playtest[2].state.left_trigger = 123;
current_playtest[2].state.right_trigger = 65000;
current_playtest[2].battery = 201;
current_playtest[2].capabilities = 0x0f;
current_playtest[2].state.motion_sample_count = 1;
current_playtest[2].state.motion_samples[0] =
{1, -2, 3, -4, 5, -6};
@ -531,7 +539,9 @@ void test_profile_vendor_requests() {
read_u16(control_payload, kResponseHeaderSize + 36) ==
65000 &&
static_cast<int16_t>(read_u16(
control_payload, kResponseHeaderSize + 50)) == -6,
control_payload, kResponseHeaderSize + 52)) == -6 &&
control_payload[kResponseHeaderSize + 39] == 201 &&
control_payload[kResponseHeaderSize + 40] == 0x0f,
"profile playtest response lost live controller state");
current_playtest[2].active = false;
require(usb_configuration_management_vendor_control(
@ -539,6 +549,52 @@ void test_profile_vendor_requests() {
control_payload[kResponseHeaderSize] == 0 &&
control_payload[kResponseHeaderSize + 1] == 0xff,
"disconnected profile playtest was not encoded");
current_profile_metadata = {};
current_profile_metadata.metadata.state = ProfileServiceState::kReady;
current_profile_metadata.metadata.generation = 10;
current_profile_metadata.status =
ConfigurationTransactionStatus::kCommitted;
current_profile_metadata.valid = true;
memcpy(current_profile_metadata.alias, "Desk pad", 9);
memcpy(current_profile_metadata.profile_names[7], "Desktop", 8);
request = setup_request(
Operation::kProfileMetadataRead, TUSB_DIR_IN,
kMaximumResponseSize);
require(usb_configuration_management_vendor_control(
0, CONTROL_STAGE_SETUP, &request) &&
control_payload.size() ==
kResponseHeaderSize + kProfileMetadataPayloadSize &&
control_payload[kResponseHeaderSize] == 8 &&
memcmp(&control_payload[kResponseHeaderSize + 1],
"Desk pad", 8) == 0 &&
control_payload[
kResponseHeaderSize +
8 * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1)] == 7,
"profile metadata response was not encoded");
std::vector<uint8_t> metadata(27);
write_u32(&metadata, 0, 0x12345678);
require(controller_identity_encode(
expected_identity, &metadata[4],
CONTROLLER_IDENTITY_ENCODED_SIZE),
"metadata identity did not encode");
metadata[18] = 7;
metadata[19] = 7;
memcpy(&metadata[20], "Desktop", 7);
perform_out(Operation::kProfileMetadataSet, metadata);
require(profile_metadata_requested &&
profile_metadata_transaction_id == 0x12345678 &&
profile_metadata_index == 7 &&
profile_metadata_value == "Desktop",
"profile metadata mutation was not dispatched");
std::vector<uint8_t> identify(CONTROLLER_IDENTITY_ENCODED_SIZE);
require(controller_identity_encode(
expected_identity, identify.data(), identify.size()),
"identify identity did not encode");
perform_out(Operation::kProfileIdentify, identify);
require(identify_requested,
"controller Identify request was not dispatched");
current_profile_transaction = {};
current_profile_transaction.metadata.state =
@ -735,6 +791,17 @@ ConfigurationTransactionStatus profile_service_select(
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_set_metadata(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile, const char* value, size_t value_size) {
profile_metadata_requested = true;
profile_metadata_transaction_id = transaction_id;
profile_identity = identity;
profile_metadata_index = selected_profile;
profile_metadata_value.assign(value, value + value_size);
return ConfigurationTransactionStatus::kPending;
}
ConfigurationTransactionStatus profile_service_begin(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile, uint16_t schema_version,
@ -763,6 +830,17 @@ ConfigurationTransactionStatus profile_service_commit(
return ConfigurationTransactionStatus::kPending;
}
void profile_service_metadata_snapshot(
ProfileServiceMetadataSnapshot* output) {
*output = current_profile_metadata;
}
bool bluepad32_input_backend_identify(
const ControllerIdentity&) {
identify_requested = true;
return true;
}
ConfigurationTransactionStatus profile_service_reset(
uint32_t transaction_id, const ControllerIdentity& identity,
uint8_t selected_profile) {