Add Profile Studio Set A enhancements
This commit is contained in:
parent
890f47ab80
commit
8818f1f6e9
22 changed files with 1745 additions and 118 deletions
|
|
@ -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:
|
||||
|
|
|
|||
20
README.md
20
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(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<uint8_t>(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 ||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<uint8_t>(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<uint16_t>(snapshot.state.left_stick_x));
|
||||
write_u16(
|
||||
&payload[28],
|
||||
static_cast<uint16_t>(snapshot.state.left_stick_y));
|
||||
write_u16(
|
||||
&payload[30],
|
||||
static_cast<uint16_t>(snapshot.state.right_stick_x));
|
||||
write_u16(
|
||||
&payload[32],
|
||||
static_cast<uint16_t>(snapshot.state.right_stick_y));
|
||||
write_u16(&payload[26],
|
||||
static_cast<uint16_t>(snapshot.state.left_stick_x));
|
||||
write_u16(&payload[28],
|
||||
static_cast<uint16_t>(snapshot.state.left_stick_y));
|
||||
write_u16(&payload[30],
|
||||
static_cast<uint16_t>(snapshot.state.right_stick_x));
|
||||
write_u16(&payload[32],
|
||||
static_cast<uint16_t>(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<uint16_t>(motion.accel_x));
|
||||
write_u16(&payload[42],
|
||||
static_cast<uint16_t>(motion.accel_y));
|
||||
write_u16(&payload[44],
|
||||
static_cast<uint16_t>(motion.accel_z));
|
||||
write_u16(&payload[46],
|
||||
static_cast<uint16_t>(motion.gyro_x));
|
||||
write_u16(&payload[48],
|
||||
static_cast<uint16_t>(motion.gyro_y));
|
||||
write_u16(&payload[50],
|
||||
static_cast<uint16_t>(motion.gyro_z));
|
||||
write_u16(&payload[42], static_cast<uint16_t>(motion.accel_x));
|
||||
write_u16(&payload[44], static_cast<uint16_t>(motion.accel_y));
|
||||
write_u16(&payload[46], static_cast<uint16_t>(motion.accel_z));
|
||||
write_u16(&payload[48], static_cast<uint16_t>(motion.gyro_x));
|
||||
write_u16(&payload[50], static_cast<uint16_t>(motion.gyro_y));
|
||||
write_u16(&payload[52], static_cast<uint16_t>(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<uint8_t>(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<uint32_t>(payload[0]) |
|
||||
(static_cast<uint32_t>(payload[1]) << 8) |
|
||||
(static_cast<uint32_t>(payload[2]) << 16) |
|
||||
(static_cast<uint32_t>(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<const char*>(&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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<I", transaction_id)
|
||||
+ identity.to_bytes()
|
||||
+ bytes((profile_index, len(encoded)))
|
||||
+ encoded,
|
||||
)
|
||||
return _wait_for_profile_transaction(
|
||||
device, transaction_id, timeout
|
||||
)
|
||||
|
||||
|
||||
def identify_controller(
|
||||
device: UsbDevice, identity: ControllerIdentity
|
||||
) -> 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("<hhhhHH", payload, 26)
|
||||
)
|
||||
motion_values = struct.unpack_from("<hhhhhh", payload, 40)
|
||||
motion_values = struct.unpack_from("<hhhhhh", payload, 42)
|
||||
if not has_motion and any(motion_values):
|
||||
raise ConfigManagerError("playtest motion sample was not declared")
|
||||
return ProfilePlaytest(
|
||||
|
|
@ -2168,6 +2315,8 @@ def parse_profile_playtest(envelope: Envelope) -> 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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@
|
|||
<label class="field-label" for="identitySelect">Profile owner</label>
|
||||
<select id="identitySelect" class="select"></select>
|
||||
<p class="field-help">The default applies when a controller has no dedicated layout.</p>
|
||||
<div class="identity-tools">
|
||||
<label class="field-label" for="controllerAlias">Controller alias</label>
|
||||
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname">
|
||||
<div class="identity-actions">
|
||||
<button class="button button-secondary" id="saveAliasButton" type="button">Save alias</button>
|
||||
<button class="button button-ghost" id="identifyButton" type="button">Identify</button>
|
||||
</div>
|
||||
<p class="controller-details" id="controllerDetails"></p>
|
||||
</div>
|
||||
|
||||
<div class="profile-list" id="profileList" aria-label="Profiles"></div>
|
||||
|
||||
|
|
@ -57,8 +66,17 @@
|
|||
<span class="active-badge" id="activeBadge" hidden>Active</span>
|
||||
<span class="dirty-badge" id="dirtyBadge" hidden>Unsaved</span>
|
||||
</div>
|
||||
<div class="profile-name-editor">
|
||||
<label for="profileName">Profile name</label>
|
||||
<input id="profileName" maxlength="31" placeholder="Optional name">
|
||||
<button class="button button-secondary" id="saveProfileNameButton" type="button">Rename</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="button button-ghost" id="copyProfileButton" type="button">Copy</button>
|
||||
<button class="button button-ghost" id="exportProfileButton" type="button">Export</button>
|
||||
<button class="button button-ghost" id="importProfileButton" type="button">Import</button>
|
||||
<input id="importProfileFile" type="file" accept="application/json,.json" hidden>
|
||||
<button class="button button-ghost" id="resetDraftButton" type="button">Start from defaults</button>
|
||||
<button class="button button-secondary" id="activateButton" type="button">Make active</button>
|
||||
<button class="button button-primary" id="saveButton" type="button">Save to Pico</button>
|
||||
|
|
@ -85,7 +103,7 @@
|
|||
<p class="eyebrow">01 · Controls</p>
|
||||
<h3>Button mapping</h3>
|
||||
</div>
|
||||
<p>Choose what each physical button should produce.</p>
|
||||
<div class="panel-heading-actions"><p>Choose what each physical button should produce.</p><button class="section-reset" type="button" data-reset-section="mapping">Reset section</button></div>
|
||||
</div>
|
||||
<div class="controller-mapper">
|
||||
<div class="controller-canvas" id="controllerCanvas" data-style="generic">
|
||||
|
|
@ -149,7 +167,7 @@
|
|||
<p class="eyebrow">02 · Response</p>
|
||||
<h3>Sticks & triggers</h3>
|
||||
</div>
|
||||
<p>Values use the controller's normalized integer range.</p>
|
||||
<div class="panel-heading-actions"><p>Values use the controller's normalized integer range.</p><button class="section-reset" type="button" data-reset-section="analog">Reset section</button></div>
|
||||
</div>
|
||||
<div class="playtest-panel" id="playtestPanel" data-state="waiting">
|
||||
<div class="playtest-heading">
|
||||
|
|
@ -231,7 +249,7 @@
|
|||
<p class="eyebrow">03 · Identity</p>
|
||||
<h3>Feedback</h3>
|
||||
</div>
|
||||
<p>Scale rumble and choose profile-change confirmation.</p>
|
||||
<div class="panel-heading-actions"><p>Scale rumble and choose profile-change confirmation.</p><button class="section-reset" type="button" data-reset-section="feedback">Reset section</button></div>
|
||||
</div>
|
||||
<div class="subpanel" id="rumbleFields"></div>
|
||||
</section>
|
||||
|
|
@ -242,7 +260,7 @@
|
|||
<p class="eyebrow">04 · Repeat</p>
|
||||
<h3>Turbo behavior</h3>
|
||||
</div>
|
||||
<p>Off, hold-to-repeat, or press-once Auto Burst per button.</p>
|
||||
<div class="panel-heading-actions"><p>Off, hold-to-repeat, or press-once Auto Burst per button.</p><button class="section-reset" type="button" data-reset-section="turbo">Reset section</button></div>
|
||||
</div>
|
||||
<div class="mapping-grid compact" id="turboFields"></div>
|
||||
</section>
|
||||
|
|
@ -253,6 +271,7 @@
|
|||
<p class="eyebrow">05 · Actions</p>
|
||||
<h3>Bindings & custom macro</h3>
|
||||
</div>
|
||||
<div class="panel-heading-actions"><button class="section-reset" type="button" data-reset-section="macro">Reset section</button></div>
|
||||
</div>
|
||||
<div class="builtin-actions" id="builtinActions"></div>
|
||||
<div class="macro-controls" id="macroControls"></div>
|
||||
|
|
@ -271,6 +290,20 @@
|
|||
</section>
|
||||
</main>
|
||||
|
||||
<dialog class="copy-dialog" id="copyDialog">
|
||||
<form method="dialog">
|
||||
<p class="eyebrow">Duplicate profile</p>
|
||||
<h3>Choose a destination</h3>
|
||||
<label class="field-label" for="copyIdentity">Controller</label>
|
||||
<select class="select" id="copyIdentity"></select>
|
||||
<label class="field-label" for="copySlot">Profile slot</label>
|
||||
<select class="select" id="copySlot"></select>
|
||||
<div class="dialog-actions">
|
||||
<button class="button button-ghost" value="cancel">Cancel</button>
|
||||
<button class="button button-primary" id="confirmCopyButton" value="default">Copy profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/playtest.js" defer></script>
|
||||
<script src="/app.js" defer></script>
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<button class="profile-button${selected ? " selected" : ""}" type="button" data-profile-index="${index}">
|
||||
<span class="profile-number"><span>${index + 1}</span>Profile ${index + 1}</span>
|
||||
<span class="profile-number"><span>${index + 1}</span>${escapeHtml(state.profileNames[index] || `Profile ${index + 1}`)}</span>
|
||||
${active ? '<span class="mini-active">Active</span>' : ""}
|
||||
</button>`;
|
||||
}).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() {
|
|||
</label>`).join("")}
|
||||
</div>` : "";
|
||||
return `
|
||||
<div class="subpanel">
|
||||
<div class="subpanel" data-analog-group="${group}" data-analog-side="${side}">
|
||||
<div class="subpanel-heading"><h4>${title}</h4><span>${group === "sticks" ? "Signed axes · 32767 full scale" : "Unsigned · 65535 full scale"}</span></div>
|
||||
<div class="curve-editor">
|
||||
<svg viewBox="0 0 100 60" role="img" aria-label="${title} response curve">
|
||||
<path class="curve-guide" d="M 0 60 L 100 0"></path>
|
||||
<path class="curve-line" d="${curvePath(config.curve_q8_8)}"></path>
|
||||
<circle class="curve-marker" cx="0" cy="60" r="3"></circle>
|
||||
</svg>
|
||||
<div class="curve-controls">
|
||||
<label>Response preset
|
||||
<select class="select" data-kind="curve-preset" data-group="${group}" data-side="${side}">
|
||||
${Object.keys(curvePresets).map((preset) => `<option value="${preset}"${curvePreset(config.curve_q8_8) === preset ? " selected" : ""}>${label(preset)}</option>`).join("")}
|
||||
<option value="custom"${curvePreset(config.curve_q8_8) === "custom" ? " selected" : ""}>Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Fine adjustment <output>${config.curve_q8_8}</output>
|
||||
<input type="range" min="1" max="2048" step="1" value="${config.curve_q8_8}" data-kind="curve-range" data-group="${group}" data-side="${side}">
|
||||
</label>
|
||||
<button class="button button-ghost copy-side" type="button" data-copy-analog="${group}" data-source-side="${side}">Apply to ${side === "left" ? "right" : "left"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="number-grid">
|
||||
${fields.map(([field, fieldLabel, min, max]) => `
|
||||
<div class="number-field${field === "digital_threshold" ? " wide" : ""}">
|
||||
|
|
@ -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) => (
|
||||
`<option value="${identity.index}">${escapeHtml(identity.label)}</option>`
|
||||
)).join("");
|
||||
elements.copyIdentity.value = String(state.identityIndex);
|
||||
elements.copySlot.innerHTML = Array.from(
|
||||
{ length: state.schema.profile_capacity },
|
||||
(_, index) => `<option value="${index + 1}">Profile ${index + 1}</option>`
|
||||
).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}`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -249,12 +249,55 @@ void test_interrupted_and_corrupt_append_recovery() {
|
|||
"corrupt newest record displaced the previous record");
|
||||
}
|
||||
|
||||
void test_profile_names_and_aliases_recover() {
|
||||
erase_all();
|
||||
const ControllerIdentity stable = identity(7);
|
||||
ProfileStorage storage;
|
||||
require(storage.initialize(fake_io()), "metadata catalog did not initialize");
|
||||
require(storage.set_alias(stable, "Player one", 10) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
storage.set_profile_name(stable, 0, "Zelda", 5) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
storage.set_profile_name(stable, 7, "Desktop", 7) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"profile metadata did not append");
|
||||
char value[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(storage.get_alias(stable, value, sizeof(value)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(value, "Player one") == 0,
|
||||
"controller alias did not read back");
|
||||
|
||||
flash.fail_after_programs = flash.programs + 1;
|
||||
require(storage.set_alias(stable, "Interrupted", 11) ==
|
||||
ProfileStorageResult::kIoError,
|
||||
"interrupted alias append reported success");
|
||||
flash.fail_after_programs = -1;
|
||||
ProfileStorage reloaded;
|
||||
require(reloaded.initialize(fake_io()) &&
|
||||
reloaded.get_alias(stable, value, sizeof(value)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(value, "Player one") == 0,
|
||||
"interrupted alias displaced the previous value");
|
||||
require(reloaded.get_profile_name(stable, 0, value, sizeof(value)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(value, "Zelda") == 0 &&
|
||||
reloaded.get_profile_name(stable, 7, value, sizeof(value)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(value, "Desktop") == 0,
|
||||
"profile names did not survive reload");
|
||||
}
|
||||
|
||||
void test_compaction_preserves_latest_records() {
|
||||
erase_all();
|
||||
const ControllerIdentity global = controller_identity_global();
|
||||
ProfileStorage storage;
|
||||
require(storage.initialize(fake_io()), "catalog did not initialize");
|
||||
ControllerProfile profile = controller_profile_default(global, 0);
|
||||
require(storage.set_alias(global, "Fallback", 8) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
storage.set_profile_name(global, 0, "Compacted", 9) ==
|
||||
ProfileStorageResult::kOk,
|
||||
"compaction metadata did not append");
|
||||
for (uint16_t write = 1; write <= 260; ++write) {
|
||||
profile.weak_rumble_scale = static_cast<uint8_t>(write);
|
||||
require(storage.set(global, 0, profile) == ProfileStorageResult::kOk,
|
||||
|
|
@ -264,11 +307,19 @@ void test_compaction_preserves_latest_records() {
|
|||
"full arena did not compact into its peer");
|
||||
ProfileStorage reloaded;
|
||||
ControllerProfile recovered{};
|
||||
char metadata[PROFILE_STORAGE_METADATA_PAYLOAD_SIZE]{};
|
||||
require(reloaded.initialize(fake_io()) &&
|
||||
reloaded.get(global, 0, &recovered) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
recovered.weak_rumble_scale == static_cast<uint8_t>(260),
|
||||
"compaction did not preserve the latest profile");
|
||||
recovered.weak_rumble_scale == static_cast<uint8_t>(260) &&
|
||||
reloaded.get_alias(global, metadata, sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "Fallback") == 0 &&
|
||||
reloaded.get_profile_name(global, 0, metadata,
|
||||
sizeof(metadata)) ==
|
||||
ProfileStorageResult::kOk &&
|
||||
strcmp(metadata, "Compacted") == 0,
|
||||
"compaction did not preserve profiles and metadata");
|
||||
}
|
||||
|
||||
void test_legacy_migration_is_atomic_and_complete() {
|
||||
|
|
@ -306,6 +357,7 @@ int main() {
|
|||
test_empty_catalog_and_eight_profiles();
|
||||
test_identity_capacity_and_defaults();
|
||||
test_interrupted_and_corrupt_append_recovery();
|
||||
test_profile_names_and_aliases_recover();
|
||||
test_compaction_preserves_latest_records();
|
||||
test_legacy_migration_is_atomic_and_complete();
|
||||
std::cout << "profile storage tests passed\n";
|
||||
|
|
|
|||
|
|
@ -95,6 +95,15 @@ class FakeDevice:
|
|||
for identity in self.profile_identities
|
||||
for index in range(config_manager.PROFILE_CAPACITY)
|
||||
}
|
||||
self.profile_aliases = {
|
||||
identity.to_bytes(): "" for identity in self.profile_identities
|
||||
}
|
||||
self.profile_names = {
|
||||
(identity.to_bytes(), index): ""
|
||||
for identity in self.profile_identities
|
||||
for index in range(config_manager.PROFILE_CAPACITY)
|
||||
}
|
||||
self.identified_identities: list[bytes] = []
|
||||
self.selected_profile = (self.global_identity.to_bytes(), 0)
|
||||
self.profile_generation = 7
|
||||
self.profile_transaction_id = 0
|
||||
|
|
@ -111,6 +120,8 @@ class FakeDevice:
|
|||
self.profile_chunk_sizes: list[int] = []
|
||||
self.pending_profile_mutation: tuple[int, bytes, int] | None = None
|
||||
self.profile_transaction_pending_reads = 0
|
||||
self.playtest_battery = 251
|
||||
self.playtest_capabilities = 0x0F
|
||||
self.profile_status_responses: list[tuple[int, int]] = []
|
||||
self.playtest_connected = True
|
||||
self.playtest_slot = 1
|
||||
|
|
@ -143,8 +154,29 @@ class FakeDevice:
|
|||
def _profile_list_payload(self) -> bytes:
|
||||
payload = bytearray([len(self.profile_identities)])
|
||||
for identity in self.profile_identities:
|
||||
payload.extend(identity.to_bytes())
|
||||
payload.extend((self.active_profiles[identity.to_bytes()], 0))
|
||||
identity_bytes = identity.to_bytes()
|
||||
alias = self.profile_aliases.get(identity_bytes, "").encode("utf-8")
|
||||
payload.extend(identity_bytes)
|
||||
payload.extend((self.active_profiles[identity_bytes], 0, len(alias)))
|
||||
payload.extend(alias)
|
||||
payload.extend(bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(alias)))
|
||||
return bytes(payload)
|
||||
|
||||
def _profile_metadata_payload(self) -> bytes:
|
||||
identity, _ = self.selected_profile
|
||||
values = [
|
||||
self.profile_aliases.get(identity, ""),
|
||||
*(
|
||||
self.profile_names.get((identity, index), "")
|
||||
for index in range(config_manager.PROFILE_CAPACITY)
|
||||
),
|
||||
]
|
||||
payload = bytearray()
|
||||
for value in values:
|
||||
encoded = value.encode("utf-8")
|
||||
payload.extend((len(encoded),))
|
||||
payload.extend(encoded)
|
||||
payload.extend(bytes(config_manager.PROFILE_METADATA_MAX_BYTES - len(encoded)))
|
||||
return bytes(payload)
|
||||
|
||||
def _profile_transaction_payload(self) -> bytes:
|
||||
|
|
@ -182,8 +214,10 @@ class FakeDevice:
|
|||
*self.playtest_triggers
|
||||
)
|
||||
payload[38] = 1 if self.playtest_motion is not None else 0
|
||||
payload[39] = self.playtest_battery
|
||||
payload[40] = self.playtest_capabilities
|
||||
if self.playtest_motion is not None:
|
||||
struct.pack_into("<hhhhhh", payload, 40, *self.playtest_motion)
|
||||
struct.pack_into("<hhhhhh", payload, 42, *self.playtest_motion)
|
||||
return bytes(payload), flags
|
||||
|
||||
def _queue_profile_mutation(self, operation: int, payload: bytes) -> None:
|
||||
|
|
@ -334,6 +368,13 @@ class FakeDevice:
|
|||
schema=config_manager.PROFILE_PLAYTEST_SCHEMA_VERSION,
|
||||
generation=self.playtest_state_generation,
|
||||
)
|
||||
if request == config_manager.OP_PROFILE_METADATA_READ:
|
||||
return make_response(
|
||||
request,
|
||||
self._profile_metadata_payload(),
|
||||
schema=config_manager.PROFILE_METADATA_SCHEMA_VERSION,
|
||||
generation=self.profile_generation,
|
||||
)
|
||||
if request == config_manager.OP_PROFILE_TRANSACTION_STATUS:
|
||||
if self.profile_transaction_status == config_manager.STATUS_PENDING:
|
||||
if self.profile_transaction_pending_reads:
|
||||
|
|
@ -513,6 +554,26 @@ class FakeDevice:
|
|||
<= self.profile_transaction_index
|
||||
< config_manager.PROFILE_CAPACITY
|
||||
)
|
||||
elif request == config_manager.OP_PROFILE_METADATA_SET:
|
||||
self.profile_transaction_id = struct.unpack_from("<I", payload)[0]
|
||||
identity = payload[4:18]
|
||||
profile_index = payload[18]
|
||||
value_size = payload[19]
|
||||
value = payload[20 : 20 + value_size].decode("utf-8")
|
||||
assert len(payload) == 20 + value_size
|
||||
if profile_index == config_manager.PROFILE_NONE_BUTTON:
|
||||
self.profile_aliases[identity] = value
|
||||
else:
|
||||
self.profile_names[(identity, profile_index)] = value
|
||||
self.profile_transaction_identity = identity
|
||||
self.profile_transaction_index = profile_index
|
||||
self.profile_transaction_payload = bytearray()
|
||||
self.profile_transaction_expected_size = 0
|
||||
self.profile_transaction_expected_crc = 0
|
||||
self.profile_generation += 1
|
||||
self.profile_transaction_status = config_manager.STATUS_OK
|
||||
elif request == config_manager.OP_PROFILE_IDENTIFY:
|
||||
self.identified_identities.append(payload)
|
||||
else:
|
||||
raise AssertionError(f"unexpected OUT request {request}")
|
||||
return len(encoded)
|
||||
|
|
@ -1456,6 +1517,32 @@ def test_profile_list_select_read_and_chunked_commit() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_profile_metadata_and_identify_round_trip() -> None:
|
||||
device = FakeDevice()
|
||||
identity = device.stable_identity
|
||||
alias_status = config_manager.set_profile_metadata(
|
||||
device, identity, config_manager.PROFILE_NONE_BUTTON,
|
||||
"Desk pad", 1.0,
|
||||
)
|
||||
name_status = config_manager.set_profile_metadata(
|
||||
device, identity, 7, "Desktop", 1.0,
|
||||
)
|
||||
metadata = config_manager.read_profile_metadata(device, identity, 7)
|
||||
assert alias_status.status == config_manager.STATUS_OK
|
||||
assert name_status.stored_generation == alias_status.stored_generation + 1
|
||||
assert metadata.alias == "Desk pad"
|
||||
assert metadata.profile_names[7] == "Desktop"
|
||||
assert config_manager.list_profiles(device)[1].alias == "Desk pad"
|
||||
|
||||
config_manager.identify_controller(device, identity)
|
||||
assert device.identified_identities == [identity.to_bytes()]
|
||||
with pytest.raises(
|
||||
config_manager.ConfigManagerError,
|
||||
match="no controller to identify",
|
||||
):
|
||||
config_manager.identify_controller(device, device.global_identity)
|
||||
|
||||
|
||||
def test_profile_playtest_decodes_raw_controller_state() -> None:
|
||||
device = FakeDevice()
|
||||
playtest = config_manager.read_profile_playtest(device)
|
||||
|
|
@ -1469,6 +1556,8 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
|
|||
left_stick=(-1234, 2345),
|
||||
right_stick=(-30000, 30000),
|
||||
triggers=(123, 65000),
|
||||
battery=251,
|
||||
capabilities=0x0F,
|
||||
motion=(1, -2, 3, -4, 5, -6),
|
||||
)
|
||||
assert playtest.to_json_object()["buttons"] == [
|
||||
|
|
@ -1489,6 +1578,8 @@ def test_profile_playtest_decodes_raw_controller_state() -> None:
|
|||
left_stick=(0, 0),
|
||||
right_stick=(0, 0),
|
||||
triggers=(0, 0),
|
||||
battery=0,
|
||||
capabilities=0,
|
||||
motion=None,
|
||||
)
|
||||
device.playtest_connected = True
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ ConfigurationServiceSnapshot current_configuration{};
|
|||
ProfileServiceListSnapshot current_profile_list{};
|
||||
ProfileServiceSelectedSnapshot current_profile_selected{};
|
||||
ProfileServiceTransactionSnapshot current_profile_transaction{};
|
||||
ProfileServiceMetadataSnapshot current_profile_metadata{};
|
||||
Bluepad32PlaytestSnapshot current_playtest[
|
||||
BLUEPAD32_INPUT_BACKEND_SLOT_COUNT]{};
|
||||
AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe;
|
||||
|
|
@ -52,6 +53,11 @@ uint32_t profile_reset_transaction_id = 0;
|
|||
uint32_t profile_commit_transaction_id = 0;
|
||||
bool profile_activate_requested = false;
|
||||
uint32_t profile_activate_transaction_id = 0;
|
||||
bool profile_metadata_requested = false;
|
||||
uint32_t profile_metadata_transaction_id = 0;
|
||||
uint8_t profile_metadata_index = 0;
|
||||
std::string profile_metadata_value;
|
||||
bool identify_requested = false;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
|
|
@ -454,12 +460,12 @@ void test_profile_vendor_requests() {
|
|||
Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize);
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
control_payload.size() == kResponseHeaderSize + 33 &&
|
||||
control_payload.size() == kResponseHeaderSize + 97 &&
|
||||
control_payload[5] ==
|
||||
static_cast<uint8_t>(Operation::kProfileList) &&
|
||||
control_payload[10] == CONTROLLER_PROFILE_SCHEMA_VERSION &&
|
||||
control_payload[kResponseHeaderSize] == 2 &&
|
||||
control_payload[kResponseHeaderSize + 31] == 2,
|
||||
control_payload[kResponseHeaderSize + 63] == 2,
|
||||
"profile list response was not encoded");
|
||||
|
||||
current_profile_selected = {};
|
||||
|
|
@ -502,6 +508,8 @@ void test_profile_vendor_requests() {
|
|||
current_playtest[2].state.right_stick_y = INT16_MAX;
|
||||
current_playtest[2].state.left_trigger = 123;
|
||||
current_playtest[2].state.right_trigger = 65000;
|
||||
current_playtest[2].battery = 201;
|
||||
current_playtest[2].capabilities = 0x0f;
|
||||
current_playtest[2].state.motion_sample_count = 1;
|
||||
current_playtest[2].state.motion_samples[0] =
|
||||
{1, -2, 3, -4, 5, -6};
|
||||
|
|
@ -531,7 +539,9 @@ void test_profile_vendor_requests() {
|
|||
read_u16(control_payload, kResponseHeaderSize + 36) ==
|
||||
65000 &&
|
||||
static_cast<int16_t>(read_u16(
|
||||
control_payload, kResponseHeaderSize + 50)) == -6,
|
||||
control_payload, kResponseHeaderSize + 52)) == -6 &&
|
||||
control_payload[kResponseHeaderSize + 39] == 201 &&
|
||||
control_payload[kResponseHeaderSize + 40] == 0x0f,
|
||||
"profile playtest response lost live controller state");
|
||||
current_playtest[2].active = false;
|
||||
require(usb_configuration_management_vendor_control(
|
||||
|
|
@ -539,6 +549,52 @@ void test_profile_vendor_requests() {
|
|||
control_payload[kResponseHeaderSize] == 0 &&
|
||||
control_payload[kResponseHeaderSize + 1] == 0xff,
|
||||
"disconnected profile playtest was not encoded");
|
||||
current_profile_metadata = {};
|
||||
current_profile_metadata.metadata.state = ProfileServiceState::kReady;
|
||||
current_profile_metadata.metadata.generation = 10;
|
||||
current_profile_metadata.status =
|
||||
ConfigurationTransactionStatus::kCommitted;
|
||||
current_profile_metadata.valid = true;
|
||||
memcpy(current_profile_metadata.alias, "Desk pad", 9);
|
||||
memcpy(current_profile_metadata.profile_names[7], "Desktop", 8);
|
||||
request = setup_request(
|
||||
Operation::kProfileMetadataRead, TUSB_DIR_IN,
|
||||
kMaximumResponseSize);
|
||||
require(usb_configuration_management_vendor_control(
|
||||
0, CONTROL_STAGE_SETUP, &request) &&
|
||||
control_payload.size() ==
|
||||
kResponseHeaderSize + kProfileMetadataPayloadSize &&
|
||||
control_payload[kResponseHeaderSize] == 8 &&
|
||||
memcmp(&control_payload[kResponseHeaderSize + 1],
|
||||
"Desk pad", 8) == 0 &&
|
||||
control_payload[
|
||||
kResponseHeaderSize +
|
||||
8 * (PROFILE_SERVICE_METADATA_MAX_BYTES + 1)] == 7,
|
||||
"profile metadata response was not encoded");
|
||||
|
||||
std::vector<uint8_t> metadata(27);
|
||||
write_u32(&metadata, 0, 0x12345678);
|
||||
require(controller_identity_encode(
|
||||
expected_identity, &metadata[4],
|
||||
CONTROLLER_IDENTITY_ENCODED_SIZE),
|
||||
"metadata identity did not encode");
|
||||
metadata[18] = 7;
|
||||
metadata[19] = 7;
|
||||
memcpy(&metadata[20], "Desktop", 7);
|
||||
perform_out(Operation::kProfileMetadataSet, metadata);
|
||||
require(profile_metadata_requested &&
|
||||
profile_metadata_transaction_id == 0x12345678 &&
|
||||
profile_metadata_index == 7 &&
|
||||
profile_metadata_value == "Desktop",
|
||||
"profile metadata mutation was not dispatched");
|
||||
|
||||
std::vector<uint8_t> identify(CONTROLLER_IDENTITY_ENCODED_SIZE);
|
||||
require(controller_identity_encode(
|
||||
expected_identity, identify.data(), identify.size()),
|
||||
"identify identity did not encode");
|
||||
perform_out(Operation::kProfileIdentify, identify);
|
||||
require(identify_requested,
|
||||
"controller Identify request was not dispatched");
|
||||
|
||||
current_profile_transaction = {};
|
||||
current_profile_transaction.metadata.state =
|
||||
|
|
@ -735,6 +791,17 @@ ConfigurationTransactionStatus profile_service_select(
|
|||
return ConfigurationTransactionStatus::kPending;
|
||||
}
|
||||
|
||||
ConfigurationTransactionStatus profile_service_set_metadata(
|
||||
uint32_t transaction_id, const ControllerIdentity& identity,
|
||||
uint8_t selected_profile, const char* value, size_t value_size) {
|
||||
profile_metadata_requested = true;
|
||||
profile_metadata_transaction_id = transaction_id;
|
||||
profile_identity = identity;
|
||||
profile_metadata_index = selected_profile;
|
||||
profile_metadata_value.assign(value, value + value_size);
|
||||
return ConfigurationTransactionStatus::kPending;
|
||||
}
|
||||
|
||||
ConfigurationTransactionStatus profile_service_begin(
|
||||
uint32_t transaction_id, const ControllerIdentity& identity,
|
||||
uint8_t selected_profile, uint16_t schema_version,
|
||||
|
|
@ -763,6 +830,17 @@ ConfigurationTransactionStatus profile_service_commit(
|
|||
return ConfigurationTransactionStatus::kPending;
|
||||
}
|
||||
|
||||
void profile_service_metadata_snapshot(
|
||||
ProfileServiceMetadataSnapshot* output) {
|
||||
*output = current_profile_metadata;
|
||||
}
|
||||
|
||||
bool bluepad32_input_backend_identify(
|
||||
const ControllerIdentity&) {
|
||||
identify_requested = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
ConfigurationTransactionStatus profile_service_reset(
|
||||
uint32_t transaction_id, const ControllerIdentity& identity,
|
||||
uint8_t selected_profile) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue