From 8818f1f6e98e386b40742a6b32e98a23dd201075 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 4 Sep 2026 21:49:50 -0600 Subject: [PATCH] Add Profile Studio Set A enhancements --- ADAPTER_PARITY_PLAN.md | 78 +++- README.md | 20 +- .../input/bluepad32_input_backend.cpp | 39 ++ src/firmware/input/bluepad32_input_backend.h | 4 + src/firmware/profile/profile_service.cpp | 102 ++++- src/firmware/profile/profile_service.h | 18 + src/firmware/profile/profile_storage.cpp | 274 +++++++++++-- src/firmware/profile/profile_storage.h | 32 ++ .../usb/usb_configuration_management.cpp | 133 +++++-- .../usb/usb_configuration_management.h | 22 +- src/switch_pico_bridge/config_manager.py | 171 +++++++- src/switch_pico_bridge/profile_web.py | 178 ++++++++- src/switch_pico_bridge/web/profile_editor.css | 31 ++ .../web/profile_editor.html | 41 +- src/switch_pico_bridge/web/profile_editor.js | 372 +++++++++++++++++- tests/bluepad32_backend_lifecycle_test.cpp | 20 +- tests/bluepad32_native_stubs/uni.h | 2 + tests/profile_service_test.cpp | 36 +- tests/profile_storage_test.cpp | 56 ++- tests/test_config_manager.py | 97 ++++- tests/test_profile_web.py | 53 ++- tests/usb_configuration_management_test.cpp | 84 +++- 22 files changed, 1745 insertions(+), 118 deletions(-) diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index 3f7c6f3..798e001 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -585,10 +585,10 @@ Storage design: - decode only the fallback and active profile for each observed identity - keep report-path profile access allocation-free with bounded snapshots -The completed AIO image uses 683,128 bytes of 4 MiB flash and reserves 256 +The completed AIO image uses 695,592 bytes of 4 MiB flash and reserves 256 KiB for the two profile arenas. Total flash use plus configuration, bonds, -and the RP2350 terminal sector is 965,752 bytes (23.03%). Linked SRAM is -97,600 of 532,480 bytes; the profile catalog index is 1,556 bytes. +and the RP2350 terminal sector is 978,216 bytes (23.32%). Linked SRAM is +99,040 of 532,480 bytes; the profile catalog index is 1,828 bytes. Acceptance: @@ -613,6 +613,78 @@ Completion evidence: - the browser editor renders and selects all eight slots - Pico 2 W hardware read and activated profile 8, then restored profile 1 +### Candidate profile and Profile Studio enhancements — Set A complete + +The live playtest, eight-slot catalog, controller-native labels, automatic +active-profile synchronization, and all Set A usability work are complete. +Set B and Set C remain candidates; their order reflects user value and +dependency, not a commitment to implement every item. + +| Priority | Candidate | Intended scope | Dependency or principal risk | +|---|---|---|---| +| A1 — Complete | Named and copyable profiles | Store a short profile name; rename, duplicate to another slot or controller, expose JSON import/export in the browser, and reset one section without replacing the entire profile. Names use catalog metadata records, so the 256-byte input profile remains stable. | Implemented with atomic metadata records and existing profile transactions; no report-path cost. | +| A2 — Complete | Visual response-curve editor | Replace raw `curve_q8_8` as the primary control with a graph, named presets, fine adjustment, live raw/output markers, and “apply to other side” for sticks or triggers. Retain the exact fixed-point value as the wire representation. | Implemented entirely in Profile Studio; existing profiles round-trip unchanged. | +| A3 — Complete | Controller aliases and Identify action | Allow names such as “Living-room DualSense”; show battery, transport, and capabilities as secondary details; provide an Identify button that briefly rumbles or lights only the selected live controller. | Implemented with catalog alias records, capability-gated live telemetry, and a bounded non-persistent Identify command. | +| B1 | Direct profile shortcuts | Assign a modifier plus face button or D-pad direction to select profiles 1–8 directly while retaining the existing cycle chord. Publish feedback only after the activation record commits. | Profile schema/runtime change; chord precedence and consumption must remain deterministic. | +| B2 | One Shift layer per profile | While a configured modifier is held or toggled, apply one alternate button map over the base profile. Start with buttons only; do not layer analog transforms, Turbo, or macros initially. | Profile schema change and explicit precedence between base mapping, Shift, Turbo, and macros. | +| B3 | Macro authoring tools | Record live controller input, insert, duplicate, remove, and drag-reorder steps; preview playback; show time and sparse-byte cost on every edit. Later add once, while-held, toggle, and bounded-repeat playback modes. | Editing tools fit the current format; new playback modes require schema/runtime work and must remain within the shared 16-step/136-byte budget. | +| B4 | Configurable Turbo and finite Burst | Add a bounded repeat rate, duty cycle, and finite Burst count while retaining hold Turbo and Auto Burst. Support shared defaults with optional per-button overrides rather than duplicating full settings sixteen times. | Profile schema/runtime change; scheduling must preserve the existing deterministic phase accumulator. | +| C1 | Motion calibration and tuning | Expose live gyro/accelerometer values, bias calibration, axis orientation/inversion, sensitivity, drift threshold, smoothing, and hold/toggle activation. Preserve native Switch motion units rather than introducing gyro-to-stick emulation first. | Controller-specific validation and physical motion testing; filters must not add report latency. | +| C2 | Feedback preview and profile lighting | Add non-persistent weak/strong rumble tests, profile-switch preview, and player LED/lightbar preview. Optionally persist an RGB profile color where the controller supports it. | New bounded management command; unsupported output capabilities must be visibly disabled. | + +Set A completion evidence: + +- profile names and controller aliases survive catalog reload, interrupted + writes, and arena compaction without changing the 256-byte profile schema +- profiles copy across identities and slots through existing validated atomic + transactions; browser JSON import/export and per-section resets operate on + unsaved drafts +- four response-curve graphs expose named presets, exact Q8.8 fine adjustment, + live curve markers, and one-click linked-side application +- live controller details expose transport, normalized battery percentage, and + rumble/lightbar/player-LED/motion capabilities +- Identify queues one bounded feedback pulse only for the matching live stable + identity + +Recommended delivery order: + +1. A1 profile metadata, duplication, and browser backup controls. +2. A2 curve visualization and linked-side editing. +3. A3 controller aliases, battery/capability details, and Identify. +4. B1 direct profile shortcuts. +5. B3 macro recording and editing before adding new playback semantics. +6. B2 one bounded Shift layer. +7. B4 configurable Turbo and finite Burst. +8. C1 motion calibration. +9. C2 feedback preview and custom lighting. + +Design constraints: + +- Do not add automatic per-game switching on console paths; the Pico cannot + reliably observe the active game. +- Do not add arbitrary scripts or an unbounded stack of action layers. +- Keep every report-path operation allocation-free and bounded. +- Keep live telemetry read-only, non-overlapping, and paused while the editor + is hidden. +- Preserve unsaved drafts across metadata refreshes and transient USB + disconnects. +- Gate every controller-specific input or output by reported capabilities. +- Add a new profile schema only when persistent runtime behavior changes; + UI-only presets and catalog metadata must not churn the profile wire format. + +Research basis: + +- 8BitDo Ultimate Software: mapping, stick/trigger tuning, vibration, macros, + profiles, and Turbo/Burst modes — https://support.8bitdo.com/ultimate/pro2.html +- DualSense Edge profiles: names, copies, direct shortcuts, curve presets, + linked trigger settings, and live playtest — + https://www.playstation.com/en-us/support/hardware/set-up-edge-controller/ +- Steam Input action-set layers: temporary mapping overlays and precedence + risks — + https://partner.steamgames.com/doc/features/steam_controller/action_set_layers +- Xbox Elite Shift: modifier-driven alternate mappings — + https://support.xbox.com/en-US/help/hardware-network/controller/shift-elite-series-2 + ### Phase 8 — Performance and release qualification Measure: diff --git a/README.md b/README.md index a4fb6d0..e24d8c0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,16 @@ Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and ` The editor selects Switch Pro, DualSense, or Xbox artwork from the connected controller's USB VID/PID and places each remappable control directly over the matching physical button. Controller artwork is from [AL2009man/Gamepad-Asset-Pack](https://github.com/AL2009man/Gamepad-Asset-Pack) under its MIT license; the bundled license and source revision are recorded beside the assets. +Profile names and controller aliases are stored as independently checksummed +catalog metadata, so naming does not change the 256-byte profile format. The +editor can rename and copy profiles across controllers and slots, import or +export JSON backups, and reset one section without discarding the rest of the +draft. Its response-curve cards provide named presets, exact Q8.8 fine +adjustment, live curve markers, and one-click application to the opposite +stick or trigger. Connected-controller details include transport, battery, +and supported feedback/motion capabilities; **Identify** sends one bounded +rumble/light pulse only to the selected live controller. + `profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns eight persistent profiles and one active index. The JSON export/import commands remain available for version-controlled or scripted profiles. Profile numbers shown to users are `1` through `8`; `--identity` uses the zero-based index from `profiles list`. `pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. @@ -601,16 +611,16 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file: | Resource | Used or reserved | Device capacity | |---|---:|---:| -| Executable flash image | 683,128 bytes | 4 MiB | +| Executable flash image | 695,592 bytes | 4 MiB | | Indexed profile arenas | 256 KiB | 4 MiB flash | | Adapter configuration | 8 KiB | 4 MiB flash | | BTstack bonds | 8 KiB | 4 MiB flash | | RP2350 terminal sector | 4 KiB | 4 MiB flash | -| Linked SRAM | 97,600 bytes | 520 KiB | +| Linked SRAM | 99,040 bytes | 520 KiB | -The executable plus persistent reservations consume 965,752 bytes (23.03%) -of flash, leaving 3,228,552 bytes (3.08 MiB). Linked SRAM consumes 18.33%, -leaving 434,880 bytes of link-time headroom. +The executable plus persistent reservations consume 978,216 bytes (23.32%) +of flash, leaving 3,216,088 bytes (3.07 MiB). Linked SRAM consumes 18.60%, +leaving 433,440 bytes of link-time headroom. Profiles use two 128 KiB append-only arenas. Each independently published record contains one identity/profile key, generation, schema, length, and CRC. diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 9006a93..0fcc3ba 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -1906,6 +1906,14 @@ void bluepad32_input_backend_playtest_snapshot( out->identity = slot.identity; out->physical_button_mask = slot.pre_hotkey_button_mask; out->state = slot.state; + if (slot.device != nullptr) { + out->battery = slot.device->controller.battery; + out->capabilities = + (slot.device->report_parser.play_dual_rumble != nullptr ? 1u : 0u) | + (slot.device->report_parser.set_lightbar_color != nullptr ? 2u : 0u) | + (slot.device->report_parser.set_player_leds != nullptr ? 4u : 0u) | + (slot.state.motion_sample_count != 0 ? 8u : 0u); + } critical_section_exit(&g_state_lock); } @@ -1972,6 +1980,37 @@ void bluepad32_input_backend_queue_rumble( critical_section_exit(&g_state_lock); } +bool bluepad32_input_backend_identify( + const ControllerIdentity& identity) { + if (!g_initialized || !identity.stable || + controller_identity_is_global(identity)) { + return false; + } + bool queued = false; + critical_section_enter_blocking(&g_state_lock); + for (BackendSlot& slot : g_slots) { + if (!slot.active || + !controller_identity_equal(slot.identity, identity)) { + continue; + } + const ProfileFeedbackEnvelope feedback{ + slot.connection_generation, 1, + ControllerProfileConfirmationPolicy::kRumbleAndLed}; + if (slot.pending_profile_feedback_count < + kProfileFeedbackQueueCapacity) { + slot.pending_profile_feedback[ + slot.pending_profile_feedback_count++] = feedback; + } else { + slot.pending_profile_feedback[ + kProfileFeedbackQueueCapacity - 1u] = feedback; + } + queued = true; + break; + } + critical_section_exit(&g_state_lock); + return queued; +} + void bluepad32_input_backend_queue_profile_feedback( uint8_t slot_index, uint32_t connection_generation, uint8_t active_profile_number, diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index f96ca9b..60dad7e 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -70,6 +70,8 @@ struct Bluepad32PlaytestSnapshot { uint32_t state_generation = 0; ControllerIdentity identity{}; uint16_t physical_button_mask = 0; + uint8_t battery = 0; + uint8_t capabilities = 0; ControllerState state{}; }; @@ -111,6 +113,8 @@ bool bluepad32_input_backend_toggle_motion( uint8_t slot, uint32_t connection_generation); void bluepad32_input_backend_queue_rumble( uint8_t slot, const ControllerRumbleOutput& rumble); +bool bluepad32_input_backend_identify( + const ControllerIdentity& identity); // Enqueue bounded local profile confirmation for the matching live connection // generation. The two-entry per-slot FIFO preserves initial-then-switch // ordering. Profile lighting is transient and restored to the steady slot diff --git a/src/firmware/profile/profile_service.cpp b/src/firmware/profile/profile_service.cpp index 821b7be..078c732 100644 --- a/src/firmware/profile/profile_service.cpp +++ b/src/firmware/profile/profile_service.cpp @@ -9,11 +9,15 @@ namespace { constexpr uint32_t kMinimumCommitIntervalMs = 1000; constexpr uint32_t kInternalTransactionIdMask = 0x80000000u; +static_assert(PROFILE_SERVICE_METADATA_MAX_BYTES == + PROFILE_STORAGE_METADATA_MAX_BYTES); enum class PendingCommandType : uint8_t { kNone = 0, kReset = 1, kActivate = 2, + kSetAlias = 3, + kSetProfileName = 4, }; struct PendingCommand { @@ -21,6 +25,8 @@ struct PendingCommand { uint32_t transaction_id = 0; ControllerIdentity identity{}; uint8_t profile_index = 0; + uint8_t value_size = 0; + char value[PROFILE_SERVICE_METADATA_MAX_BYTES]{}; }; struct ProfileTransaction { @@ -68,6 +74,11 @@ void refresh_list_locked() { continue; } ProfileServiceListRow &row = g_list.rows[g_list.count++]; + if (g_storage.get_alias( + entry->identity, row.alias, + sizeof(row.alias)) != ProfileStorageResult::kOk) { + row.alias[0] = '\0'; + } row.identity = entry->identity; row.active_profile = entry->active_profile; } @@ -268,8 +279,14 @@ void profile_service_task_on_storage_core(uint32_t now_ms) { } else if (command.type == PendingCommandType::kActivate) { storage_result = g_storage.activate(command.identity, command.profile_index); + } else if (command.type == PendingCommandType::kSetAlias) { + storage_result = g_storage.set_alias( + command.identity, command.value, command.value_size); + } else if (command.type == PendingCommandType::kSetProfileName) { + storage_result = g_storage.set_profile_name( + command.identity, command.profile_index, + command.value, command.value_size); } - const ConfigurationTransactionStatus status = storage_result_status(storage_result); if (status == ConfigurationTransactionStatus::kCommitted) { @@ -495,6 +512,58 @@ profile_service_activate(uint32_t transaction_id, return ConfigurationTransactionStatus::kPending; } +ConfigurationTransactionStatus profile_service_set_metadata( + uint32_t transaction_id, const ControllerIdentity &identity, + uint8_t profile_index, const char *value, size_t value_size) { + if (!g_prepared) { + profile_service_prepare(); + } + critical_section_enter_blocking(&g_lock); + if (g_command.type != PendingCommandType::kNone || + g_internal_activation.type != PendingCommandType::kNone || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kReceiving || + g_transaction.snapshot.status == + ConfigurationTransactionStatus::kPending) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + g_transaction = {}; + g_transaction.snapshot.transaction_id = transaction_id; + g_transaction.identity = identity; + g_transaction.profile_index = profile_index; + bool malformed = + transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT) || + value_size > PROFILE_SERVICE_METADATA_MAX_BYTES || + (value_size != 0 && value == nullptr); + for (size_t index = 0; index < value_size && !malformed; ++index) { + malformed = value[index] == '\0'; + } + if (malformed) { + g_transaction.snapshot.status = + ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_transaction.snapshot.status = ConfigurationTransactionStatus::kPending; + g_command.transaction_id = transaction_id; + g_command.type = profile_index == CONTROLLER_PROFILE_ALL + ? PendingCommandType::kSetAlias + : PendingCommandType::kSetProfileName; + g_command.identity = identity; + g_command.profile_index = profile_index; + g_command.value_size = static_cast(value_size); + if (value_size != 0) { + memcpy(g_command.value, value, value_size); + } + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; +} + ConfigurationTransactionStatus profile_service_activate_internal(uint32_t transaction_id, const ControllerIdentity &identity, @@ -543,6 +612,37 @@ void profile_service_selected_snapshot(ProfileServiceSelectedSnapshot *output) { critical_section_exit(&g_lock); } +void profile_service_metadata_snapshot( + ProfileServiceMetadataSnapshot *output) { + if (output == nullptr) { + return; + } + *output = {}; + critical_section_enter_blocking(&g_lock); + output->metadata = g_metadata; + output->identity = g_selected.identity; + output->status = g_selected.status; + bool valid = + g_metadata.state == ProfileServiceState::kReady && + g_storage.get_alias( + g_selected.identity, output->alias, + sizeof(output->alias)) == ProfileStorageResult::kOk; + for (uint8_t profile = 0; + profile < CONTROLLER_PROFILE_COUNT && valid; ++profile) { + valid = g_storage.get_profile_name( + g_selected.identity, profile, + output->profile_names[profile], + sizeof(output->profile_names[profile])) == + ProfileStorageResult::kOk; + } + output->valid = valid; + if (!valid && output->status == + ConfigurationTransactionStatus::kCommitted) { + output->status = ConfigurationTransactionStatus::kStorageError; + } + critical_section_exit(&g_lock); +} + void profile_service_transaction_snapshot( ProfileServiceTransactionSnapshot *output) { if (output == nullptr) { diff --git a/src/firmware/profile/profile_service.h b/src/firmware/profile/profile_service.h index 704308d..d14aba7 100644 --- a/src/firmware/profile/profile_service.h +++ b/src/firmware/profile/profile_service.h @@ -8,6 +8,7 @@ constexpr uint8_t PROFILE_SERVICE_LIST_CAPACITY = CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1; +constexpr size_t PROFILE_SERVICE_METADATA_MAX_BYTES = 31; enum class ProfileServiceState : uint8_t { kLoading = 0, @@ -23,6 +24,7 @@ struct ProfileServiceMetadata { struct ProfileServiceListRow { ControllerIdentity identity{}; + char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{}; uint8_t active_profile = 0; }; @@ -42,6 +44,17 @@ struct ProfileServiceSelectedSnapshot { ControllerProfile profile{}; }; +struct ProfileServiceMetadataSnapshot { + ProfileServiceMetadata metadata{}; + bool valid = false; + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kIdle; + ControllerIdentity identity{}; + char alias[PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{}; + char profile_names[CONTROLLER_PROFILE_COUNT] + [PROFILE_SERVICE_METADATA_MAX_BYTES + 1]{}; +}; + struct ProfileServiceActiveProfileSnapshot { ProfileServiceMetadata metadata{}; bool valid = false; @@ -79,6 +92,9 @@ ConfigurationTransactionStatus profile_service_reset( ConfigurationTransactionStatus profile_service_activate( uint32_t transaction_id, const ControllerIdentity& identity, uint8_t profile_index); +ConfigurationTransactionStatus profile_service_set_metadata( + uint32_t transaction_id, const ControllerIdentity& identity, + uint8_t profile_index, const char* value, size_t value_size); // Queue a controller-originated activation without replacing the host-visible // transaction snapshot. transaction_id must be nonzero with its high bit set. ConfigurationTransactionStatus profile_service_activate_internal( @@ -88,6 +104,8 @@ ConfigurationTransactionStatus profile_service_activate_internal( void profile_service_list_snapshot(ProfileServiceListSnapshot* output); void profile_service_selected_snapshot( ProfileServiceSelectedSnapshot* output); +void profile_service_metadata_snapshot( + ProfileServiceMetadataSnapshot* output); void profile_service_transaction_snapshot( ProfileServiceTransactionSnapshot* output); uint32_t profile_service_database_generation(); diff --git a/src/firmware/profile/profile_storage.cpp b/src/firmware/profile/profile_storage.cpp index 39a57af..9d0a16a 100644 --- a/src/firmware/profile/profile_storage.cpp +++ b/src/firmware/profile/profile_storage.cpp @@ -79,6 +79,13 @@ bool bytes_are(uint8_t value, const uint8_t *data, size_t size) { return true; } +bool metadata_value_valid(const uint8_t *payload, size_t size) { + return payload != nullptr && size == PROFILE_STORAGE_METADATA_PAYLOAD_SIZE && + payload[0] <= PROFILE_STORAGE_METADATA_MAX_BYTES && + bytes_are(0, &payload[payload[0] + 1], + size - payload[0] - 1); +} + } // namespace uint32_t profile_storage_crc32(const uint8_t *data, size_t size) { @@ -257,6 +264,128 @@ ProfileStorage::activate(const ControllerIdentity &identity, return append(RecordType::kActivate, identity, profile_index, nullptr, 0); } +ProfileStorageResult ProfileStorage::get_alias( + const ControllerIdentity &identity, char *output, + size_t output_size) const { + if (!initialized_ || output == nullptr || output_size == 0 || + !valid_identity(identity)) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr || + entry->alias_record == PROFILE_STORAGE_NO_RECORD) { + output[0] = '\0'; + return ProfileStorageResult::kOk; + } + return read_metadata_record(entry->alias_record, output, output_size) + ? ProfileStorageResult::kOk + : ProfileStorageResult::kIoError; +} + +ProfileStorageResult ProfileStorage::set_alias( + const ControllerIdentity &identity, const char *value, + size_t value_size) { + if (!initialized_ || !valid_identity(identity) || + value_size > PROFILE_STORAGE_METADATA_MAX_BYTES || + (value_size != 0 && value == nullptr)) { + return ProfileStorageResult::kInvalidArgument; + } + char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + if (find(identity) == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + if (get_alias(identity, current, sizeof(current)) == + ProfileStorageResult::kOk && + strlen(current) == value_size && + (value_size == 0 || memcmp(current, value, value_size) == 0)) { + return ProfileStorageResult::kUnchanged; + } + uint8_t payload[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + payload[0] = static_cast(value_size); + if (value_size != 0) { + memcpy(&payload[1], value, value_size); + } + return append(RecordType::kAlias, identity, CONTROLLER_PROFILE_ALL, + payload, sizeof(payload)); +} + +ProfileStorageResult ProfileStorage::get_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + char *output, size_t output_size) const { + if (!initialized_ || output == nullptr || output_size == 0 || + !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr || + entry->profile_names_record == PROFILE_STORAGE_NO_RECORD) { + output[0] = '\0'; + return ProfileStorageResult::kOk; + } + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (!io_.read( + io_.context, record_arena(entry->profile_names_record), + record_offset(entry->profile_names_record) + + kRecordPayloadOffset, + payload, sizeof(payload))) { + return ProfileStorageResult::kIoError; + } + const size_t offset = + profile_index * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE; + const size_t size = payload[offset]; + if (size > PROFILE_STORAGE_METADATA_MAX_BYTES || + output_size <= size) { + return ProfileStorageResult::kInvalidArgument; + } + memcpy(output, &payload[offset + 1], size); + output[size] = '\0'; + return ProfileStorageResult::kOk; +} + +ProfileStorageResult ProfileStorage::set_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + const char *value, size_t value_size) { + if (!initialized_ || !valid_identity(identity) || + profile_index >= CONTROLLER_PROFILE_COUNT || + value_size > PROFILE_STORAGE_METADATA_MAX_BYTES || + (value_size != 0 && value == nullptr)) { + return ProfileStorageResult::kInvalidArgument; + } + const ProfileStorageIdentityIndex *entry = find(identity); + if (entry == nullptr && + identity_count_ >= CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) { + return ProfileStorageResult::kFull; + } + char current[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + if (get_profile_name(identity, profile_index, current, + sizeof(current)) == ProfileStorageResult::kOk && + strlen(current) == value_size && + (value_size == 0 || memcmp(current, value, value_size) == 0)) { + return ProfileStorageResult::kUnchanged; + } + uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (entry != nullptr && + entry->profile_names_record != PROFILE_STORAGE_NO_RECORD && + !io_.read( + io_.context, record_arena(entry->profile_names_record), + record_offset(entry->profile_names_record) + + kRecordPayloadOffset, + payload, sizeof(payload))) { + return ProfileStorageResult::kIoError; + } + const size_t offset = + profile_index * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE; + memset(&payload[offset], 0, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE); + payload[offset] = static_cast(value_size); + if (value_size != 0) { + memcpy(&payload[offset + 1], value, value_size); + } + return append(RecordType::kProfileNames, identity, + CONTROLLER_PROFILE_ALL, payload, sizeof(payload)); +} + uint8_t ProfileStorage::identity_count() const { return identity_count_; } const ProfileStorageIdentityIndex * @@ -330,30 +459,59 @@ bool ProfileStorage::scan_arena(uint8_t arena, uint32_t *epoch, const uint32_t record_generation = read_u32(&header[8]); const size_t payload_size = read_u16(&header[12]); ControllerIdentity identity_value{}; + const bool known_type = + type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kResetAll || type == RecordType::kActivate || + type == RecordType::kAlias || + type == RecordType::kProfileNames; + const bool indexed_profile = + type == RecordType::kProfile || type == RecordType::kReset || + type == RecordType::kActivate; + const size_t expected_payload_size = + type == RecordType::kProfile || + type == RecordType::kProfileNames + ? CONTROLLER_PROFILE_ENCODED_SIZE + : type == RecordType::kAlias + ? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE + : 0; if (!controller_identity_decode( &header[20], CONTROLLER_IDENTITY_ENCODED_SIZE, &identity_value) || - !valid_identity(identity_value) || - (type != RecordType::kProfile && type != RecordType::kReset && - type != RecordType::kResetAll && type != RecordType::kActivate) || - ((type == RecordType::kProfile || type == RecordType::kReset || - type == RecordType::kActivate) && - profile_index >= CONTROLLER_PROFILE_COUNT) || - (type == RecordType::kProfile && - payload_size != CONTROLLER_PROFILE_ENCODED_SIZE) || - (type != RecordType::kProfile && payload_size != 0)) { + !valid_identity(identity_value) || !known_type || + (indexed_profile && profile_index >= CONTROLLER_PROFILE_COUNT) || + payload_size != expected_payload_size) { continue; } - if (type == RecordType::kProfile) { + if (payload_size != 0) { uint8_t payload[CONTROLLER_PROFILE_ENCODED_SIZE]{}; - ControllerProfile decoded{}; - if (!io_.read(io_.context, arena, offset + kRecordPayloadOffset, payload, - sizeof(payload)) || - read_u16(&header[14]) != read_u16(payload) || - profile_storage_crc32(payload, sizeof(payload)) != - read_u32(&header[16]) || - !controller_profile_decode(payload, sizeof(payload), &decoded)) { + if (!io_.read(io_.context, arena, offset + kRecordPayloadOffset, + payload, payload_size) || + profile_storage_crc32(payload, payload_size) != + read_u32(&header[16])) { continue; } + if (type == RecordType::kProfile) { + ControllerProfile decoded{}; + if (read_u16(&header[14]) != read_u16(payload) || + !controller_profile_decode(payload, payload_size, &decoded)) { + continue; + } + } else if (type == RecordType::kAlias) { + if (read_u16(&header[14]) != 0 || + !metadata_value_valid(payload, payload_size)) { + continue; + } + } else { + bool valid_names = read_u16(&header[14]) == 0; + for (size_t name = 0; + name < CONTROLLER_PROFILE_COUNT && valid_names; ++name) { + valid_names = metadata_value_valid( + &payload[name * PROFILE_STORAGE_METADATA_PAYLOAD_SIZE], + PROFILE_STORAGE_METADATA_PAYLOAD_SIZE); + } + if (!valid_names) { + continue; + } + } } else if (read_u16(&header[14]) != 0) { continue; } @@ -377,6 +535,23 @@ bool ProfileStorage::read_profile_record(uint32_t record, controller_profile_decode(encoded, sizeof(encoded), output); } +bool ProfileStorage::read_metadata_record( + uint32_t record, char *output, size_t output_size) const { + uint8_t payload[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{}; + if (record == PROFILE_STORAGE_NO_RECORD || + !io_.read( + io_.context, record_arena(record), + record_offset(record) + kRecordPayloadOffset, + payload, sizeof(payload)) || + payload[0] > PROFILE_STORAGE_METADATA_MAX_BYTES || + output_size <= payload[0]) { + return false; + } + memcpy(output, &payload[1], payload[0]); + output[payload[0]] = '\0'; + return true; +} + ProfileStorageResult ProfileStorage::append(RecordType type, const ControllerIdentity &identity, uint8_t profile_index, @@ -426,6 +601,34 @@ ProfileStorageResult ProfileStorage::compact() { } offset += PROFILE_STORAGE_RECORD_SIZE; } + uint8_t metadata[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + if (entry.alias_record != PROFILE_STORAGE_NO_RECORD) { + if (!io_.read( + io_.context, record_arena(entry.alias_record), + record_offset(entry.alias_record) + kRecordPayloadOffset, + metadata, PROFILE_STORAGE_METADATA_PAYLOAD_SIZE) || + !write_record( + target, offset, RecordType::kAlias, entry.identity, + CONTROLLER_PROFILE_ALL, ++generation, metadata, + PROFILE_STORAGE_METADATA_PAYLOAD_SIZE)) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } + if (entry.profile_names_record != PROFILE_STORAGE_NO_RECORD) { + if (!io_.read( + io_.context, record_arena(entry.profile_names_record), + record_offset(entry.profile_names_record) + + kRecordPayloadOffset, + metadata, CONTROLLER_PROFILE_ENCODED_SIZE) || + !write_record( + target, offset, RecordType::kProfileNames, + entry.identity, CONTROLLER_PROFILE_ALL, ++generation, + metadata, CONTROLLER_PROFILE_ENCODED_SIZE)) { + return ProfileStorageResult::kIoError; + } + offset += PROFILE_STORAGE_RECORD_SIZE; + } if (!write_record(target, offset, RecordType::kActivate, entry.identity, entry.active_profile, ++generation, nullptr, 0)) { return ProfileStorageResult::kIoError; @@ -670,19 +873,27 @@ bool ProfileStorage::write_record(uint8_t arena, size_t offset, RecordType type, uint8_t profile_index, uint32_t generation, const uint8_t *payload, size_t payload_size) const { + const size_t expected_payload_size = + type == RecordType::kProfile || + type == RecordType::kProfileNames + ? CONTROLLER_PROFILE_ENCODED_SIZE + : type == RecordType::kAlias + ? PROFILE_STORAGE_METADATA_PAYLOAD_SIZE + : 0; if (arena >= PROFILE_STORAGE_ARENA_COUNT || offset < PROFILE_STORAGE_RECORDS_OFFSET || offset + PROFILE_STORAGE_RECORD_SIZE > PROFILE_STORAGE_ARENA_SIZE || offset % PROFILE_STORAGE_PAGE_SIZE != 0 || - (type == RecordType::kProfile - ? payload == nullptr || - payload_size != CONTROLLER_PROFILE_ENCODED_SIZE - : payload_size != 0)) { + payload_size != expected_payload_size || + (payload_size != 0 && payload == nullptr)) { return false; } if (payload_size != 0) { - if (!io_.program_page(io_.context, arena, offset + kRecordPayloadOffset, - payload, PROFILE_STORAGE_PAGE_SIZE)) { + uint8_t page[PROFILE_STORAGE_PAGE_SIZE]{}; + memcpy(page, payload, payload_size); + if (!io_.program_page(io_.context, arena, + offset + kRecordPayloadOffset, + page, sizeof(page))) { return false; } } @@ -737,6 +948,23 @@ void ProfileStorage::apply_record(ProfileStorageIdentityIndex *index, } return; } + if (type == RecordType::kAlias) { + if (entry->alias_generation == 0 || + generation_is_newer(generation, entry->alias_generation)) { + entry->alias_record = record; + entry->alias_generation = generation; + } + return; + } + if (type == RecordType::kProfileNames) { + if (entry->profile_names_generation == 0 || + generation_is_newer( + generation, entry->profile_names_generation)) { + entry->profile_names_record = record; + entry->profile_names_generation = generation; + } + return; + } if (type == RecordType::kResetAll) { for (uint8_t profile = 0; profile < CONTROLLER_PROFILE_COUNT; ++profile) { if (entry->profile_generation[profile] == 0 || diff --git a/src/firmware/profile/profile_storage.h b/src/firmware/profile/profile_storage.h index 1f204f3..e480262 100644 --- a/src/firmware/profile/profile_storage.h +++ b/src/firmware/profile/profile_storage.h @@ -15,6 +15,18 @@ constexpr size_t PROFILE_STORAGE_SUPERBLOCK_SIZE = PROFILE_STORAGE_PAGE_SIZE; constexpr size_t PROFILE_STORAGE_RECORD_SIZE = 2 * PROFILE_STORAGE_PAGE_SIZE; constexpr size_t PROFILE_STORAGE_RECORDS_OFFSET = PROFILE_STORAGE_SECTOR_SIZE; constexpr uint32_t PROFILE_STORAGE_NO_RECORD = UINT32_MAX; +constexpr uint8_t PROFILE_STORAGE_METADATA_MAX_BYTES = 31; +constexpr size_t PROFILE_STORAGE_METADATA_PAYLOAD_SIZE = + PROFILE_STORAGE_METADATA_MAX_BYTES + 1; +constexpr size_t PROFILE_STORAGE_MAX_LIVE_RECORDS = + (CONTROLLER_PROFILE_STABLE_IDENTITY_CAPACITY + 1) * + (CONTROLLER_PROFILE_COUNT + 3); +constexpr size_t PROFILE_STORAGE_RECORD_CAPACITY = + (PROFILE_STORAGE_ARENA_SIZE - PROFILE_STORAGE_RECORDS_OFFSET) / + PROFILE_STORAGE_RECORD_SIZE; +static_assert(PROFILE_STORAGE_MAX_LIVE_RECORDS <= + PROFILE_STORAGE_RECORD_CAPACITY, + "all live profile records must fit during compaction"); // Layout of the retired v1/v2 whole-database store. The indexed catalog reads // this region once during migration; new firmware never writes it. @@ -64,6 +76,10 @@ struct ProfileStorageIdentityIndex { uint32_t active_generation = 0; uint32_t profile_generation[CONTROLLER_PROFILE_COUNT]{}; uint32_t profile_record[CONTROLLER_PROFILE_COUNT]{}; + uint32_t alias_generation = 0; + uint32_t alias_record = PROFILE_STORAGE_NO_RECORD; + uint32_t profile_names_generation = 0; + uint32_t profile_names_record = PROFILE_STORAGE_NO_RECORD; }; uint32_t profile_storage_crc32(const uint8_t *data, size_t size); @@ -82,6 +98,18 @@ public: uint8_t profile_index); ProfileStorageResult activate(const ControllerIdentity &identity, uint8_t profile_index); + ProfileStorageResult get_alias( + const ControllerIdentity &identity, char *output, + size_t output_size) const; + ProfileStorageResult set_alias( + const ControllerIdentity &identity, const char *value, + size_t value_size); + ProfileStorageResult get_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + char *output, size_t output_size) const; + ProfileStorageResult set_profile_name( + const ControllerIdentity &identity, uint8_t profile_index, + const char *value, size_t value_size); uint8_t identity_count() const; const ProfileStorageIdentityIndex *identity(uint8_t index) const; @@ -95,6 +123,8 @@ private: kReset = 2, kResetAll = 3, kActivate = 4, + kAlias = 5, + kProfileNames = 6, }; bool scan_arena(uint8_t arena, uint32_t *epoch, uint32_t *generation, @@ -102,6 +132,8 @@ private: ProfileStorageIdentityIndex *index, uint8_t *identity_count) const; bool read_profile_record(uint32_t record, ControllerProfile *output) const; + bool read_metadata_record(uint32_t record, char *output, + size_t output_size) const; ProfileStorageResult append(RecordType type, const ControllerIdentity &identity, uint8_t profile_index, const uint8_t *payload, diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index c100579..4d3917c 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -100,6 +100,13 @@ bool valid_out_size(Operation operation, size_t size) { case Operation::kProfileReset: case Operation::kProfileActivate: return size == kRequestHeaderSize + 19; + case Operation::kProfileMetadataSet: + return size >= kRequestHeaderSize + 20 && + size <= kRequestHeaderSize + 20 + + PROFILE_SERVICE_METADATA_MAX_BYTES; + case Operation::kProfileIdentify: + return size == kRequestHeaderSize + + CONTROLLER_IDENTITY_ENCODED_SIZE; case Operation::kPairingRefresh: case Operation::kPairingClear: return size == kRequestHeaderSize; @@ -271,15 +278,20 @@ size_t encode_profile_list(const ProfileServiceListSnapshot& snapshot, payload[0] = snapshot.count; size_t offset = 1; for (uint8_t index = 0; index < snapshot.count; ++index) { + const size_t alias_size = strlen(snapshot.rows[index].alias); if (!controller_identity_encode(snapshot.rows[index].identity, &payload[offset], CONTROLLER_IDENTITY_ENCODED_SIZE) || snapshot.rows[index].active_profile >= - CONTROLLER_PROFILE_COUNT) { + CONTROLLER_PROFILE_COUNT || + alias_size > PROFILE_SERVICE_METADATA_MAX_BYTES) { return 0; } payload[offset + 14] = snapshot.rows[index].active_profile; - offset += 16; + payload[offset + 16] = static_cast(alias_size); + memcpy(&payload[offset + 17], snapshot.rows[index].alias, + alias_size); + offset += kProfileListRowSize; } return encode_response( Operation::kProfileList, profile_service_status(snapshot.metadata), @@ -306,38 +318,30 @@ size_t encode_profile_playtest( write_u16(&payload[2], snapshot.physical_button_mask); write_u32(&payload[4], snapshot.connection_generation); write_u32(&payload[8], snapshot.state_generation); - write_u16( - &payload[26], - static_cast(snapshot.state.left_stick_x)); - write_u16( - &payload[28], - static_cast(snapshot.state.left_stick_y)); - write_u16( - &payload[30], - static_cast(snapshot.state.right_stick_x)); - write_u16( - &payload[32], - static_cast(snapshot.state.right_stick_y)); + write_u16(&payload[26], + static_cast(snapshot.state.left_stick_x)); + write_u16(&payload[28], + static_cast(snapshot.state.left_stick_y)); + write_u16(&payload[30], + static_cast(snapshot.state.right_stick_x)); + write_u16(&payload[32], + static_cast(snapshot.state.right_stick_y)); write_u16(&payload[34], snapshot.state.left_trigger); write_u16(&payload[36], snapshot.state.right_trigger); payload[38] = snapshot.state.motion_sample_count; + payload[39] = snapshot.battery; + payload[40] = snapshot.capabilities; if (snapshot.state.motion_sample_count != 0) { payload[0] |= 2; const ControllerMotionSample& motion = snapshot.state.motion_samples[ snapshot.state.motion_sample_count - 1u]; - write_u16(&payload[40], - static_cast(motion.accel_x)); - write_u16(&payload[42], - static_cast(motion.accel_y)); - write_u16(&payload[44], - static_cast(motion.accel_z)); - write_u16(&payload[46], - static_cast(motion.gyro_x)); - write_u16(&payload[48], - static_cast(motion.gyro_y)); - write_u16(&payload[50], - static_cast(motion.gyro_z)); + write_u16(&payload[42], static_cast(motion.accel_x)); + write_u16(&payload[44], static_cast(motion.accel_y)); + write_u16(&payload[46], static_cast(motion.accel_z)); + write_u16(&payload[48], static_cast(motion.gyro_x)); + write_u16(&payload[50], static_cast(motion.gyro_y)); + write_u16(&payload[52], static_cast(motion.gyro_z)); } } return encode_response( @@ -368,6 +372,41 @@ size_t encode_profile_read(const ProfileServiceSelectedSnapshot& snapshot, payload, payload_size, output, output_size); } +size_t encode_profile_metadata( + const ProfileServiceMetadataSnapshot& snapshot, + uint8_t* output, size_t output_size) { + Status status = profile_service_status(snapshot.metadata); + if (status == Status::kOk) { + status = transaction_status(snapshot.status); + } + uint8_t payload[kProfileMetadataPayloadSize]{}; + if (snapshot.valid) { + const char* values[CONTROLLER_PROFILE_COUNT + 1] = { + snapshot.alias, + snapshot.profile_names[0], snapshot.profile_names[1], + snapshot.profile_names[2], snapshot.profile_names[3], + snapshot.profile_names[4], snapshot.profile_names[5], + snapshot.profile_names[6], snapshot.profile_names[7], + }; + for (size_t index = 0; + index < CONTROLLER_PROFILE_COUNT + 1; ++index) { + const size_t size = strlen(values[index]); + if (size > PROFILE_SERVICE_METADATA_MAX_BYTES) { + return 0; + } + const size_t offset = + index * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); + payload[offset] = static_cast(size); + memcpy(&payload[offset + 1], values[index], size); + } + } + return encode_response( + Operation::kProfileMetadataRead, status, 0, + kProfileMetadataSchemaVersion, snapshot.metadata.generation, + payload, snapshot.valid ? sizeof(payload) : 0, + output, output_size); +} + size_t encode_profile_transaction( const ProfileServiceTransactionSnapshot& snapshot, uint8_t* output, size_t output_size) { @@ -585,6 +624,41 @@ bool process_out_request() { status == ConfigurationTransactionStatus::kUnchanged || status == ConfigurationTransactionStatus::kCommitted; } + case Operation::kProfileMetadataSet: { + const uint32_t transaction_id = + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24); + ControllerIdentity identity{}; + const uint8_t profile_index = payload[18]; + const size_t value_size = payload[19]; + if (transaction_id == 0 || + (profile_index != CONTROLLER_PROFILE_ALL && + profile_index >= CONTROLLER_PROFILE_COUNT) || + value_size > PROFILE_SERVICE_METADATA_MAX_BYTES || + request.payload_size != 20 + value_size || + !controller_identity_decode( + &payload[4], CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity)) { + return false; + } + const ConfigurationTransactionStatus status = + profile_service_set_metadata( + transaction_id, identity, profile_index, + reinterpret_cast(&payload[20]), + value_size); + return status == ConfigurationTransactionStatus::kPending || + status == ConfigurationTransactionStatus::kUnchanged || + status == ConfigurationTransactionStatus::kCommitted; + } + case Operation::kProfileIdentify: { + ControllerIdentity identity{}; + return controller_identity_decode( + payload, CONTROLLER_IDENTITY_ENCODED_SIZE, + &identity) && + bluepad32_input_backend_identify(identity); + } case Operation::kPairingRefresh: bluepad32_input_backend_request_pairing_snapshot(); return true; @@ -706,6 +780,13 @@ bool usb_configuration_management_vendor_control( selected_slot, playtest, response, sizeof(response)); break; } + case Operation::kProfileMetadataRead: { + ProfileServiceMetadataSnapshot snapshot{}; + profile_service_metadata_snapshot(&snapshot); + response_size = encode_profile_metadata( + snapshot, response, sizeof(response)); + break; + } case Operation::kProfileTransactionStatus: { ProfileServiceTransactionSnapshot snapshot{}; profile_service_transaction_snapshot(&snapshot); diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index 5fe2b11..04460fd 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -18,16 +18,22 @@ constexpr size_t kRequestHeaderSize = 16; constexpr size_t kResponseHeaderSize = 20; constexpr size_t kPairingRecordSize = 8; constexpr size_t kPairingPayloadHeaderSize = 4; -constexpr size_t kMaximumRequestSize = 64; +constexpr size_t kMaximumRequestSize = 80; +constexpr size_t kProfileListRowSize = + 16 + PROFILE_SERVICE_METADATA_MAX_BYTES + 1; constexpr size_t kProfileListPayloadSize = - 1 + PROFILE_SERVICE_LIST_CAPACITY * 16; -constexpr uint16_t kProfilePlaytestSchemaVersion = 1; -constexpr size_t kProfilePlaytestPayloadSize = 52; + 1 + PROFILE_SERVICE_LIST_CAPACITY * kProfileListRowSize; +constexpr uint16_t kProfilePlaytestSchemaVersion = 2; +constexpr size_t kProfilePlaytestPayloadSize = 54; +constexpr size_t kProfileMetadataPayloadSize = + (CONTROLLER_PROFILE_COUNT + 1) * + (PROFILE_SERVICE_METADATA_MAX_BYTES + 1); +constexpr uint16_t kProfileMetadataSchemaVersion = 1; constexpr size_t kMaximumResponseSize = kResponseHeaderSize + kProfileListPayloadSize; constexpr size_t kMaximumChunkSize = kMaximumRequestSize - kRequestHeaderSize - 8; -static_assert(kMaximumResponseSize == 293, +static_assert(kMaximumResponseSize == 837, "profile list no longer fits the EP0 response buffer"); enum class Operation : uint8_t { @@ -55,6 +61,9 @@ enum class Operation : uint8_t { kProfileActivate = 0x37, kProfileTransactionStatus = 0x38, kProfilePlaytest = 0x39, + kProfileMetadataRead = 0x3a, + kProfileMetadataSet = 0x3b, + kProfileIdentify = 0x3c, }; enum class Status : uint8_t { @@ -93,6 +102,9 @@ size_t encode_profile_playtest( size_t encode_profile_transaction( const ProfileServiceTransactionSnapshot& snapshot, uint8_t* output, size_t output_size); +size_t encode_profile_metadata( + const ProfileServiceMetadataSnapshot& snapshot, + uint8_t* output, size_t output_size); } // namespace UsbConfigurationManagement diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 060f6ff..0b5d31d 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -29,8 +29,8 @@ REQUEST_INDEX = 0x0001 PROTOCOL_VERSION = 1 REQUEST_HEADER_SIZE = 16 RESPONSE_HEADER_SIZE = 20 -MAXIMUM_REQUEST_SIZE = 64 -MAXIMUM_RESPONSE_SIZE = 293 +MAXIMUM_REQUEST_SIZE = 80 +MAXIMUM_RESPONSE_SIZE = 837 MAXIMUM_CHUNK_SIZE = 40 USB_TIMEOUT_MS = 1000 DEFAULT_OPERATION_TIMEOUT_SECONDS = 15.0 @@ -60,6 +60,9 @@ OP_PROFILE_RESET = 0x36 OP_PROFILE_ACTIVATE = 0x37 OP_PROFILE_TRANSACTION_STATUS = 0x38 OP_PROFILE_PLAYTEST = 0x39 +OP_PROFILE_METADATA_READ = 0x3A +OP_PROFILE_METADATA_SET = 0x3B +OP_PROFILE_IDENTIFY = 0x3C STATUS_OK = 0 STATUS_PENDING = 1 @@ -110,7 +113,7 @@ PROFILE_CAPACITY = 8 PROFILE_IDENTITY_CAPACITY = 16 PROFILE_LIST_CAPACITY = PROFILE_IDENTITY_CAPACITY + 1 CONTROLLER_IDENTITY_SIZE = 14 -PROFILE_LIST_ROW_SIZE = 16 +PROFILE_LIST_ROW_SIZE = 48 PROFILE_NONE_BUTTON = 0xFF PROFILE_MACRO_COUNT = 4 PROFILE_MACRO_STEP_CAPACITY = 16 @@ -121,9 +124,13 @@ PROFILE_MACRO_STEP_SIZE = 19 PROFILE_MAXIMUM_WAIT_MS = 10000 PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD = 0x8000 PROFILE_DEFAULT_DIGITAL_THRESHOLD = 22934 -PROFILE_PLAYTEST_SCHEMA_VERSION = 1 -PROFILE_PLAYTEST_SIZE = 52 +PROFILE_PLAYTEST_SCHEMA_VERSION = 2 +PROFILE_PLAYTEST_SIZE = 54 PROFILE_PLAYTEST_SLOT_COUNT = 4 +PROFILE_METADATA_SCHEMA_VERSION = 1 +PROFILE_METADATA_MAX_BYTES = 31 +PROFILE_METADATA_VALUE_SIZE = 32 +PROFILE_METADATA_SIZE = 288 LOGICAL_BUTTONS = ( "south", @@ -497,6 +504,7 @@ class ControllerIdentity: class ProfileListEntry: identity: ControllerIdentity active_profile_index: int + alias: str = "" def __post_init__(self) -> None: if not isinstance(self.identity, ControllerIdentity): @@ -509,6 +517,35 @@ class ProfileListEntry: 0, PROFILE_CAPACITY - 1, ) + if type(self.alias) is not str or "\x00" in self.alias or len( + self.alias.encode("utf-8") + ) > PROFILE_METADATA_MAX_BYTES: + raise ConfigManagerError( + "controller alias must contain at most 31 UTF-8 bytes" + ) + + +@dataclass(frozen=True) +class ProfileMetadata: + alias: str + profile_names: tuple[str, ...] + + def __post_init__(self) -> None: + if len(self.profile_names) != PROFILE_CAPACITY: + raise ConfigManagerError("profile metadata must contain eight names") + for label, value in ( + ("controller alias", self.alias), + *( + (f"profile {index + 1} name", name) + for index, name in enumerate(self.profile_names) + ), + ): + if type(value) is not str or "\x00" in value or len( + value.encode("utf-8") + ) > PROFILE_METADATA_MAX_BYTES: + raise ConfigManagerError( + f"{label} must contain at most 31 UTF-8 bytes" + ) @dataclass(frozen=True) @@ -522,6 +559,8 @@ class ProfilePlaytest: left_stick: tuple[int, int] right_stick: tuple[int, int] triggers: tuple[int, int] + battery: int + capabilities: int motion: tuple[int, int, int, int, int, int] | None def to_json_object(self) -> dict[str, Any]: @@ -533,6 +572,7 @@ class ProfilePlaytest: "identity": ( { "address": self.identity.address_text, + "transport": self.identity.transport_text, "vendor_id": self.identity.vendor_id, "product_id": self.identity.product_id, } @@ -552,6 +592,16 @@ class ProfilePlaytest: "left": self.triggers[0], "right": self.triggers[1], }, + "battery": ( + round((self.battery - 1) / 250 * 100) + if self.battery != 0 + else None + ), + "capabilities": [ + name for bit, name in enumerate( + ("rumble", "lightbar", "player_leds", "motion") + ) if self.capabilities & (1 << bit) + ], "motion": ( { "accel": list(self.motion[:3]), @@ -2060,8 +2110,18 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: envelope.payload[offset : offset + CONTROLLER_IDENTITY_SIZE] ) active_profile_index = envelope.payload[offset + 14] - if envelope.payload[offset + 15] != 0: - raise ConfigManagerError("profile-list reserved field is nonzero") + alias_size = envelope.payload[offset + 16] + alias_payload = envelope.payload[offset + 17 : offset + 48] + if ( + envelope.payload[offset + 15] != 0 + or alias_size > PROFILE_METADATA_MAX_BYTES + or any(alias_payload[alias_size:]) + ): + raise ConfigManagerError("invalid profile-list metadata") + try: + alias = alias_payload[:alias_size].decode("utf-8") + except UnicodeDecodeError as exc: + raise ConfigManagerError("invalid profile-list alias") from exc if index == 0 and not identity.is_global_fallback: raise ConfigManagerError( "profile list does not begin with global fallback" @@ -2071,7 +2131,7 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: if identity in identities: raise ConfigManagerError("duplicate identity in profile list") identities.add(identity) - entries.append(ProfileListEntry(identity, active_profile_index)) + entries.append(ProfileListEntry(identity, active_profile_index, alias)) return tuple(entries) @@ -2124,6 +2184,93 @@ def read_profile( return read_selected_profile(device) +def _decode_profile_metadata_value( + payload: bytes, offset: int, label: str +) -> str: + size = payload[offset] + encoded = payload[offset + 1 : offset + PROFILE_METADATA_VALUE_SIZE] + if size > PROFILE_METADATA_MAX_BYTES or any(encoded[size:]): + raise ConfigManagerError(f"invalid {label} metadata") + try: + return encoded[:size].decode("utf-8") + except UnicodeDecodeError as exc: + raise ConfigManagerError(f"invalid {label} UTF-8") from exc + + +def parse_profile_metadata(envelope: Envelope) -> ProfileMetadata: + _raise_status(envelope) + if ( + envelope.schema_version != PROFILE_METADATA_SCHEMA_VERSION + or len(envelope.payload) != PROFILE_METADATA_SIZE + ): + raise ConfigManagerError("invalid profile metadata payload") + alias = _decode_profile_metadata_value( + envelope.payload, 0, "controller alias" + ) + names = tuple( + _decode_profile_metadata_value( + envelope.payload, + (index + 1) * PROFILE_METADATA_VALUE_SIZE, + f"profile {index + 1} name", + ) + for index in range(PROFILE_CAPACITY) + ) + return ProfileMetadata(alias, names) + + +def read_selected_profile_metadata(device: UsbDevice) -> ProfileMetadata: + return parse_profile_metadata( + _control_in(device, OP_PROFILE_METADATA_READ) + ) + + +def read_profile_metadata( + device: UsbDevice, + identity: ControllerIdentity, + profile_index: int = 0, +) -> ProfileMetadata: + select_profile(device, identity, profile_index) + return read_selected_profile_metadata(device) + + +def set_profile_metadata( + device: UsbDevice, + identity: ControllerIdentity, + profile_index: int, + value: str, + timeout: float, +) -> TransactionStatus: + if profile_index != PROFILE_NONE_BUTTON: + _validate_profile_index(profile_index) + if type(value) is not str or "\x00" in value: + raise ConfigManagerError("profile metadata must be text") + encoded = value.encode("utf-8") + if len(encoded) > PROFILE_METADATA_MAX_BYTES: + raise ConfigManagerError( + "profile metadata must contain at most 31 UTF-8 bytes" + ) + transaction_id = _host_transaction_id() + _control_out( + device, + OP_PROFILE_METADATA_SET, + struct.pack(" None: + if identity.is_global_fallback: + raise ConfigManagerError("default profile has no controller to identify") + _control_out(device, OP_PROFILE_IDENTIFY, identity.to_bytes()) + + def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: _raise_status(envelope) if ( @@ -2133,7 +2280,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: raise ConfigManagerError("invalid profile playtest payload") payload = envelope.payload flags = payload[0] - if flags & ~0x03 or flags != envelope.flags or payload[39] != 0: + if flags & ~0x03 or flags != envelope.flags or payload[41] != 0: raise ConfigManagerError("invalid profile playtest flags") connected = bool(flags & 0x01) has_motion = bool(flags & 0x02) @@ -2143,7 +2290,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: raise ConfigManagerError("invalid disconnected playtest payload") return ProfilePlaytest( False, None, 0, 0, None, 0, - (0, 0), (0, 0), (0, 0), None, + (0, 0), (0, 0), (0, 0), 0, 0, None, ) if ( payload[1] >= PROFILE_PLAYTEST_SLOT_COUNT @@ -2155,7 +2302,7 @@ def parse_profile_playtest(envelope: Envelope) -> ProfilePlaytest: left_x, left_y, right_x, right_y, left_trigger, right_trigger = ( struct.unpack_from(" ProfilePlaytest: left_stick=(left_x, left_y), right_stick=(right_x, right_y), triggers=(left_trigger, right_trigger), + battery=payload[39], + capabilities=payload[40], motion=motion_values if has_motion else None, ) diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py index a71258a..ca7121b 100644 --- a/src/switch_pico_bridge/profile_web.py +++ b/src/switch_pico_bridge/profile_web.py @@ -95,7 +95,11 @@ def _controller_presentation( return {"model": "8BitDo controller", "style": "switch"} return {"model": "Connected controller", "style": "generic"} -def _controller_label(identity: config_manager.ControllerIdentity) -> str: +def _controller_label( + identity: config_manager.ControllerIdentity, alias: str = "" +) -> str: + if alias: + return alias if identity.is_global_fallback: return "Default profile" presentation = _controller_presentation(identity) @@ -218,26 +222,60 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): def do_PUT(self) -> None: if not self._allow_mutation(): return - selection = self._parse_profile_path(urlsplit(self.path).path) - if selection is None or selection[2] is not None: + path = urlsplit(self.path).path + identity_selection = self._parse_identity_path(path) + if identity_selection is not None: + identity_index, action = identity_selection + if action == "alias": + self._api_call(lambda: self._set_alias(identity_index)) + return + selection = self._parse_profile_path(path) + if selection is None: self.send_error(HTTPStatus.NOT_FOUND) return - identity_index, profile_index, _ = selection - self._api_call( - lambda: self._write_profile(identity_index, profile_index) - ) + identity_index, profile_index, action = selection + if action is None: + self._api_call( + lambda: self._write_profile(identity_index, profile_index) + ) + return + if action == "name": + self._api_call( + lambda: self._set_profile_name( + identity_index, profile_index + ) + ) + return + self.send_error(HTTPStatus.NOT_FOUND) def do_POST(self) -> None: if not self._allow_mutation(): return - selection = self._parse_profile_path(urlsplit(self.path).path) - if selection is None or selection[2] != "activate": - self.send_error(HTTPStatus.NOT_FOUND) - return - identity_index, profile_index, _ = selection - self._api_call( - lambda: self._activate_profile(identity_index, profile_index) - ) + path = urlsplit(self.path).path + identity_selection = self._parse_identity_path(path) + if identity_selection is not None: + identity_index, action = identity_selection + if action == "identify": + self._api_call(lambda: self._identify(identity_index)) + return + selection = self._parse_profile_path(path) + if selection is not None: + identity_index, profile_index, action = selection + if action == "activate": + self._api_call( + lambda: self._activate_profile( + identity_index, profile_index + ) + ) + return + if action == "copy": + self._api_call( + lambda: self._copy_profile( + identity_index, profile_index + ) + ) + return + self.send_error(HTTPStatus.NOT_FOUND) def _local_host_header(self) -> bool: host = self.headers.get("Host", "") @@ -279,6 +317,21 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): action = parts[4] if len(parts) == 5 else None return identity_index, profile_number - 1, action + @staticmethod + def _parse_identity_path(path: str) -> tuple[int, str] | None: + parts = path.strip("/").split("/") + if len(parts) != 4 or parts[:2] != ["api", "identities"]: + return None + try: + identity_index = int(parts[2]) + except ValueError: + return None + return ( + (identity_index, parts[3]) + if identity_index >= 0 + else None + ) + def _entries_and_identity( self, device: config_manager.UsbDevice, identity_index: int ) -> tuple[ @@ -300,10 +353,11 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): "identities": [ { "index": index, - "label": _controller_label(entry.identity), + "label": _controller_label(entry.identity, entry.alias), "key": entry.identity.to_bytes().hex(), "active_profile": entry.active_profile_index + 1, "controller": _controller_presentation(entry.identity), + "alias": entry.alias, } for index, entry in enumerate(entries) ] @@ -315,8 +369,12 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): device = self.profile_server.find_device() entries, identity = self._entries_and_identity(device, identity_index) profile = config_manager.read_profile(device, identity, profile_index) + metadata = config_manager.read_selected_profile_metadata(device) return { "profile": profile.to_json_object(), + "name": metadata.profile_names[profile_index], + "profile_names": list(metadata.profile_names), + "alias": metadata.alias, "active": entries[identity_index].active_profile_index == profile_index, } @@ -349,6 +407,14 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): ) return self.rfile.read(length).decode("utf-8") + def _read_json_object(self) -> dict[str, Any]: + value = json.loads(self._read_json_body()) + if not isinstance(value, dict): + raise config_manager.ConfigManagerError( + "request body must be a JSON object" + ) + return value + def _write_profile( self, identity_index: int, profile_index: int ) -> dict[str, Any]: @@ -367,6 +433,86 @@ class ProfileEditorHandler(BaseHTTPRequestHandler): "stored_crc": f"{status.stored_crc:08x}", } + def _set_profile_name( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + value = self._read_json_object().get("value") + if not isinstance(value, str): + raise config_manager.ConfigManagerError( + "profile name must be text" + ) + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.set_profile_metadata( + device, identity, profile_index, value, + self.profile_server.operation_timeout, + ) + return {"stored_generation": status.stored_generation} + + def _set_alias(self, identity_index: int) -> dict[str, Any]: + value = self._read_json_object().get("value") + if not isinstance(value, str): + raise config_manager.ConfigManagerError( + "controller alias must be text" + ) + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.set_profile_metadata( + device, identity, config_manager.PROFILE_NONE_BUTTON, + value, self.profile_server.operation_timeout, + ) + return { + "stored_generation": status.stored_generation, + "label": _controller_label(identity, value), + } + + def _identify(self, identity_index: int) -> dict[str, Any]: + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + config_manager.identify_controller(device, identity) + return {"identified": True} + + def _copy_profile( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + destination = self._read_json_object() + destination_identity_index = destination.get("identity_index") + destination_profile_number = destination.get("profile_number") + if ( + type(destination_identity_index) is not int + or type(destination_profile_number) is not int + or not 1 <= destination_profile_number <= + config_manager.PROFILE_CAPACITY + ): + raise config_manager.ConfigManagerError( + "copy destination is invalid" + ) + device = self.profile_server.find_device() + _, source_identity = self._entries_and_identity( + device, identity_index + ) + profile = config_manager.read_profile( + device, source_identity, profile_index + ) + metadata = config_manager.read_selected_profile_metadata(device) + _, destination_identity = self._entries_and_identity( + device, destination_identity_index + ) + target_profile = destination_profile_number - 1 + profile_status = config_manager.write_profile( + device, destination_identity, target_profile, profile, + self.profile_server.operation_timeout, + ) + name_status = config_manager.set_profile_metadata( + device, destination_identity, target_profile, + metadata.profile_names[profile_index], + self.profile_server.operation_timeout, + ) + return { + "stored_generation": name_status.stored_generation, + "profile_generation": profile_status.stored_generation, + } + def _activate_profile( self, identity_index: int, profile_index: int ) -> dict[str, Any]: diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css index 9408924..e6b59c7 100644 --- a/src/switch_pico_bridge/web/profile_editor.css +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -536,6 +536,33 @@ input[type="range"] { width: 100%; accent-color: var(--blue); } .toast.show { opacity: 1; transform: translateY(0); } .toast.error { border-color: var(--rose); } +.identity-tools .field-label { margin-top: 18px; } +.identity-actions { display: flex; gap: 8px; margin-top: 9px; } +.identity-actions .button { flex: 1; padding-inline: 8px; } +.controller-details { min-height: 18px; margin-top: 8px; color: var(--subtle); font-size: 0.67rem; } +.profile-name-editor { display: grid; grid-template-columns: minmax(140px, 240px) auto; align-items: end; gap: 8px; margin-top: 12px; } +.profile-name-editor label { grid-column: 1 / -1; color: var(--muted); font-size: 0.66rem; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase; } +.profile-name-editor input { min-height: 40px; padding: 8px 11px; color: var(--ink); border: 1px solid var(--control-line); border-radius: 10px; background: var(--input); } +.panel-heading-actions { display: flex; align-items: flex-end; flex-direction: column; gap: 8px; } +.panel-heading-actions p { max-width: 440px; color: var(--muted); font-size: 0.78rem; line-height: 1.55; text-align: right; } +.section-reset { padding: 0; color: var(--sky); border: 0; background: transparent; font-size: 0.68rem; font-weight: 760; } +.section-reset:hover { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; } +.curve-editor { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(150px, 1.2fr); gap: 14px; margin-bottom: 16px; padding: 12px; border: 1px solid var(--line-soft); border-radius: 13px; background: #0a1120; } +.curve-editor svg { width: 100%; min-height: 110px; overflow: visible; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.curve-editor path { fill: none; vector-effect: non-scaling-stroke; } +.curve-guide { stroke: var(--subtle); stroke-dasharray: 3 3; opacity: 0.45; } +.curve-line { stroke: var(--blue); stroke-width: 2.5; } +.curve-marker { fill: var(--amber); stroke: #0a1120; stroke-width: 2; opacity: 0; transition: opacity 120ms ease; } +.curve-marker.visible { opacity: 1; } +.curve-controls { display: flex; flex-direction: column; gap: 10px; } +.curve-controls label { color: var(--muted); font-size: 0.67rem; font-weight: 720; } +.curve-controls output { float: right; color: var(--ink); } +.curve-controls input[type="range"] { display: block; margin-top: 8px; } +.copy-side { min-height: 34px; margin-top: auto; padding-block: 6px; } +.copy-dialog { width: min(440px, calc(100% - 28px)); padding: 24px; color: var(--ink); border: 1px solid var(--line); border-radius: 18px; background: var(--surface-solid); box-shadow: var(--shadow); } +.copy-dialog::backdrop { background: rgba(2, 5, 13, 0.76); } +.copy-dialog h3 { margin-bottom: 18px; } +.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 22px; } @media (max-width: 1120px) { .workspace { grid-template-columns: 250px minmax(0, 1fr); } .profile-list { grid-template-columns: 1fr; } @@ -558,6 +585,7 @@ input[type="range"] { width: 100%; accent-color: var(--blue); } .mini-active { margin-left: 0; } .editor-header, .panel-heading { align-items: flex-start; flex-direction: column; } .header-actions { justify-content: flex-start; } + .curve-editor { grid-template-columns: 1fr; } .section-nav { top: 8px; } .builtin-actions, .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; } .playtest-grid { grid-template-columns: 1fr; } @@ -575,6 +603,9 @@ input[type="range"] { width: 100%; accent-color: var(--blue); } .connection { max-width: 48%; padding: 7px 9px; font-size: 0.68rem; } .workspace { width: min(100% - 16px, 480px); } .sidebar, .panel, .editor-header { border-radius: 17px; } + .profile-name-editor { grid-template-columns: 1fr; } + .panel-heading-actions { align-items: flex-start; } + .panel-heading-actions p { text-align: left; } .sidebar, .panel { padding: 18px; } .trigger-meter { grid-template-columns: 25px minmax(100px, 1fr); } .trigger-meter output { grid-column: 2; text-align: left; } diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html index 7066bda..6e6dae6 100644 --- a/src/switch_pico_bridge/web/profile_editor.html +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -39,6 +39,15 @@

The default applies when a controller has no dedicated layout.

+
+ + +
+ + +
+

+
@@ -57,8 +66,17 @@ +
+ + + +
+ + + + @@ -85,7 +103,7 @@

01 · Controls

Button mapping

-

Choose what each physical button should produce.

+

Choose what each physical button should produce.

@@ -149,7 +167,7 @@

02 · Response

Sticks & triggers

-

Values use the controller's normalized integer range.

+

Values use the controller's normalized integer range.

@@ -231,7 +249,7 @@

03 · Identity

Feedback

-

Scale rumble and choose profile-change confirmation.

+

Scale rumble and choose profile-change confirmation.

@@ -242,7 +260,7 @@

04 · Repeat

Turbo behavior

-

Off, hold-to-repeat, or press-once Auto Burst per button.

+

Off, hold-to-repeat, or press-once Auto Burst per button.

@@ -253,6 +271,7 @@

05 · Actions

Bindings & custom macro

+
@@ -271,6 +290,20 @@ + +
+

Duplicate profile

+

Choose a destination

+ + + + +
+ + +
+
+
diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js index dfdefb2..3730fb4 100644 --- a/src/switch_pico_bridge/web/profile_editor.js +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -8,12 +8,15 @@ const state = { identityIndex: 0, profileIndex: 0, profile: null, + profileNames: Array(8).fill(""), original: "", + pendingName: false, active: false, selectedButton: "south", selectedMacro: 0, token: "", busy: false, + identifyAvailable: false, adapterConnected: false, playtestRequestActive: false, playtestTimer: 0, @@ -27,9 +30,15 @@ const elements = { identity: document.querySelector("#identitySelect"), profileList: document.querySelector("#profileList"), profileOwner: document.querySelector("#profileOwner"), + controllerAlias: document.querySelector("#controllerAlias"), + controllerDetails: document.querySelector("#controllerDetails"), + saveAlias: document.querySelector("#saveAliasButton"), + identify: document.querySelector("#identifyButton"), profileTitle: document.querySelector("#profileTitle"), activeBadge: document.querySelector("#activeBadge"), dirtyBadge: document.querySelector("#dirtyBadge"), + profileName: document.querySelector("#profileName"), + saveProfileName: document.querySelector("#saveProfileNameButton"), loading: document.querySelector("#loadingCard"), form: document.querySelector("#profileForm"), controllerCanvas: document.querySelector("#controllerCanvas"), @@ -61,6 +70,14 @@ const elements = { activate: document.querySelector("#activateButton"), save: document.querySelector("#saveButton"), addMacroStep: document.querySelector("#addMacroStepButton"), + copyProfile: document.querySelector("#copyProfileButton"), + exportProfile: document.querySelector("#exportProfileButton"), + importProfile: document.querySelector("#importProfileButton"), + importProfileFile: document.querySelector("#importProfileFile"), + copyDialog: document.querySelector("#copyDialog"), + copyIdentity: document.querySelector("#copyIdentity"), + copySlot: document.querySelector("#copySlot"), + confirmCopy: document.querySelector("#confirmCopyButton"), toast: document.querySelector("#toast"), }; @@ -146,7 +163,30 @@ function updateTriggerPlaytest(side, raw, output, config) { meter.querySelector("output").textContent = `${raw} → ${output}`; } +function updateCurveMarker(group, side, input) { + const panel = document.querySelector( + `[data-analog-group="${group}"][data-analog-side="${side}"]` + ); + if (!panel) return; + const normalized = Math.max(0, Math.min(1, input)); + const config = state.profile[group][side]; + const output = ProfilePlaytestMath.transformTrigger( + Math.round(normalized * 65535), + { + lower_deadzone: 0, + upper_saturation: 65535, + curve_q8_8: config.curve_q8_8, + } + ) / 65535; + const marker = panel.querySelector(".curve-marker"); + marker.setAttribute("cx", String(normalized * 100)); + marker.setAttribute("cy", String(60 - output * 60)); + marker.classList.add("visible"); +} + function clearPlaytest(message, stateName = "waiting") { + state.identifyAvailable = false; + elements.identify.disabled = true; elements.playtestPanel.dataset.state = stateName; elements.playtestStatus.textContent = stateName === "error" ? "Unavailable" : "Waiting"; @@ -155,6 +195,8 @@ function clearPlaytest(message, stateName = "waiting") { elements.playtestHelp.textContent = message; elements.controllerHotspots.querySelectorAll(".pressed") .forEach((button) => button.classList.remove("pressed")); + document.querySelectorAll(".curve-marker.visible") + .forEach((marker) => marker.classList.remove("visible")); } function renderPlaytest(sample) { @@ -188,6 +230,16 @@ function renderPlaytest(sample) { "right", sample.triggers.right, outputRightTrigger, state.profile.triggers.right ); + updateCurveMarker( + "sticks", "left", + Math.max(Math.abs(rawLeft.x), Math.abs(rawLeft.y)) / 32767 + ); + updateCurveMarker( + "sticks", "right", + Math.max(Math.abs(rawRight.x), Math.abs(rawRight.y)) / 32767 + ); + updateCurveMarker("triggers", "left", sample.triggers.left / 65535); + updateCurveMarker("triggers", "right", sample.triggers.right / 65535); const style = sample.controller?.style || currentControllerStyle(); elements.playtestLeftTriggerLabel.textContent = controlLabel("left_trigger", style); @@ -204,7 +256,25 @@ function renderPlaytest(sample) { }); elements.playtestPanel.dataset.state = "live"; elements.playtestStatus.textContent = "Live"; + const owner = currentOwner(); + const sourceAddress = sample.identity?.address + ?.replaceAll(":", "").toLowerCase(); + state.identifyAvailable = Boolean( + owner && owner.index !== 0 && sourceAddress && + owner.key.includes(sourceAddress) && + sample.capabilities?.some((capability) => + ["rumble", "lightbar", "player_leds"].includes(capability) + ) + ); + elements.identify.disabled = state.busy || !state.identifyAvailable; elements.playtestTitle.textContent = sample.label || "Connected controller"; + const details = [ + sample.controller?.model, + sample.identity?.transport, + sample.battery === null ? null : `${sample.battery}% battery`, + ...(sample.capabilities || []).map(label), + ].filter(Boolean); + elements.controllerDetails.textContent = details.join(" · "); elements.playtestHelp.textContent = "Yellow is raw input; blue is the output produced by this unsaved draft."; } @@ -248,7 +318,8 @@ async function pollPlaytest() { } function isDirty() { - return state.profile !== null && canonical(state.profile) !== state.original; + return state.profile !== null && + (canonical(state.profile) !== state.original || state.pendingName); } function setConnection(mode, text) { @@ -299,6 +370,16 @@ function setBusy(busy) { elements.resetDraft.disabled = busy; elements.refresh.disabled = busy; elements.identity.disabled = busy; + elements.identify.disabled = busy || !state.identifyAvailable; + elements.profileName.disabled = busy; + elements.saveProfileName.disabled = busy || !state.adapterConnected; + elements.controllerAlias.disabled = + busy || currentOwner()?.index === 0; + elements.saveAlias.disabled = + busy || !state.adapterConnected || currentOwner()?.index === 0; + elements.copyProfile.disabled = busy || !state.adapterConnected; + elements.importProfile.disabled = busy; + elements.exportProfile.disabled = busy; if (state.schema && state.profile) { const macro = state.profile.macros[state.selectedMacro]; const totalSteps = state.profile.macros.reduce( @@ -435,7 +516,7 @@ function renderProfileList() { const active = owner && owner.active_profile === index + 1; return ` `; }).join(""); @@ -601,29 +682,49 @@ const analogDefinitions = [ ["center_y", "Center Y", -32768, 32767], ["inner_deadzone", "Inner deadzone", 0, 32767], ["outer_saturation", "Outer saturation", 1, 32767], - ["curve_q8_8", "Curve (Q8.8)", 1, 65535], ]], ["sticks", "right", "Right stick", [ ["center_x", "Center X", -32768, 32767], ["center_y", "Center Y", -32768, 32767], ["inner_deadzone", "Inner deadzone", 0, 32767], ["outer_saturation", "Outer saturation", 1, 32767], - ["curve_q8_8", "Curve (Q8.8)", 1, 65535], ]], ["triggers", "left", "Left trigger", [ ["lower_deadzone", "Lower deadzone", 0, 65535], ["upper_saturation", "Upper saturation", 1, 65535], - ["curve_q8_8", "Curve (Q8.8)", 1, 65535], ["digital_threshold", "Digital threshold", 0, 65535], ]], ["triggers", "right", "Right trigger", [ ["lower_deadzone", "Lower deadzone", 0, 65535], ["upper_saturation", "Upper saturation", 1, 65535], - ["curve_q8_8", "Curve (Q8.8)", 1, 65535], ["digital_threshold", "Digital threshold", 0, 65535], ]], ]; +const curvePresets = { + quick: 128, + linear: 256, + precise: 512, + deliberate: 768, +}; + +function curvePreset(value) { + return Object.entries(curvePresets) + .find(([, preset]) => preset === value)?.[0] || "custom"; +} + +function curvePath(curve) { + return Array.from({ length: 21 }, (_, index) => { + const input = Math.round(index / 20 * 65535); + const output = ProfilePlaytestMath.transformTrigger(input, { + lower_deadzone: 0, + upper_saturation: 65535, + curve_q8_8: curve, + }); + return `${index === 0 ? "M" : "L"} ${index * 5} ${60 - output / 65535 * 60}`; + }).join(" "); +} + function renderAnalog() { elements.analog.innerHTML = analogDefinitions.map(([group, side, title, fields]) => { const config = state.profile[group][side]; @@ -636,8 +737,27 @@ function renderAnalog() { `).join("")} ` : ""; return ` -
+

${title}

${group === "sticks" ? "Signed axes · 32767 full scale" : "Unsigned · 65535 full scale"}
+
+ + + + + +
+ + + +
+
${fields.map(([field, fieldLabel, min, max]) => `
@@ -840,10 +960,22 @@ function renderMacro() { } function renderEditor() { - elements.profileTitle.textContent = `Profile ${state.profileIndex + 1}`; + elements.profileTitle.textContent = + state.profileNames[state.profileIndex] || `Profile ${state.profileIndex + 1}`; elements.activeBadge.hidden = !state.active; elements.activate.disabled = !state.adapterConnected || state.busy || state.active; + elements.profileName.value = state.profileNames[state.profileIndex] || ""; + const owner = currentOwner(); + elements.controllerAlias.value = owner?.alias || ""; + elements.identify.disabled = + state.busy || !state.identifyAvailable; + elements.controllerAlias.disabled = !owner || owner.index === 0; + elements.saveAlias.disabled = + !state.adapterConnected || !owner || owner.index === 0; + elements.controllerDetails.textContent = owner && owner.index !== 0 + ? owner.controller.model + : "Used when no dedicated controller profile exists."; renderIdentities(); renderProfileList(); renderButtonMap(); @@ -867,6 +999,10 @@ async function loadProfile() { const payload = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`); state.profile = payload.profile; state.original = canonical(payload.profile); + state.pendingName = false; + state.profileNames = payload.profile_names; + const owner = currentOwner(); + if (owner) owner.alias = payload.alias; state.active = payload.active; setConnection("ready", "Adapter connected"); renderEditor(); @@ -910,6 +1046,22 @@ function handleFormChange(event) { state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = Number(target.value); } else if (kind === "analog-bool") { state.profile[target.dataset.group][target.dataset.side][target.dataset.field] = target.checked; + } else if (kind === "curve-preset") { + if (target.value !== "custom") { + state.profile[target.dataset.group][target.dataset.side].curve_q8_8 = + curvePresets[target.value]; + renderAnalog(); + } + } else if (kind === "curve-range") { + const config = state.profile[target.dataset.group][target.dataset.side]; + config.curve_q8_8 = Number(target.value); + const editor = target.closest(".curve-editor"); + editor.querySelector("output").textContent = target.value; + editor.querySelector(".curve-line").setAttribute( + "d", curvePath(config.curve_q8_8) + ); + editor.querySelector("[data-kind='curve-preset']").value = + curvePreset(config.curve_q8_8); } else if (kind === "rumble-range") { state.profile.rumble[target.dataset.field] = Number(target.value); document.querySelector(`#rumble-${target.dataset.field}-value`).value = target.value; @@ -926,9 +1078,7 @@ function handleFormChange(event) { profile_switch: state.schema.default_switching_chord, motion_toggle: state.schema.default_motion_toggle_chord, }[target.dataset.action]; - const current = owner[field].length === 0 && defaults - ? defaults - : owner[field]; + const current = owner[field].length === 0 && defaults ? defaults : owner[field]; const selected = new Set(current); target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); owner[field] = state.schema.controls.filter((name) => selected.has(name)); @@ -960,6 +1110,18 @@ function handleFormChange(event) { updateDirtyState(); } +elements.analog.addEventListener("click", (event) => { + const button = event.target.closest("[data-copy-analog]"); + if (!button) return; + const group = button.dataset.copyAnalog; + const source = button.dataset.sourceSide; + const destination = source === "left" ? "right" : "left"; + state.profile[group][destination] = clone(state.profile[group][source]); + renderAnalog(); + updateDirtyState(); + toast(`${label(source)} ${label(group)} settings applied to ${destination}.`); +}); + elements.form.addEventListener("input", handleFormChange); elements.form.addEventListener("change", handleFormChange); @@ -973,6 +1135,7 @@ elements.identity.addEventListener("change", async () => { const owner = currentOwner(); if (owner) persistOwnerKey(owner.key); state.profileIndex = 0; + state.profileNames = Array(state.schema.profile_capacity).fill(""); await loadProfile(); }); @@ -1019,6 +1182,180 @@ elements.macroSteps.addEventListener("click", (event) => { updateDirtyState(); }); +elements.saveProfileName.addEventListener("click", async () => { + setBusy(true); + try { + const value = elements.profileName.value.trim(); + const result = await api( + `/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + } + ); + state.profileNames[state.profileIndex] = value; + state.pendingName = false; + renderEditor(); + toast(`Profile name saved · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.saveAlias.addEventListener("click", async () => { + const owner = currentOwner(); + if (!owner) return; + setBusy(true); + try { + const value = elements.controllerAlias.value.trim(); + const result = await api(`/api/identities/${owner.index}/alias`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }); + owner.alias = value; + owner.label = result.label; + renderIdentities(); + toast(`Controller alias saved · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.identify.addEventListener("click", async () => { + const owner = currentOwner(); + if (!owner || owner.index === 0) return; + setBusy(true); + try { + await api(`/api/identities/${owner.index}/identify`, { method: "POST" }); + toast("Identification pulse sent."); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.exportProfile.addEventListener("click", () => { + const name = state.profileNames[state.profileIndex] || + `profile-${state.profileIndex + 1}`; + const blob = new Blob([ + JSON.stringify({ name, profile: state.profile }, null, 2), + ], { type: "application/json" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `${name.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.json`; + link.click(); + URL.revokeObjectURL(link.href); +}); + +elements.importProfile.addEventListener("click", () => { + elements.importProfileFile.click(); +}); + +elements.importProfileFile.addEventListener("change", async () => { + const [file] = elements.importProfileFile.files; + if (!file) return; + try { + const imported = JSON.parse(await file.text()); + if ( + typeof imported.name === "string" && + new TextEncoder().encode(imported.name).length > 31 + ) { + throw new Error("Profile name exceeds 31 UTF-8 bytes"); + } + state.profile = clone(imported.profile || imported); + if (typeof imported.name === "string") { + state.profileNames[state.profileIndex] = imported.name; + state.pendingName = true; + } + renderEditor(); + toast("Profile imported into the unsaved draft."); + } catch (error) { + toast(`Import failed: ${error.message}`, true); + } finally { + elements.importProfileFile.value = ""; + } +}); + +elements.copyProfile.addEventListener("click", () => { + elements.copyIdentity.innerHTML = state.identities.map((identity) => ( + `` + )).join(""); + elements.copyIdentity.value = String(state.identityIndex); + elements.copySlot.innerHTML = Array.from( + { length: state.schema.profile_capacity }, + (_, index) => `` + ).join(""); + elements.copySlot.value = String( + Math.min(state.schema.profile_capacity, state.profileIndex + 2) + ); + elements.copyDialog.showModal(); +}); + +elements.confirmCopy.addEventListener("click", async (event) => { + event.preventDefault(); + setBusy(true); + try { + const result = await api( + `/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/copy`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + identity_index: Number(elements.copyIdentity.value), + profile_number: Number(elements.copySlot.value), + }), + } + ); + if (Number(elements.copyIdentity.value) === state.identityIndex) { + state.profileNames[Number(elements.copySlot.value) - 1] = + state.profileNames[state.profileIndex]; + renderProfileList(); + } + elements.copyDialog.close(); + toast(`Profile copied · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +document.querySelectorAll("[data-reset-section]").forEach((button) => { + button.addEventListener("click", () => { + const defaults = state.schema.default_profile; + const section = button.dataset.resetSection; + if (section === "mapping") { + state.profile.button_map = clone(defaults.button_map); + state.profile.triggers.left.output = defaults.triggers.left.output; + state.profile.triggers.right.output = defaults.triggers.right.output; + } else if (section === "analog") { + state.profile.sticks = clone(defaults.sticks); + const leftOutput = state.profile.triggers.left.output; + const rightOutput = state.profile.triggers.right.output; + state.profile.triggers = clone(defaults.triggers); + state.profile.triggers.left.output = leftOutput; + state.profile.triggers.right.output = rightOutput; + } else if (section === "feedback") { + state.profile.rumble = clone(defaults.rumble); + } else if (section === "turbo") { + state.profile.turbo = clone(defaults.turbo); + } else if (section === "macro") { + state.profile.switching_chord = clone(defaults.switching_chord); + state.profile.motion_toggle_chord = clone(defaults.motion_toggle_chord); + state.profile.macros = clone(defaults.macros); + } + renderEditor(); + toast(`${label(section)} reset in the unsaved draft.`); + }); +}); + elements.save.addEventListener("click", async () => { if (!elements.form.reportValidity()) return; setBusy(true); @@ -1028,6 +1365,19 @@ elements.save.addEventListener("click", async () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(state.profile), }); + if (state.pendingName) { + await api( + `/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/name`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + value: state.profileNames[state.profileIndex], + }), + } + ); + state.pendingName = false; + } state.original = canonical(state.profile); updateDirtyState(); toast(`Saved atomically · generation ${result.stored_generation} · CRC ${result.stored_crc}`); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index bac34c5..f4a1142 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -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, diff --git a/tests/bluepad32_native_stubs/uni.h b/tests/bluepad32_native_stubs/uni.h index 6f35a99..0ce0ee6 100644 --- a/tests/bluepad32_native_stubs/uni.h +++ b/tests/bluepad32_native_stubs/uni.h @@ -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; diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index 68bab29..98277c9 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -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() { diff --git a/tests/profile_storage_test.cpp b/tests/profile_storage_test.cpp index 2943289..898d6f8 100644 --- a/tests/profile_storage_test.cpp +++ b/tests/profile_storage_test.cpp @@ -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(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(260), - "compaction did not preserve the latest profile"); + recovered.weak_rumble_scale == static_cast(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"; diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 8f1d751..345254c 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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(" 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(" 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 diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py index a5a95d2..45127d2 100644 --- a/tests/test_profile_web.py +++ b/tests/test_profile_web.py @@ -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 diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index b66217f..670a9c3 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -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(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(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 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 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) {