diff --git a/README.md b/README.md index 9cf6348..d165562 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ uv run switch-pico-config mode xinput uv run switch-pico-config mode dinput uv run switch-pico-config mode mac uv run switch-pico-config profiles list +uv run switch-pico-config profiles edit uv run switch-pico-config profiles export 1 profile.json --identity 0 uv run switch-pico-config profiles import 2 profile.json --identity 0 uv run switch-pico-config profiles activate 2 --identity 0 @@ -136,7 +137,11 @@ Output mode is selected before TinyUSB starts and never changes while mounted. A Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble. -`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four persistent profiles and one active index. Exported JSON contains direct logical button mappings, independent stick and trigger calibration/curves, digital trigger thresholds, weak/strong rumble scales, a profile-switching chord, one bounded eight-step macro, and per-button Turbo modes. Profile numbers shown to users are `1` through `4`; `--identity` uses the zero-based index from `profiles list`. +`profiles edit` starts a local-only browser editor at `http://127.0.0.1:8765/`. It exposes every profile field: all 16 buttons plus the L2/R2 analog triggers can be remapped to any button or trigger output; both sticks and triggers retain independent deadzone/saturation/curve settings; and rumble, confirmation, Turbo/Auto Burst, built-in action chords, and the complete bounded custom macro sequence are editable. Select a controller identity and one of its four profile slots, use **Start from defaults** for a new draft, then **Save to Pico**. The backend validates the complete profile before using the existing chunked atomic transaction; invalid drafts never reach flash. Use `profiles edit --no-browser` for a printed URL or `profiles edit --port PORT` to choose another localhost port. + +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. + +`profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four 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 `4`; `--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. @@ -149,7 +154,7 @@ configuration, pairing, and profile work. ### Per-controller profiles -The default profile-switching chord is **L + R + Select + Start**. On DualSense, use **L1 + R1 + Create + Options**. A profile can replace this chord with any nonzero logical-button mask; a stored zero selects the default chord. +The profile editor lists **Cycle active profile**, **Toggle motion**, and **Run custom macro** as separate editable actions. Every action chord can contain any combination of the 16 buttons and the L2/R2 analog triggers. The default profile-switching chord is **L + R + Select + Start**; on DualSense, use **L1 + R1 + Create + Options**. A stored empty chord selects that default. - The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`. - Chord buttons are consumed locally and are not forwarded to the host. @@ -159,11 +164,11 @@ The default profile-switching chord is **L + R + Select + Start**. On DualSense, - On connection and profile changes, RGB/player LEDs briefly show the active profile color/count, then return to the persistent USB slot color/player number. - Each controller identity and each of the four active USB slots remain isolated. -Profile input processing is deterministic: physical input is mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Turbo runs at 15 activations per second while held. Auto Burst starts on one press and stops on a second press or the configured macro-cancel button. Macros contain at most eight state steps plus an explicit end and are cancelled on disconnect, profile/output-mode change, configured cancellation, or adapter configuration reset. +Profile input processing is deterministic: physical buttons and analog triggers are mapped and tuned first, Turbo or Auto Burst gates configured buttons second, and active macro overrides apply last. Turbo runs at 15 activations per second while held. Auto Burst starts on one press and stops on a second press or the configured macro-cancel control. A custom macro can be triggered or cancelled by a button or analog trigger, and its trigger can be a multi-control chord. Partial chords remain available as ordinary input. Macros contain at most seven state steps plus the required explicit end and are cancelled on disconnect, profile/output-mode change, configured cancellation, or adapter configuration reset. ### Per-controller motion toggle -Press **D-pad Up + R + Start** together to disable or re-enable motion for one controller. On DualSense, use **D-pad Up + R1 + Options**. +The default motion action is **D-pad Up + R + Start**; on DualSense, use **D-pad Up + R1 + Options**. Each profile can replace it with any button/trigger chord from the graphical editor; a stored empty chord selects the default. - A longer rumble confirms motion disabled. - A shorter rumble confirms motion enabled. @@ -171,7 +176,7 @@ Press **D-pad Up + R + Start** together to disable or re-enable motion for one c - Other controller slots are unaffected. - Motion returns to enabled after disconnect or reboot. -Edit `controller_hotkey_config.h` to change the chord, default state, or feedback patterns. +Edit `src/firmware/input/controller_hotkey_config.h` only to change the default motion-enabled state or its feedback patterns. ### Per-slot controller colors diff --git a/pyproject.toml b/pyproject.toml index a504030..17deced 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,4 +31,4 @@ where = ["src"] include = ["switch_pico_bridge*"] [tool.setuptools.package-data] -switch_pico_bridge = ["controller_db/gamecontrollerdb.txt"] +switch_pico_bridge = ["controller_db/gamecontrollerdb.txt", "web/*", "web/assets/*"] diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 44a42d2..597fadb 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -53,12 +53,6 @@ constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = { {0xff, 0xaa, 0x00}, {0xcc, 0x33, 0xff}, }; -constexpr uint32_t kMotionHotkeyDpadMask = - SWITCH_MOTION_HOTKEY_DPAD_MASK; -constexpr uint32_t kMotionHotkeyButtonMask = - SWITCH_MOTION_HOTKEY_BUTTON_MASK; -constexpr uint32_t kMotionHotkeyMiscMask = - SWITCH_MOTION_HOTKEY_MISC_MASK; constexpr bool kDefaultMotionEnabled = SWITCH_MOTION_DEFAULT_ENABLED != 0; constexpr uint16_t kMotionDisabledFeedbackDurationMs = @@ -76,9 +70,6 @@ constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude = static_assert(kProfileFeedbackPhaseDurationMs == 75); static_assert(CONTROLLER_PROFILE_COUNT == 4); -static_assert(kMotionHotkeyDpadMask != 0); -static_assert(kMotionHotkeyButtonMask != 0); -static_assert(kMotionHotkeyMiscMask != 0); static_assert(kMotionDisabledFeedbackDurationMs > 0); static_assert(kMotionEnabledFeedbackDurationMs > 0); @@ -153,7 +144,6 @@ struct BackendSlot { bool active; bool rumble_pending; bool motion_enabled; - bool motion_hotkey_latched; bool feedback_pending; uint32_t feedback_until_ms; uint8_t pending_profile_feedback_count; @@ -708,10 +698,6 @@ constexpr uint16_t logical_button_mask( : 0u)); } -constexpr uint16_t kMotionHotkeyLogicalButtonMask = - logical_button_mask( - kMotionHotkeyDpadMask, kMotionHotkeyButtonMask, - kMotionHotkeyMiscMask); uint16_t logical_button_mask(const uni_gamepad_t& gamepad) { return logical_button_mask( @@ -810,10 +796,6 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad, } struct HotkeyDecision { bool motion_enabled; - uint32_t suppress_dpad; - uint32_t suppress_buttons; - uint32_t suppress_misc_buttons; - uint16_t suppress_logical_buttons; }; void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, @@ -827,7 +809,6 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, } void reset_slot_hotkeys(BackendSlot& slot) { slot.motion_enabled = kDefaultMotionEnabled; - slot.motion_hotkey_latched = false; slot.pre_hotkey_button_mask = 0; slot.feedback_pending = false; slot.feedback_until_ms = 0; @@ -844,44 +825,12 @@ void reset_slot_hotkeys(BackendSlot& slot) { HotkeyDecision update_controller_hotkeys( - uint8_t slot_index, uni_hid_device_t* device, - const uni_gamepad_t& gamepad) { - const bool motion_pressed = - (gamepad.dpad & kMotionHotkeyDpadMask) == - kMotionHotkeyDpadMask && - (gamepad.buttons & kMotionHotkeyButtonMask) == - kMotionHotkeyButtonMask && - (gamepad.misc_buttons & kMotionHotkeyMiscMask) == - kMotionHotkeyMiscMask; - HotkeyDecision decision{ - kDefaultMotionEnabled, 0, 0, 0, 0}; - + uint8_t slot_index, uni_hid_device_t* device) { + HotkeyDecision decision{kDefaultMotionEnabled}; critical_section_enter_blocking(&g_state_lock); - BackendSlot& slot = g_slots[slot_index]; + const BackendSlot& slot = g_slots[slot_index]; if (slot.active && slot.device == device) { - if (motion_pressed && !slot.motion_hotkey_latched) { - slot.motion_enabled = !slot.motion_enabled; - if (slot.motion_enabled) { - queue_local_feedback( - slot, kMotionEnabledFeedbackDurationMs, - kMotionEnabledFeedbackWeakMagnitude, - kMotionEnabledFeedbackStrongMagnitude); - } else { - queue_local_feedback( - slot, kMotionDisabledFeedbackDurationMs, - kMotionDisabledFeedbackWeakMagnitude, - kMotionDisabledFeedbackStrongMagnitude); - } - } - slot.motion_hotkey_latched = motion_pressed; decision.motion_enabled = slot.motion_enabled; - if (motion_pressed) { - decision.suppress_dpad |= kMotionHotkeyDpadMask; - decision.suppress_buttons |= kMotionHotkeyButtonMask; - decision.suppress_misc_buttons |= kMotionHotkeyMiscMask; - decision.suppress_logical_buttons |= - kMotionHotkeyLogicalButtonMask; - } } critical_section_exit(&g_state_lock); return decision; @@ -1672,12 +1621,8 @@ void platform_on_controller_data(uni_hid_device_t* device, const uint16_t pre_hotkey_button_mask = logical_button_mask(gamepad); const HotkeyDecision hotkeys = update_controller_hotkeys( - static_cast(slot_index), device, gamepad); - gamepad.dpad &= ~hotkeys.suppress_dpad; - gamepad.buttons &= ~hotkeys.suppress_buttons; - gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons; - const uint16_t output_button_mask = static_cast( - pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons); + static_cast(slot_index), device); + const uint16_t output_button_mask = pre_hotkey_button_mask; publish_device_state( static_cast(slot_index), device, pre_hotkey_button_mask, @@ -1939,6 +1884,34 @@ void bluepad32_input_backend_snapshot(uint8_t slot_index, g_last_snapshot_generation[slot_index] = state_generation; } +bool bluepad32_input_backend_toggle_motion( + uint8_t slot_index, uint32_t connection_generation) { + if (!g_initialized || !valid_slot(slot_index)) { + return false; + } + bool toggled = false; + critical_section_enter_blocking(&g_state_lock); + BackendSlot& slot = g_slots[slot_index]; + if (slot.active && + slot.connection_generation == connection_generation) { + slot.motion_enabled = !slot.motion_enabled; + if (slot.motion_enabled) { + queue_local_feedback( + slot, kMotionEnabledFeedbackDurationMs, + kMotionEnabledFeedbackWeakMagnitude, + kMotionEnabledFeedbackStrongMagnitude); + } else { + queue_local_feedback( + slot, kMotionDisabledFeedbackDurationMs, + kMotionDisabledFeedbackWeakMagnitude, + kMotionDisabledFeedbackStrongMagnitude); + } + toggled = true; + } + critical_section_exit(&g_state_lock); + return toggled; +} + void bluepad32_input_backend_report_sent(uint8_t slot_index) { if (!g_initialized || !valid_slot(slot_index)) { return; diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 64f4b83..fab3f2b 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -92,6 +92,10 @@ void bluepad32_input_backend_pairing_snapshot( void bluepad32_input_backend_diagnostics( Bluepad32BackendDiagnostics* out); void bluepad32_input_backend_report_sent(uint8_t slot); +// Toggle motion and queue state feedback only for the matching live +// connection generation. +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); // Enqueue bounded local profile confirmation for the matching live connection diff --git a/src/firmware/input/controller_hotkey_config.h b/src/firmware/input/controller_hotkey_config.h index 818515f..7568a94 100644 --- a/src/firmware/input/controller_hotkey_config.h +++ b/src/firmware/input/controller_hotkey_config.h @@ -1,11 +1,7 @@ -// Compile-time AIO controller hotkey configuration. +// Compile-time AIO controller action feedback configuration. #pragma once -// Motion toggle chord: D-pad Up + R + START / Options. -#define SWITCH_MOTION_HOTKEY_DPAD_MASK DPAD_UP -#define SWITCH_MOTION_HOTKEY_BUTTON_MASK BUTTON_SHOULDER_R -#define SWITCH_MOTION_HOTKEY_MISC_MASK MISC_BUTTON_START #define SWITCH_MOTION_DEFAULT_ENABLED 1 // A longer pulse confirms disabled; a shorter pulse confirms enabled. diff --git a/src/firmware/profile/controller_profile.cpp b/src/firmware/profile/controller_profile.cpp index 304ba84..bbb658f 100644 --- a/src/firmware/profile/controller_profile.cpp +++ b/src/firmware/profile/controller_profile.cpp @@ -17,6 +17,8 @@ constexpr uint8_t kMacroOverrideMask = kControllerProfileOverrideLeftTrigger | kControllerProfileOverrideRightTrigger; constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000; +constexpr uint32_t kLogicalControlMask = + (1u << CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) - 1u; uint16_t profile_read_u16(const uint8_t* input) { return static_cast(input[0]) | @@ -50,6 +52,11 @@ bool valid_button(uint8_t button) { button == CONTROLLER_PROFILE_NO_BUTTON; } +bool valid_control_output(uint8_t output) { + return output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT || + output == CONTROLLER_PROFILE_NO_BUTTON; +} + bool valid_macro_step(const ControllerProfileMacroStep& step, bool must_end) { if (must_end) { @@ -140,19 +147,24 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity, stick.invert_x = false; stick.invert_y = false; } - for (ControllerProfileTriggerConfiguration& trigger : profile.triggers) { + for (uint8_t index = 0; index < 2; ++index) { + ControllerProfileTriggerConfiguration& trigger = + profile.triggers[index]; trigger.lower_deadzone = 0; trigger.upper_saturation = UINT16_MAX; trigger.curve_q8_8 = 256; trigger.digital_threshold = CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + trigger.output = static_cast( + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index); } profile.weak_rumble_scale = UINT8_MAX; profile.strong_rumble_scale = UINT8_MAX; profile.confirmation_policy = ControllerProfileConfirmationPolicy::kRumbleAndLed; profile.switching_chord = 0; - profile.macro_trigger = CONTROLLER_PROFILE_NO_BUTTON; + profile.motion_toggle_chord = 0; + profile.macro_trigger_mask = 0; profile.macro_cancel = CONTROLLER_PROFILE_NO_BUTTON; profile.macro_step_count = 1; for (ControllerProfileMacroStep& step : profile.macro_steps) { @@ -164,7 +176,7 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity, bool controller_profile_validate(const ControllerProfile& profile) { for (uint8_t output : profile.button_map) { - if (!valid_button(output)) { + if (!valid_control_output(output)) { return false; } } @@ -174,18 +186,31 @@ bool controller_profile_validate(const ControllerProfile& profile) { return false; } } + bool routed_triggers[2]{}; for (const ControllerProfileTriggerConfiguration& trigger : profile.triggers) { if (trigger.lower_deadzone >= trigger.upper_saturation || - trigger.curve_q8_8 == 0) { + trigger.curve_q8_8 == 0 || + !valid_control_output(trigger.output)) { return false; } + if (trigger.output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + trigger.output <= CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL) { + const uint8_t target = static_cast( + trigger.output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + if (routed_triggers[target]) { + return false; + } + routed_triggers[target] = true; + } } if (static_cast(profile.confirmation_policy) > static_cast( ControllerProfileConfirmationPolicy::kRumbleAndLed) || - !valid_button(profile.macro_trigger) || - !valid_button(profile.macro_cancel) || + !valid_control_output(profile.macro_cancel) || + (profile.switching_chord & ~kLogicalControlMask) != 0 || + (profile.motion_toggle_chord & ~kLogicalControlMask) != 0 || + (profile.macro_trigger_mask & ~kLogicalControlMask) != 0 || profile.macro_step_count == 0 || profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) { return false; @@ -237,14 +262,23 @@ bool controller_profile_encode(const ControllerProfile& profile, profile_write_u16(&encoded[2], trigger.upper_saturation); profile_write_u16(&encoded[4], trigger.curve_q8_8); profile_write_u16(&encoded[6], trigger.digital_threshold); + encoded[8] = trigger.output; } output[72] = profile.weak_rumble_scale; output[73] = profile.strong_rumble_scale; output[74] = static_cast(profile.confirmation_policy); - profile_write_u16(&output[76], profile.switching_chord); - output[78] = profile.macro_trigger; - output[79] = profile.macro_cancel; + output[75] = static_cast( + ((profile.switching_chord >> 16) & 0x03u) | + (((profile.macro_trigger_mask >> 16) & 0x03u) << 2) | + (((profile.motion_toggle_chord >> 16) & 0x03u) << 4)); + profile_write_u16( + &output[76], static_cast(profile.switching_chord)); + profile_write_u16( + &output[78], static_cast(profile.macro_trigger_mask)); output[80] = profile.macro_step_count; + output[81] = profile.macro_cancel; + profile_write_u16( + &output[98], static_cast(profile.motion_toggle_chord)); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { output[82 + index] = @@ -275,14 +309,26 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, return false; } const uint16_t schema_version = profile_read_u16(&input[0]); - if ((schema_version != CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION && - schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) || + const bool has_control_mapping = + schema_version >= CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION; + const bool has_action_trigger_bits = + schema_version == CONTROLLER_PROFILE_SCHEMA_VERSION; + const bool trigger_extension_valid = + has_control_mapping + ? input[61] == 0 && input[71] == 0 + : profile_bytes_are_zero(&input[60], 2) && + profile_bytes_are_zero(&input[70], 2); + if (schema_version < CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION || + schema_version > CONTROLLER_PROFILE_SCHEMA_VERSION || profile_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE || !profile_bytes_are_zero(&input[31], 5) || !profile_bytes_are_zero(&input[47], 5) || - !profile_bytes_are_zero(&input[60], 2) || - !profile_bytes_are_zero(&input[70], 2) || input[75] != 0 || - input[81] != 0 || !profile_bytes_are_zero(&input[98], 2) || + !trigger_extension_valid || + (has_action_trigger_bits ? (input[75] & 0xc0u) != 0 + : input[75] != 0) || + (!has_control_mapping && input[81] != 0) || + (!has_control_mapping && + !profile_bytes_are_zero(&input[98], 2)) || !profile_bytes_are_zero(&input[252], 4)) { return false; } @@ -312,6 +358,11 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, trigger.upper_saturation = profile_read_u16(&encoded[2]); trigger.curve_q8_8 = profile_read_u16(&encoded[4]); trigger.digital_threshold = profile_read_u16(&encoded[6]); + trigger.output = + has_control_mapping + ? encoded[8] + : static_cast( + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index); if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION && trigger.digital_threshold == kLegacyDefaultDigitalThreshold) { trigger.digital_threshold = @@ -323,8 +374,29 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size, profile.confirmation_policy = static_cast(input[74]); profile.switching_chord = profile_read_u16(&input[76]); - profile.macro_trigger = input[78]; - profile.macro_cancel = input[79]; + profile.motion_toggle_chord = + has_control_mapping ? profile_read_u16(&input[98]) : 0; + if (has_control_mapping) { + profile.macro_trigger_mask = profile_read_u16(&input[78]); + profile.macro_cancel = input[81]; + } else { + if (!valid_button(input[78])) { + return false; + } + profile.macro_trigger_mask = + input[78] < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT + ? static_cast(1u << input[78]) + : 0; + profile.macro_cancel = input[79]; + } + if (has_action_trigger_bits) { + profile.switching_chord |= + static_cast(input[75] & 0x03u) << 16; + profile.macro_trigger_mask |= + static_cast((input[75] >> 2) & 0x03u) << 16; + profile.motion_toggle_chord |= + static_cast((input[75] >> 4) & 0x03u) << 16; + } profile.macro_step_count = input[80]; for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { diff --git a/src/firmware/profile/controller_profile.h b/src/firmware/profile/controller_profile.h index 84ffea4..f74075b 100644 --- a/src/firmware/profile/controller_profile.h +++ b/src/firmware/profile/controller_profile.h @@ -6,10 +6,15 @@ #include "core/controller_identity.h" constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1; -constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 2; +constexpr uint16_t CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2; +constexpr uint16_t CONTROLLER_PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3; +constexpr uint16_t CONTROLLER_PROFILE_SCHEMA_VERSION = 4; constexpr size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256; constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4; constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16; +constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT = 18; +constexpr uint8_t CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL = 16; +constexpr uint8_t CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL = 17; constexpr uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8; constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000; constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff; @@ -99,6 +104,8 @@ struct ControllerProfileTriggerConfiguration { // Compared against the transformed uint16 trigger output. uint16_t digital_threshold = CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD; + // Logical control output (button, analog trigger, or NO_BUTTON). + uint8_t output = CONTROLLER_PROFILE_NO_BUTTON; }; struct ControllerProfileMacroStep { @@ -123,8 +130,9 @@ struct ControllerProfile { uint8_t strong_rumble_scale = UINT8_MAX; ControllerProfileConfirmationPolicy confirmation_policy = ControllerProfileConfirmationPolicy::kRumbleAndLed; - uint16_t switching_chord = 0; - uint8_t macro_trigger = CONTROLLER_PROFILE_NO_BUTTON; + uint32_t switching_chord = 0; + uint32_t motion_toggle_chord = 0; + uint32_t macro_trigger_mask = 0; uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON; uint8_t macro_step_count = 1; ControllerProfileTurboMode diff --git a/src/firmware/profile/controller_profile_runtime.cpp b/src/firmware/profile/controller_profile_runtime.cpp index c02f97f..72acf57 100644 --- a/src/firmware/profile/controller_profile_runtime.cpp +++ b/src/firmware/profile/controller_profile_runtime.cpp @@ -20,8 +20,9 @@ struct ControllerProfileRuntimeContext { uint32_t configuration_reset_generation = 0; bool switching_chord_held = false; bool switching_chord_armed = true; + bool motion_toggle_chord_held = false; bool switching_activation_requested = false; - uint16_t held_switching_chord = 0; + uint32_t held_switching_chord = 0; uint8_t switching_target_profile_index = 0; uint32_t switching_transaction_id = 0; bool profile_change_pending = false; @@ -49,12 +50,19 @@ uint32_t next_activation_transaction_id() { return transaction_id; } -uint16_t effective_switching_chord(const ControllerProfile& profile) { +uint32_t effective_switching_chord(const ControllerProfile& profile) { return profile.switching_chord == 0 ? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD : profile.switching_chord; } +uint32_t effective_motion_toggle_chord( + const ControllerProfile& profile) { + return profile.motion_toggle_chord == 0 + ? CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD + : profile.motion_toggle_chord; +} + void initialize_defaults() { if (g_initialized) { return; @@ -77,7 +85,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context, const ControllerIdentity& identity, uint32_t connection_generation, uint32_t observed_database_generation, - uint16_t current_input_button_mask) { + const ControllerState& current_input) { ProfileServiceActiveProfileSnapshot snapshot{}; profile_service_active_profile_snapshot(identity, &snapshot); @@ -106,8 +114,6 @@ void refresh_profile(ControllerProfileRuntimeContext* context, context->pending_initial_profile_indication = {}; } - controller_synthetic_input_cancel(&context->synthetic, - current_input_button_mask); context->active = true; context->connection_generation = connection_generation; context->identity = identity; @@ -117,6 +123,15 @@ void refresh_profile(ControllerProfileRuntimeContext* context, context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0; context->profile_snapshot_valid = snapshot.valid; context->profile = snapshot.valid ? snapshot.profile : g_default_profile; + const uint32_t current_input_control_mask = + controller_profile_extract_control_mask( + current_input, context->profile); + controller_synthetic_input_cancel( + &context->synthetic, current_input_control_mask); + const uint32_t motion_chord = + effective_motion_toggle_chord(context->profile); + context->motion_toggle_chord_held = + (current_input_control_mask & motion_chord) == motion_chord; if (!context->initial_profile_indication_resolved && snapshot.valid) { context->initial_profile_indication_resolved = true; const uint8_t policy = static_cast( @@ -173,30 +188,25 @@ ControllerProfileRuntimeContext* update_context( context.database_generation != database_generation) { refresh_profile( &context, snapshot.identity, snapshot.connection_generation, - database_generation, - controller_profile_extract_button_mask(snapshot.state)); + database_generation, snapshot.state); } return &context; } void process_profile_switching( ControllerProfileRuntimeContext* context, const ControllerIdentity& identity, - uint16_t switching_input_button_mask, - uint16_t current_input_button_mask, + uint32_t switching_input_control_mask, ControllerState* consumed_input) { if (context == nullptr || consumed_input == nullptr) { return; } if (context->switching_chord_held) { - if ((switching_input_button_mask & + if ((switching_input_control_mask & context->held_switching_chord) == context->held_switching_chord) { - controller_profile_apply_button_mask( - static_cast( - current_input_button_mask & - ~context->held_switching_chord), - consumed_input); + controller_profile_remove_control_mask( + context->held_switching_chord, consumed_input); if (!context->switching_activation_requested) { const ConfigurationTransactionStatus status = profile_service_activate_internal( @@ -214,10 +224,10 @@ void process_profile_switching( context->switching_transaction_id = 0; } - const uint16_t chord = + const uint32_t chord = effective_switching_chord(context->profile); const bool chord_fully_held = - (switching_input_button_mask & chord) == chord; + (switching_input_control_mask & chord) == chord; if (!chord_fully_held) { context->switching_chord_armed = true; return; @@ -235,10 +245,8 @@ void process_profile_switching( CONTROLLER_PROFILE_COUNT); context->switching_transaction_id = next_activation_transaction_id(); - controller_profile_apply_button_mask( - static_cast( - current_input_button_mask & ~context->held_switching_chord), - consumed_input); + controller_profile_remove_control_mask( + context->held_switching_chord, consumed_input); const ConfigurationTransactionStatus status = profile_service_activate_internal( context->switching_transaction_id, identity, @@ -269,11 +277,30 @@ ControllerProfileTransformResult controller_profile_runtime_transform( return g_neutral_output; } ControllerState consumed_input = snapshot.state; - const uint16_t current_input_button_mask = - controller_profile_extract_button_mask(snapshot.state); + const uint32_t state_control_mask = + controller_profile_extract_control_mask( + snapshot.state, context->profile); + const uint32_t input_control_mask = + (state_control_mask & ~0xffffu) | + snapshot.pre_hotkey_button_mask; + const uint32_t motion_toggle_chord = + effective_motion_toggle_chord(context->profile); + const bool motion_toggle_chord_held = + (input_control_mask & motion_toggle_chord) == + motion_toggle_chord; + if (motion_toggle_chord_held) { + if (!context->motion_toggle_chord_held) { + bluepad32_input_backend_toggle_motion( + slot, snapshot.connection_generation); + } + controller_profile_remove_control_mask( + motion_toggle_chord, &consumed_input); + } + context->motion_toggle_chord_held = + motion_toggle_chord_held; process_profile_switching( - context, snapshot.identity, snapshot.pre_hotkey_button_mask, - current_input_button_mask, &consumed_input); + context, snapshot.identity, input_control_mask, + &consumed_input); const uint32_t reset_generation = configuration_service_reset_generation(); @@ -286,7 +313,8 @@ ControllerProfileTransformResult controller_profile_runtime_transform( reset_generation) { controller_synthetic_input_cancel( &context->synthetic, - controller_profile_extract_button_mask(consumed_input)); + controller_profile_extract_control_mask( + consumed_input, context->profile)); context->output_mode = output_mode; context->configuration_reset_generation = reset_generation; } diff --git a/src/firmware/profile/controller_profile_runtime.h b/src/firmware/profile/controller_profile_runtime.h index 96783f9..21fa0d5 100644 --- a/src/firmware/profile/controller_profile_runtime.h +++ b/src/firmware/profile/controller_profile_runtime.h @@ -18,6 +18,14 @@ constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD = ControllerProfileLogicalButton::kSelect)) | (1u << static_cast( ControllerProfileLogicalButton::kStart))); +constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD = + static_cast( + (1u << static_cast( + ControllerProfileLogicalButton::kDpadUp)) | + (1u << static_cast( + ControllerProfileLogicalButton::kRightShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kStart))); struct ControllerProfileRuntimeProfileChangeEvent { uint32_t connection_generation = 0; diff --git a/src/firmware/profile/controller_profile_transform.cpp b/src/firmware/profile/controller_profile_transform.cpp index 883aa07..1002ed0 100644 --- a/src/firmware/profile/controller_profile_transform.cpp +++ b/src/firmware/profile/controller_profile_transform.cpp @@ -177,6 +177,26 @@ uint16_t transform_trigger( kQ16One); } +void route_trigger_output( + uint8_t output, uint16_t value, uint16_t digital_threshold, + uint16_t* output_button_mask, uint16_t output_triggers[2], + uint16_t output_thresholds[2]) { + if (output < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT) { + if (value >= digital_threshold) { + *output_button_mask |= static_cast(1u << output); + } + return; + } + if (output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + const uint8_t trigger_index = static_cast( + output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + if (value > output_triggers[trigger_index]) { + output_triggers[trigger_index] = value; + } + output_thresholds[trigger_index] = digital_threshold; + } +} + } // namespace uint16_t controller_profile_extract_button_mask(const ControllerState& state) { @@ -232,6 +252,44 @@ uint16_t controller_profile_extract_button_mask(const ControllerState& state) { return mask; } +uint32_t controller_profile_extract_control_mask( + const ControllerState& state, const ControllerProfile& profile) { + uint32_t mask = controller_profile_extract_button_mask(state); + const uint16_t trigger_values[2] = { + transform_trigger(state.left_trigger, profile.triggers[0]), + transform_trigger(state.right_trigger, profile.triggers[1]), + }; + for (uint8_t index = 0; index < 2; ++index) { + if (trigger_values[index] >= + profile.triggers[index].digital_threshold) { + mask |= static_cast( + 1u << (CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index)); + } + } + return mask; +} + +void controller_profile_remove_control_mask( + uint32_t control_mask, ControllerState* state) { + if (state == nullptr) { + return; + } + const uint16_t buttons = + controller_profile_extract_button_mask(*state); + controller_profile_apply_button_mask( + static_cast( + buttons & ~static_cast(control_mask)), + state); + if ((control_mask & + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL)) != 0) { + state->left_trigger = 0; + } + if ((control_mask & + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL)) != 0) { + state->right_trigger = 0; + } +} + void controller_profile_apply_button_mask(uint16_t button_mask, ControllerState* state) { if (state == nullptr) { @@ -297,9 +355,42 @@ ControllerProfileTransformResult controller_profile_transform( result.state = input; const uint16_t input_button_mask = controller_profile_extract_button_mask(input); - controller_profile_apply_button_mask( - controller_profile_map_button_mask(input_button_mask, profile), - &result.state); + uint16_t output_button_mask = + controller_profile_map_button_mask(input_button_mask, profile); + uint16_t output_triggers[2]{}; + uint16_t output_thresholds[2] = { + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, + }; + + for (uint8_t input_button = 0; + input_button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + ++input_button) { + if ((input_button_mask & + static_cast(1u << input_button)) == 0) { + continue; + } + const uint8_t output = profile.button_map[input_button]; + if (output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) { + output_triggers[ + output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL] = + UINT16_MAX; + } + } + + const uint16_t transformed_triggers[2] = { + transform_trigger(input.left_trigger, profile.triggers[0]), + transform_trigger(input.right_trigger, profile.triggers[1]), + }; + for (uint8_t input_trigger = 0; input_trigger < 2; ++input_trigger) { + route_trigger_output( + profile.triggers[input_trigger].output, + transformed_triggers[input_trigger], + profile.triggers[input_trigger].digital_threshold, + &output_button_mask, output_triggers, output_thresholds); + } + controller_profile_apply_button_mask(output_button_mask, &result.state); transform_stick(profile.sticks[0], input.left_stick_x, input.left_stick_y, &result.state.left_stick_x, @@ -307,14 +398,10 @@ ControllerProfileTransformResult controller_profile_transform( transform_stick(profile.sticks[1], input.right_stick_x, input.right_stick_y, &result.state.right_stick_x, &result.state.right_stick_y); - result.state.left_trigger = transform_trigger(input.left_trigger, - profile.triggers[0]); - result.state.right_trigger = transform_trigger(input.right_trigger, - profile.triggers[1]); - result.left_trigger_digital_threshold = - profile.triggers[0].digital_threshold; - result.right_trigger_digital_threshold = - profile.triggers[1].digital_threshold; + result.state.left_trigger = output_triggers[0]; + result.state.right_trigger = output_triggers[1]; + result.left_trigger_digital_threshold = output_thresholds[0]; + result.right_trigger_digital_threshold = output_thresholds[1]; return result; } diff --git a/src/firmware/profile/controller_profile_transform.h b/src/firmware/profile/controller_profile_transform.h index 7b4a0d8..68a77a9 100644 --- a/src/firmware/profile/controller_profile_transform.h +++ b/src/firmware/profile/controller_profile_transform.h @@ -15,6 +15,10 @@ struct ControllerProfileTransformResult { }; uint16_t controller_profile_extract_button_mask(const ControllerState& state); +uint32_t controller_profile_extract_control_mask( + const ControllerState& state, const ControllerProfile& profile); +void controller_profile_remove_control_mask( + uint32_t control_mask, ControllerState* state); void controller_profile_apply_button_mask(uint16_t button_mask, ControllerState* state); uint16_t controller_profile_map_button_mask( diff --git a/src/firmware/profile/controller_synthetic_input.cpp b/src/firmware/profile/controller_synthetic_input.cpp index 1a57ba6..2079fe2 100644 --- a/src/firmware/profile/controller_synthetic_input.cpp +++ b/src/firmware/profile/controller_synthetic_input.cpp @@ -9,8 +9,12 @@ constexpr uint16_t button_bit(uint8_t button) { return static_cast(1u << button); } -bool is_bound_button(uint8_t button) { - return button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; +constexpr uint32_t control_bit(uint8_t control) { + return static_cast(1u << control); +} + +bool is_bound_control(uint8_t control) { + return control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; } void clear_binding(ControllerSyntheticBindingState* binding) { @@ -127,12 +131,12 @@ void apply_macro_override(const ControllerProfileMacroStep& step, void controller_synthetic_input_cancel( ControllerSyntheticInputContext* context, - uint16_t current_input_button_mask) { + uint32_t current_input_control_mask) { if (context == nullptr) { return; } *context = {}; - context->previous_input_button_mask = current_input_button_mask; + context->previous_input_control_mask = current_input_control_mask; } ControllerProfileTransformResult controller_synthetic_input_apply( @@ -142,26 +146,35 @@ ControllerProfileTransformResult controller_synthetic_input_apply( return controller_profile_transform(input, profile); } - const uint16_t input_button_mask = - controller_profile_extract_button_mask(input); - uint16_t rising_button_mask = static_cast( - input_button_mask & ~context->previous_input_button_mask); + const uint32_t input_control_mask = + controller_profile_extract_control_mask(input, profile); + uint32_t rising_control_mask = + input_control_mask & ~context->previous_input_control_mask; const bool cancel_pressed = - is_bound_button(profile.macro_cancel) && - (input_button_mask & button_bit(profile.macro_cancel)) != 0; + is_bound_control(profile.macro_cancel) && + (input_control_mask & control_bit(profile.macro_cancel)) != 0; if (cancel_pressed) { - controller_synthetic_input_cancel(context, input_button_mask); - rising_button_mask = 0; + controller_synthetic_input_cancel(context, input_control_mask); + rising_control_mask = 0; } bool macro_started = false; - if (!cancel_pressed && is_bound_button(profile.macro_trigger) && - (rising_button_mask & button_bit(profile.macro_trigger)) != 0) { + const bool trigger_chord_completed = + profile.macro_trigger_mask != 0 && + (input_control_mask & profile.macro_trigger_mask) == + profile.macro_trigger_mask && + (rising_control_mask & profile.macro_trigger_mask) != 0; + if (!cancel_pressed && trigger_chord_completed) { macro_started = start_macro(context, profile, now_ms); } if (!macro_started) { advance_macro(context, profile, now_ms); } + const bool consume_macro_trigger = + context->macro_active || + (profile.macro_trigger_mask != 0 && + (input_control_mask & profile.macro_trigger_mask) == + profile.macro_trigger_mask); uint16_t gated_input_button_mask = 0; for (uint8_t input_button = 0; @@ -169,15 +182,15 @@ ControllerProfileTransformResult controller_synthetic_input_apply( ++input_button) { ControllerSyntheticBindingState& binding = context->bindings[input_button]; - if (input_button == profile.macro_trigger || + const uint16_t bit = button_bit(input_button); + if ((consume_macro_trigger && + (profile.macro_trigger_mask & bit) != 0) || input_button == profile.macro_cancel) { clear_binding(&binding); continue; } - - const uint16_t bit = button_bit(input_button); - const bool pressed = (input_button_mask & bit) != 0; - const bool rising = (rising_button_mask & bit) != 0; + const bool pressed = (input_control_mask & bit) != 0; + const bool rising = (rising_control_mask & bit) != 0; switch (profile.turbo_modes[input_button]) { case ControllerProfileTurboMode::kOff: clear_binding(&binding); @@ -219,6 +232,14 @@ ControllerProfileTransformResult controller_synthetic_input_apply( ControllerState gated_input = input; controller_profile_apply_button_mask(gated_input_button_mask, &gated_input); + uint32_t consumed_controls = + consume_macro_trigger ? profile.macro_trigger_mask : 0; + if (cancel_pressed) { + consumed_controls |= control_bit(profile.macro_cancel); + } + controller_profile_remove_control_mask( + consumed_controls, &gated_input); + ControllerProfileTransformResult result = controller_profile_transform(gated_input, profile); if (context->macro_active && @@ -232,6 +253,6 @@ ControllerProfileTransformResult controller_synthetic_input_apply( } } - context->previous_input_button_mask = input_button_mask; + context->previous_input_control_mask = input_control_mask; return result; } diff --git a/src/firmware/profile/controller_synthetic_input.h b/src/firmware/profile/controller_synthetic_input.h index 36c0bf9..012f392 100644 --- a/src/firmware/profile/controller_synthetic_input.h +++ b/src/firmware/profile/controller_synthetic_input.h @@ -15,7 +15,7 @@ struct ControllerSyntheticInputContext { bool macro_active = false; uint8_t macro_step_index = 0; uint32_t macro_deadline_ms = 0; - uint16_t previous_input_button_mask = 0; + uint32_t previous_input_control_mask = 0; ControllerSyntheticBindingState bindings[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{}; }; @@ -25,7 +25,7 @@ struct ControllerSyntheticInputContext { // restart until they are released and pressed again. void controller_synthetic_input_cancel( ControllerSyntheticInputContext* context, - uint16_t current_input_button_mask = 0); + uint32_t current_input_control_mask = 0); // Apply raw-input consumption, mapped physical contributions, Turbo/Auto Burst // gating, and finally the active macro step's field overrides. diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index 8944e63..0e2ba79 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import importlib import json import secrets import struct @@ -99,7 +100,9 @@ TRANSPORT_UNKNOWN = 0 TRANSPORT_CLASSIC = 1 TRANSPORT_BLE = 2 PROFILE_LEGACY_SCHEMA_VERSION = 1 -PROFILE_SCHEMA_VERSION = 2 +PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION = 2 +PROFILE_CONTROL_MAPPING_SCHEMA_VERSION = 3 +PROFILE_SCHEMA_VERSION = 4 PROFILE_SIZE = 256 PROFILE_CAPACITY = 4 PROFILE_IDENTITY_CAPACITY = 16 @@ -131,6 +134,8 @@ LOGICAL_BUTTONS = ( "dpad_left", "dpad_right", ) +LOGICAL_CONTROLS = LOGICAL_BUTTONS + ("left_trigger", "right_trigger") +PROFILE_LOGICAL_CONTROL_MASK = (1 << len(LOGICAL_CONTROLS)) - 1 RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led") TURBO_MODES = ("off", "turbo", "auto_burst") MACRO_STEP_TYPES = ("state", "end") @@ -332,6 +337,21 @@ def _button_name(value: int) -> str | None: return None return LOGICAL_BUTTONS[value] +def _control_index(value: Any, name: str) -> int: + if value is None: + return PROFILE_NONE_BUTTON + if type(value) is not str or value not in LOGICAL_CONTROLS: + choices = ", ".join(LOGICAL_CONTROLS) + raise ConfigManagerError(f"{name} must be null or one of: {choices}") + return LOGICAL_CONTROLS.index(value) + + +def _control_name(value: int) -> str | None: + if value == PROFILE_NONE_BUTTON: + return None + return LOGICAL_CONTROLS[value] + + def _button_mask_from_json(value: Any, name: str) -> int: if type(value) is not list: @@ -347,6 +367,28 @@ def _button_mask_from_json(value: Any, name: str) -> int: mask |= bit return mask +def _control_mask_from_json(value: Any, name: str) -> int: + if type(value) is not list: + raise ConfigManagerError(f"{name} must be a JSON array") + mask = 0 + for entry in value: + index = _control_index(entry, name) + if index == PROFILE_NONE_BUTTON: + raise ConfigManagerError(f"{name} cannot contain null") + bit = 1 << index + if mask & bit: + raise ConfigManagerError(f"{name} contains a duplicate control") + mask |= bit + return mask + + +def _control_mask_to_json(mask: int) -> list[str]: + return [ + name + for index, name in enumerate(LOGICAL_CONTROLS) + if mask & (1 << index) + ] + def _button_mask_to_json(mask: int) -> list[str]: return [ @@ -570,6 +612,7 @@ class TriggerConfig: upper_saturation: int curve_q8_8: int digital_threshold: int + output: int def __post_init__(self) -> None: _require_int( @@ -586,39 +629,83 @@ class TriggerConfig: _require_int( self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF ) + if ( + type(self.output) is not int + or self.output != PROFILE_NONE_BUTTON + and not 0 <= self.output < len(LOGICAL_CONTROLS) + ): + raise ConfigManagerError("invalid trigger output mapping") @classmethod - def from_bytes(cls, payload: bytes) -> TriggerConfig: - if len(payload) != 10 or payload[8:] != b"\x00\x00": + def from_bytes( + cls, + payload: bytes, + *, + schema_version: int, + source_index: int, + ) -> TriggerConfig: + if len(payload) != 10 or payload[9] != 0: raise ConfigManagerError("invalid trigger configuration encoding") - return cls(*struct.unpack("= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + else len(LOGICAL_BUTTONS) + source_index + ) + lower, upper, curve_q8_8, threshold = struct.unpack( + " bytes: return struct.pack( - " dict[str, int]: + def to_json_object(self) -> dict[str, int | str | None]: return { "lower_deadzone": self.lower_deadzone, "upper_saturation": self.upper_saturation, "curve_q8_8": self.curve_q8_8, "digital_threshold": self.digital_threshold, + "output": _control_name(self.output), } @classmethod - def from_json_object(cls, value: Any, name: str) -> TriggerConfig: + def from_json_object( + cls, + value: Any, + name: str, + *, + schema_version: int, + source_index: int, + ) -> TriggerConfig: fields = ( "lower_deadzone", "upper_saturation", "curve_q8_8", "digital_threshold", ) + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + fields += ("output",) obj = _require_object(value, fields, name) + output = ( + _control_index(obj["output"], f"{name}.output") + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + else len(LOGICAL_BUTTONS) + source_index + ) return cls( _require_int( obj["lower_deadzone"], @@ -641,8 +728,10 @@ class TriggerConfig: 0, 0xFFFF, ), + output, ) + def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig: if ( trigger.digital_threshold @@ -654,6 +743,7 @@ def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig: trigger.upper_saturation, trigger.curve_q8_8, PROFILE_DEFAULT_DIGITAL_THRESHOLD, + trigger.output, ) @@ -877,7 +967,8 @@ class ControllerProfile: strong_rumble_scale: int confirmation_policy: int switching_chord: int - macro_trigger: int + motion_toggle_chord: int + macro_trigger_mask: int macro_cancel: int macro_steps: tuple[MacroStep, ...] turbo_modes: tuple[int, ...] @@ -892,9 +983,9 @@ class ControllerProfile: for mapping in self.button_map: if type(mapping) is not int or ( mapping != PROFILE_NONE_BUTTON - and not 0 <= mapping < len(LOGICAL_BUTTONS) + and not 0 <= mapping < len(LOGICAL_CONTROLS) ): - raise ConfigManagerError("invalid logical button mapping") + raise ConfigManagerError("invalid logical control mapping") if not isinstance(self.left_stick, StickConfig) or not isinstance( self.right_stick, StickConfig ): @@ -905,6 +996,15 @@ class ControllerProfile: raise ConfigManagerError( "profile triggers must be TriggerConfig values" ) + routed_triggers = [ + trigger.output + for trigger in (self.left_trigger, self.right_trigger) + if len(LOGICAL_BUTTONS) <= trigger.output < len(LOGICAL_CONTROLS) + ] + if len(routed_triggers) != len(set(routed_triggers)): + raise ConfigManagerError( + "left and right trigger cannot target the same analog trigger" + ) _require_int( self.weak_rumble_scale, "weak rumble scale", 0, 0xFF ) @@ -917,16 +1017,29 @@ class ControllerProfile: 0, len(RUMBLE_POLICIES) - 1, ) - _require_int(self.switching_chord, "switching chord", 0, 0xFFFF) - for value, name in ( - (self.macro_trigger, "macro trigger"), - (self.macro_cancel, "macro cancel"), + _require_int( + self.switching_chord, + "switching chord", + 0, + PROFILE_LOGICAL_CONTROL_MASK, + ) + _require_int( + self.motion_toggle_chord, + "motion toggle chord", + 0, + PROFILE_LOGICAL_CONTROL_MASK, + ) + _require_int( + self.macro_trigger_mask, + "macro trigger chord", + 0, + PROFILE_LOGICAL_CONTROL_MASK, + ) + if type(self.macro_cancel) is not int or ( + self.macro_cancel != PROFILE_NONE_BUTTON + and not 0 <= self.macro_cancel < len(LOGICAL_CONTROLS) ): - if type(value) is not int or ( - value != PROFILE_NONE_BUTTON - and not 0 <= value < len(LOGICAL_BUTTONS) - ): - raise ConfigManagerError(f"invalid {name}") + raise ConfigManagerError("invalid macro cancel") if ( type(self.macro_steps) is not tuple or not 1 <= len(self.macro_steps) <= PROFILE_MACRO_STEP_CAPACITY @@ -947,20 +1060,32 @@ class ControllerProfile: @classmethod def default(cls) -> ControllerProfile: stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False) - trigger = TriggerConfig( - 0, 0xFFFF, 256, PROFILE_DEFAULT_DIGITAL_THRESHOLD + left_trigger = TriggerConfig( + 0, + 0xFFFF, + 256, + PROFILE_DEFAULT_DIGITAL_THRESHOLD, + LOGICAL_CONTROLS.index("left_trigger"), + ) + right_trigger = TriggerConfig( + 0, + 0xFFFF, + 256, + PROFILE_DEFAULT_DIGITAL_THRESHOLD, + LOGICAL_CONTROLS.index("right_trigger"), ) return cls( button_map=tuple(range(len(LOGICAL_BUTTONS))), left_stick=stick, right_stick=stick, - left_trigger=trigger, - right_trigger=trigger, + left_trigger=left_trigger, + right_trigger=right_trigger, weak_rumble_scale=0xFF, strong_rumble_scale=0xFF, confirmation_policy=3, switching_chord=0, - macro_trigger=PROFILE_NONE_BUTTON, + motion_toggle_chord=0, + macro_trigger_mask=0, macro_cancel=PROFILE_NONE_BUTTON, macro_steps=(MacroStep.end(),), turbo_modes=(0,) * len(LOGICAL_BUTTONS), @@ -973,15 +1098,26 @@ class ControllerProfile: raise ConfigManagerError("invalid profile size") version, size = struct.unpack_from(" PROFILE_SCHEMA_VERSION or size != PROFILE_SIZE ): raise ConfigManagerError("unsupported profile schema") - if payload[75] != 0 or payload[81] != 0: - raise ConfigManagerError("profile reserved fields must be zero") - if payload[98:100] != b"\x00\x00" or payload[252:] != bytes(4): + has_control_mapping = ( + version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION + ) + has_action_trigger_bits = version == PROFILE_SCHEMA_VERSION + if payload[252:] != bytes(4): raise ConfigManagerError("profile reserved fields must be zero") + if has_action_trigger_bits: + if payload[75] & 0xC0: + raise ConfigManagerError("profile action flags are invalid") + elif payload[75] != 0: + raise ConfigManagerError("legacy profile reserved fields must be zero") + if not has_control_mapping and ( + payload[81] != 0 or payload[98:100] != b"\x00\x00" + ): + raise ConfigManagerError("legacy profile reserved fields must be zero") macro_count = payload[80] if not 1 <= macro_count <= PROFILE_MACRO_STEP_CAPACITY: raise ConfigManagerError("invalid macro step count") @@ -999,11 +1135,38 @@ class ControllerProfile: step != MacroStep.end() for step in all_steps[macro_count:] ): raise ConfigManagerError("unused macro steps must be canonical end") - left_trigger = TriggerConfig.from_bytes(payload[52:62]) - right_trigger = TriggerConfig.from_bytes(payload[62:72]) + left_trigger = TriggerConfig.from_bytes( + payload[52:62], schema_version=version, source_index=0 + ) + right_trigger = TriggerConfig.from_bytes( + payload[62:72], schema_version=version, source_index=1 + ) if version == PROFILE_LEGACY_SCHEMA_VERSION: left_trigger = _migrate_legacy_trigger_threshold(left_trigger) right_trigger = _migrate_legacy_trigger_threshold(right_trigger) + if has_control_mapping: + macro_trigger_mask = struct.unpack_from("> 2) & 0x03) << 16 + motion_toggle_chord |= ((action_bits >> 4) & 0x03) << 16 return cls( button_map=tuple(payload[4:20]), left_stick=StickConfig.from_bytes(payload[20:36]), @@ -1013,9 +1176,10 @@ class ControllerProfile: weak_rumble_scale=payload[72], strong_rumble_scale=payload[73], confirmation_policy=payload[74], - switching_chord=struct.unpack_from("> 16) & 0x03) + | (((self.macro_trigger_mask >> 16) & 0x03) << 2) + | (((self.motion_toggle_chord >> 16) & 0x03) << 4) ) + struct.pack_into(" ControllerProfile: + if type(value) is not dict: + raise ConfigManagerError("profile must be a JSON object") + schema_version = _require_int( + value.get("schema_version"), + "profile.schema_version", + 0, + 0xFFFF, + ) + if ( + schema_version < PROFILE_LEGACY_SCHEMA_VERSION + or schema_version > PROFILE_SCHEMA_VERSION + ): + raise ConfigManagerError("unsupported profile schema") fields = ( "schema_version", "size", @@ -1111,16 +1291,10 @@ class ControllerProfile: "macro", "turbo", ) + if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + fields += ("motion_toggle_chord",) obj = _require_object(value, fields, "profile") - schema_version = _require_int( - obj["schema_version"], "profile.schema_version", 0, 0xFFFF - ) - if ( - schema_version - not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION) - or _require_int(obj["size"], "profile.size", 0, 0xFFFF) - != PROFILE_SIZE - ): + if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != PROFILE_SIZE: raise ConfigManagerError("unsupported profile schema") button_map = _require_object( obj["button_map"], LOGICAL_BUTTONS, "profile.button_map" @@ -1150,17 +1324,67 @@ class ControllerProfile: "profile.macro.steps must contain one to eight steps" ) left_trigger = TriggerConfig.from_json_object( - triggers["left"], "profile.triggers.left" + triggers["left"], + "profile.triggers.left", + schema_version=schema_version, + source_index=0, ) right_trigger = TriggerConfig.from_json_object( - triggers["right"], "profile.triggers.right" + triggers["right"], + "profile.triggers.right", + schema_version=schema_version, + source_index=1, ) if schema_version == PROFILE_LEGACY_SCHEMA_VERSION: left_trigger = _migrate_legacy_trigger_threshold(left_trigger) right_trigger = _migrate_legacy_trigger_threshold(right_trigger) + if schema_version == PROFILE_SCHEMA_VERSION: + switching_chord = _control_mask_from_json( + obj["switching_chord"], "profile.switching_chord" + ) + motion_toggle_chord = _control_mask_from_json( + obj["motion_toggle_chord"], + "profile.motion_toggle_chord", + ) + macro_trigger_mask = _control_mask_from_json( + macro["trigger"], "profile.macro.trigger" + ) + macro_cancel = _control_index( + macro["cancel"], "profile.macro.cancel" + ) + elif schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION: + switching_chord = _button_mask_from_json( + obj["switching_chord"], "profile.switching_chord" + ) + motion_toggle_chord = _button_mask_from_json( + obj["motion_toggle_chord"], + "profile.motion_toggle_chord", + ) + macro_trigger_mask = _button_mask_from_json( + macro["trigger"], "profile.macro.trigger" + ) + macro_cancel = _button_index( + macro["cancel"], "profile.macro.cancel" + ) + else: + switching_chord = _button_mask_from_json( + obj["switching_chord"], "profile.switching_chord" + ) + motion_toggle_chord = 0 + legacy_trigger = _button_index( + macro["trigger"], "profile.macro.trigger" + ) + macro_trigger_mask = ( + 0 + if legacy_trigger == PROFILE_NONE_BUTTON + else 1 << legacy_trigger + ) + macro_cancel = _button_index( + macro["cancel"], "profile.macro.cancel" + ) return cls( button_map=tuple( - _button_index( + _control_index( button_map[name], f"profile.button_map.{name}" ) for name in LOGICAL_BUTTONS @@ -1190,15 +1414,10 @@ class ControllerProfile: RUMBLE_POLICIES, "profile.rumble.confirmation_policy", ), - switching_chord=_button_mask_from_json( - obj["switching_chord"], "profile.switching_chord" - ), - macro_trigger=_button_index( - macro["trigger"], "profile.macro.trigger" - ), - macro_cancel=_button_index( - macro["cancel"], "profile.macro.cancel" - ), + switching_chord=switching_chord, + motion_toggle_chord=motion_toggle_chord, + macro_trigger_mask=macro_trigger_mask, + macro_cancel=macro_cancel, macro_steps=tuple( MacroStep.from_json_object( step, f"profile.macro.steps[{index}]" @@ -1535,6 +1754,8 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: _raise_status(envelope) if envelope.schema_version not in ( PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, + PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile-list schema") @@ -1600,6 +1821,8 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile: _raise_status(envelope) if envelope.schema_version not in ( PROFILE_LEGACY_SCHEMA_VERSION, + PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION, + PROFILE_CONTROL_MAPPING_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, ): raise ConfigManagerError("unsupported profile schema") @@ -2138,6 +2361,20 @@ def _identity_index(value: str) -> int: ) return index +def _tcp_port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "port must be a number from 0 to 65535" + ) from exc + if not 0 <= port <= 0xFFFF: + raise argparse.ArgumentTypeError( + "port must be a number from 0 to 65535" + ) + return port + + def _add_identity_argument(parser: argparse.ArgumentParser) -> None: parser.add_argument( @@ -2194,7 +2431,8 @@ def build_parser() -> argparse.ArgumentParser: config_reset.add_argument("--yes", action="store_true") profiles = commands.add_parser( - "profiles", help="list, import, export, or select controller profiles" + "profiles", + help="open the editor or manage controller profiles as JSON", ) profile_commands = profiles.add_subparsers( dest="profile_command", required=True @@ -2202,6 +2440,21 @@ def build_parser() -> argparse.ArgumentParser: profile_commands.add_parser( "list", help="list profile identities and active profiles" ) + profile_edit = profile_commands.add_parser( + "edit", help="open the local graphical profile editor" + ) + profile_edit.add_argument( + "--port", + type=_tcp_port, + default=8765, + metavar="PORT", + help="localhost port (default: 8765; use 0 to choose automatically)", + ) + profile_edit.add_argument( + "--no-browser", + action="store_true", + help="print the editor URL without opening a browser", + ) profile_export = profile_commands.add_parser( "export", help="export a profile as JSON" ) @@ -2275,6 +2528,18 @@ def main(argv: Sequence[str] | None = None) -> int: imported_profile: ControllerProfile | None = None try: + if args.command == "profiles" and args.profile_command == "edit": + profile_web = importlib.import_module( + ".profile_web", __package__ + ) + profile_web.run_profile_editor( + bus=args.bus, + address=args.address, + timeout=args.timeout, + port=args.port, + open_browser=not args.no_browser, + ) + return 0 if args.command == "profiles" and args.profile_command == "import": imported_profile = _load_profile(args.path) device = find_pico(args.bus, args.address, args.timeout) diff --git a/src/switch_pico_bridge/profile_web.py b/src/switch_pico_bridge/profile_web.py new file mode 100644 index 0000000..e62b41b --- /dev/null +++ b/src/switch_pico_bridge/profile_web.py @@ -0,0 +1,396 @@ +"""Local-only HTTP backend for the graphical controller profile editor.""" +from __future__ import annotations + +import json +import secrets +import webbrowser +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, HTTPServer +from importlib import resources +from typing import Any, Callable, cast +from urllib.parse import urlsplit + +import usb.core + +from . import config_manager + +_MAXIMUM_REQUEST_BYTES = 64 * 1024 +_ASSET_TYPES = { + "/": ("profile_editor.html", "text/html; charset=utf-8"), + "/app.js": ("profile_editor.js", "text/javascript; charset=utf-8"), + "/style.css": ("profile_editor.css", "text/css; charset=utf-8"), + "/assets/controller-switch-pro.svg": ( + "assets/controller-switch-pro.svg", + "image/svg+xml", + ), + "/assets/controller-dualsense.svg": ( + "assets/controller-dualsense.svg", + "image/svg+xml", + ), + "/assets/controller-xbox.svg": ( + "assets/controller-xbox.svg", + "image/svg+xml", + ), +} + +def _controller_presentation( + identity: config_manager.ControllerIdentity, +) -> dict[str, str]: + if identity.is_global_fallback: + return {"model": "Generic controller", "style": "generic"} + + known = { + (0x057E, 0x2009): ("Nintendo Switch Pro Controller", "switch"), + (0x054C, 0x0CE6): ("Sony DualSense", "playstation"), + (0x054C, 0x09CC): ("Sony DualShock 4", "playstation"), + } + exact = known.get((identity.vendor_id, identity.product_id)) + if exact is not None: + model, style = exact + return {"model": model, "style": style} + if identity.vendor_id == 0x054C: + return {"model": "Sony controller", "style": "playstation"} + if identity.vendor_id == 0x045E: + return {"model": "Xbox controller", "style": "xbox"} + if identity.vendor_id == 0x2DC8: + return {"model": "8BitDo controller", "style": "switch"} + return {"model": "Connected controller", "style": "generic"} + + + +class ProfileEditorServer(HTTPServer): + """Single-threaded server that serializes USB profile transactions.""" + + def __init__( + self, + address: tuple[str, int], + *, + bus: int | None, + device_address: int | None, + timeout: float, + ) -> None: + super().__init__(address, ProfileEditorHandler) + self.bus = bus + self.device_address = device_address + self.operation_timeout = timeout + self.mutation_token = secrets.token_urlsafe(32) + + def find_device(self) -> config_manager.UsbDevice: + return config_manager.find_pico( + self.bus, self.device_address, self.operation_timeout + ) + + +class ProfileEditorHandler(BaseHTTPRequestHandler): + @property + def profile_server(self) -> ProfileEditorServer: + return cast(ProfileEditorServer, self.server) + + + def do_GET(self) -> None: + if not self._local_host_header(): + self.send_error(HTTPStatus.FORBIDDEN) + return + + path = urlsplit(self.path).path + if path in _ASSET_TYPES: + self._serve_asset(*_ASSET_TYPES[path]) + return + if path == "/api/schema": + self._send_json( + { + "buttons": list(config_manager.LOGICAL_BUTTONS), + "controls": list(config_manager.LOGICAL_CONTROLS), + "default_switching_chord": [ + "left_shoulder", + "right_shoulder", + "select", + "start", + ], + "default_motion_toggle_chord": [ + "dpad_up", + "right_shoulder", + "start", + ], + "rumble_policies": list(config_manager.RUMBLE_POLICIES), + "turbo_modes": list(config_manager.TURBO_MODES), + "macro_overrides": list(config_manager.MACRO_OVERRIDE_NAMES), + "maximum_macro_state_steps": ( + config_manager.PROFILE_MACRO_STEP_CAPACITY - 1 + ), + "default_profile": ( + config_manager.ControllerProfile.default().to_json_object() + ), + "mutation_token": self.profile_server.mutation_token, + } + ) + return + if path == "/api/profiles": + self._api_call(self._list_profiles) + return + + selection = self._parse_profile_path(path) + if selection is not None: + identity_index, profile_index, action = selection + if action is None: + self._api_call( + lambda: self._read_profile(identity_index, profile_index) + ) + return + self.send_error(HTTPStatus.NOT_FOUND) + + 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: + self.send_error(HTTPStatus.NOT_FOUND) + return + identity_index, profile_index, _ = selection + self._api_call( + lambda: self._write_profile(identity_index, profile_index) + ) + + 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) + ) + + def _local_host_header(self) -> bool: + host = self.headers.get("Host", "") + return host in { + f"127.0.0.1:{self.profile_server.server_port}", + f"localhost:{self.profile_server.server_port}", + } + + def _allow_mutation(self) -> bool: + if not self._local_host_header(): + self.send_error(HTTPStatus.FORBIDDEN) + return False + if ( + self.headers.get("X-Switch-Pico-Token") + != self.profile_server.mutation_token + ): + self._send_json( + {"error": "missing or invalid mutation token"}, + status=HTTPStatus.FORBIDDEN, + ) + return False + return True + + @staticmethod + def _parse_profile_path(path: str) -> tuple[int, int, str | None] | None: + parts = path.strip("/").split("/") + if len(parts) not in (4, 5) or parts[:2] != ["api", "profiles"]: + return None + try: + identity_index = int(parts[2]) + profile_number = int(parts[3]) + except ValueError: + return None + if ( + identity_index < 0 + or not 1 <= profile_number <= config_manager.PROFILE_CAPACITY + ): + return None + action = parts[4] if len(parts) == 5 else None + return identity_index, profile_number - 1, action + + def _entries_and_identity( + self, device: config_manager.UsbDevice, identity_index: int + ) -> tuple[ + tuple[config_manager.ProfileListEntry, ...], + config_manager.ControllerIdentity, + ]: + entries = config_manager.list_profiles(device) + if not 0 <= identity_index < len(entries): + raise config_manager.ConfigManagerError( + f"identity index {identity_index} is out of range; " + f"refresh the profile library" + ) + return entries, entries[identity_index].identity + + def _list_profiles(self) -> dict[str, Any]: + device = self.profile_server.find_device() + entries = config_manager.list_profiles(device) + return { + "identities": [ + { + "index": index, + "label": ( + "Global fallback" + if entry.identity.is_global_fallback + else ( + f"{entry.identity.transport_text} " + f"{entry.identity.address_text} · " + f"{entry.identity.vendor_id:04X}:" + f"{entry.identity.product_id:04X}" + ) + ), + "active_profile": entry.active_profile_index + 1, + "controller": _controller_presentation(entry.identity), + } + for index, entry in enumerate(entries) + ] + } + + def _read_profile( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + device = self.profile_server.find_device() + entries, identity = self._entries_and_identity(device, identity_index) + profile = config_manager.read_profile(device, identity, profile_index) + return { + "profile": profile.to_json_object(), + "active": entries[identity_index].active_profile_index == profile_index, + } + + def _read_json_body(self) -> str: + try: + length = int(self.headers.get("Content-Length", "")) + except ValueError as exc: + raise config_manager.ConfigManagerError( + "invalid request content length" + ) from exc + if not 1 <= length <= _MAXIMUM_REQUEST_BYTES: + raise config_manager.ConfigManagerError( + "profile request must contain 1 to 65536 bytes" + ) + return self.rfile.read(length).decode("utf-8") + + def _write_profile( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + profile = config_manager.ControllerProfile.from_json(self._read_json_body()) + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.write_profile( + device, + identity, + profile_index, + profile, + self.profile_server.operation_timeout, + ) + return { + "stored_generation": status.stored_generation, + "stored_crc": f"{status.stored_crc:08x}", + } + + def _activate_profile( + self, identity_index: int, profile_index: int + ) -> dict[str, Any]: + device = self.profile_server.find_device() + _, identity = self._entries_and_identity(device, identity_index) + status = config_manager.activate_profile( + device, + identity, + profile_index, + self.profile_server.operation_timeout, + ) + return {"stored_generation": status.stored_generation} + + def _api_call( + self, operation: Callable[[], dict[str, Any]] + ) -> None: + try: + result = operation() + except config_manager.ConfigManagerError as exc: + self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST) + except usb.core.USBError as exc: + self._send_json( + {"error": f"USB access failed: {exc}"}, + status=HTTPStatus.SERVICE_UNAVAILABLE, + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + self._send_json( + {"error": f"invalid JSON request: {exc}"}, + status=HTTPStatus.BAD_REQUEST, + ) + else: + self._send_json(result) + + def _serve_asset(self, filename: str, content_type: str) -> None: + try: + payload = ( + resources.files("switch_pico_bridge") + .joinpath("web", filename) + .read_bytes() + ) + except (FileNotFoundError, OSError): + self.send_error(HTTPStatus.NOT_FOUND) + return + self._send_bytes(payload, content_type) + + def _send_json( + self, value: Any, *, status: HTTPStatus = HTTPStatus.OK + ) -> None: + self._send_bytes( + json.dumps(value, separators=(",", ":")).encode("utf-8"), + "application/json; charset=utf-8", + status=status, + ) + + def _send_bytes( + self, + payload: bytes, + content_type: str, + *, + status: HTTPStatus = HTTPStatus.OK, + ) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self'; " + "connect-src 'self'; img-src 'self'; frame-ancestors 'none'", + ) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format: str, *args: Any) -> None: + return + + +def run_profile_editor( + *, + bus: int | None, + address: int | None, + timeout: float, + port: int, + open_browser: bool, +) -> None: + try: + server = ProfileEditorServer( + ("127.0.0.1", port), + bus=bus, + device_address=address, + timeout=timeout, + ) + except OSError as exc: + raise config_manager.ConfigManagerError( + f"could not start profile editor on port {port}: {exc}" + ) from exc + + url = f"http://127.0.0.1:{server.server_port}/" + print(f"Profile editor: {url}") + print("Press Ctrl+C to stop.") + if open_browser and not webbrowser.open(url): + print(f"Could not open a browser automatically; open {url} manually.") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt b/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt new file mode 100644 index 0000000..64f58ef --- /dev/null +++ b/src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt @@ -0,0 +1,35 @@ +Controller artwork source +========================= + +The following files are redistributed from AL2009man/Gamepad-Asset-Pack +revision a6d1113b0b3b2438bbbcb08a33f9a25eb2391d7c: + +- controller-dualsense.svg +- controller-switch-pro.svg +- controller-xbox.svg + +Source: https://github.com/AL2009man/Gamepad-Asset-Pack +Copyright (c) 2024 Al. Lopez +License: MIT + +MIT License + +Copyright (c) 2024 Al. Lopez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/switch_pico_bridge/web/assets/controller-dualsense.svg b/src/switch_pico_bridge/web/assets/controller-dualsense.svg new file mode 100644 index 0000000..92a893e --- /dev/null +++ b/src/switch_pico_bridge/web/assets/controller-dualsense.svg @@ -0,0 +1,2013 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/controller-switch-pro.svg b/src/switch_pico_bridge/web/assets/controller-switch-pro.svg new file mode 100644 index 0000000..5b68d3e --- /dev/null +++ b/src/switch_pico_bridge/web/assets/controller-switch-pro.svg @@ -0,0 +1,463 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/assets/controller-xbox.svg b/src/switch_pico_bridge/web/assets/controller-xbox.svg new file mode 100644 index 0000000..a8c9e97 --- /dev/null +++ b/src/switch_pico_bridge/web/assets/controller-xbox.svg @@ -0,0 +1,1383 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/switch_pico_bridge/web/profile_editor.css b/src/switch_pico_bridge/web/profile_editor.css new file mode 100644 index 0000000..45953bd --- /dev/null +++ b/src/switch_pico_bridge/web/profile_editor.css @@ -0,0 +1,421 @@ +:root { + --ink: #f5f7fa; + --muted: #9da7b5; + --surface: rgba(18, 22, 30, 0.92); + --surface-2: #171d27; + --surface-3: #202938; + --line: rgba(255, 255, 255, 0.09); + --cyan: #56d8e6; + --cyan-strong: #25b9ca; + --coral: #ff7a6b; + --lime: #a9df78; + --warning: #f2c766; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.36); + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +[hidden] { display: none !important; } +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(circle at 20% -10%, rgba(46, 122, 136, 0.22), transparent 36rem), + radial-gradient(circle at 100% 30%, rgba(176, 72, 74, 0.14), transparent 34rem), + #0b0e13; +} + +button, input, select { font: inherit; } +button, select { cursor: pointer; } +button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { + outline: 2px solid var(--cyan); + outline-offset: 2px; +} + +.ambient { + position: fixed; + z-index: -1; + width: 26rem; + height: 26rem; + border-radius: 50%; + filter: blur(100px); + opacity: 0.12; +} +.ambient-one { top: 14rem; left: -12rem; background: var(--cyan); } +.ambient-two { right: -10rem; bottom: 4rem; background: var(--coral); } + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 76px; + padding: 14px clamp(18px, 4vw, 58px); + border-bottom: 1px solid var(--line); + background: rgba(11, 14, 19, 0.82); + backdrop-filter: blur(20px); +} +.brand { display: flex; align-items: center; gap: 14px; } +.brand-mark { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + width: 36px; + height: 42px; + padding: 6px; + border: 1px solid rgba(86, 216, 230, 0.5); + border-radius: 14px; + transform: rotate(-4deg); +} +.brand-mark span { border-radius: 8px; background: var(--cyan); } +.brand-mark span:last-child { background: var(--coral); } +h1, h2, h3, h4, p { margin: 0; } +h1 { font-size: 1.12rem; letter-spacing: -0.02em; } +h2 { font-size: 1.5rem; letter-spacing: -0.035em; } +h3 { font-size: 1.3rem; letter-spacing: -0.025em; } +h4 { font-size: 0.98rem; } +.eyebrow { + margin-bottom: 4px; + color: var(--cyan); + font-size: 0.68rem; + font-weight: 750; + letter-spacing: 0.16em; + text-transform: uppercase; +} +.connection { + display: flex; + align-items: center; + gap: 9px; + padding: 8px 12px; + color: var(--muted); + border: 1px solid var(--line); + border-radius: 999px; + font-size: 0.78rem; +} +.connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warning); + box-shadow: 0 0 12px currentColor; +} +.connection[data-state="ready"] .connection-dot { background: var(--lime); } +.connection[data-state="error"] .connection-dot { background: var(--coral); } + +.workspace { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + gap: 24px; + width: min(1500px, calc(100% - 40px)); + margin: 28px auto 80px; +} +.sidebar { + position: sticky; + top: 104px; + align-self: start; + padding: 22px; + border: 1px solid var(--line); + border-radius: 22px; + background: var(--surface); + box-shadow: var(--shadow); +} +.sidebar-heading, .editor-header, .panel-heading, .subpanel-heading, .title-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.icon-button { + width: 36px; + height: 36px; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 50%; + background: var(--surface-3); + font-size: 1.2rem; +} +.field-label { display: block; margin: 26px 0 7px; color: var(--muted); font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; } +.field-help { margin-top: 8px; color: var(--muted); font-size: 0.76rem; line-height: 1.5; } +.select, .number-input { + width: 100%; + min-height: 40px; + padding: 8px 11px; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 10px; + background: #10151d; +} +.profile-list { display: grid; gap: 8px; margin-top: 24px; } +.profile-button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 13px 14px; + color: var(--muted); + border: 1px solid transparent; + border-radius: 12px; + background: transparent; + text-align: left; +} +.profile-button:hover { color: var(--ink); background: var(--surface-2); } +.profile-button.selected { color: var(--ink); border-color: rgba(86, 216, 230, 0.3); background: rgba(86, 216, 230, 0.09); } +.profile-number { display: flex; align-items: center; gap: 11px; font-weight: 700; } +.profile-number span { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 8px; background: var(--surface-3); font-size: 0.74rem; } +.mini-active { color: var(--lime); font-size: 0.68rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } +.sidebar-note { display: flex; gap: 10px; margin-top: 24px; padding: 14px; color: var(--muted); border-top: 1px solid var(--line); font-size: 0.76rem; line-height: 1.5; } +.note-icon { display: grid; flex: 0 0 auto; place-items: center; width: 20px; height: 20px; color: var(--cyan); border: 1px solid rgba(86, 216, 230, 0.4); border-radius: 50%; font-weight: 800; } + +.editor-shell { min-width: 0; } +.editor-header { + min-height: 92px; + padding: 18px 4px 22px; +} +.header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px; } +.button { + min-height: 40px; + padding: 9px 15px; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-3); + font-size: 0.82rem; + font-weight: 700; +} +.button:hover { transform: translateY(-1px); filter: brightness(1.08); } +.button:disabled { cursor: not-allowed; opacity: 0.45; transform: none; } +.button-primary { color: #061316; border-color: transparent; background: var(--cyan); } +.button-secondary { background: rgba(255, 255, 255, 0.06); } +.button-ghost { color: var(--muted); background: transparent; } +.active-badge, .dirty-badge { padding: 4px 8px; border-radius: 999px; font-size: 0.65rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; } +.active-badge { color: var(--lime); background: rgba(169, 223, 120, 0.1); } +.dirty-badge { color: var(--warning); background: rgba(242, 199, 102, 0.1); } +.section-nav { display: flex; gap: 4px; overflow-x: auto; margin-bottom: 12px; padding: 5px; border: 1px solid var(--line); border-radius: 13px; background: rgba(18, 22, 30, 0.7); } +.section-nav a { padding: 7px 12px; color: var(--muted); border-radius: 8px; font-size: 0.75rem; font-weight: 700; text-decoration: none; } +.section-nav a:hover { color: var(--ink); background: var(--surface-3); } + +.panel { + scroll-margin-top: 114px; + margin-bottom: 18px; + padding: clamp(20px, 3vw, 30px); + border: 1px solid var(--line); + border-radius: 22px; + background: var(--surface); + box-shadow: 0 14px 44px rgba(0, 0, 0, 0.2); +} +.panel-heading { align-items: flex-end; margin-bottom: 24px; } +.panel-heading > p { max-width: 430px; color: var(--muted); font-size: 0.78rem; line-height: 1.5; text-align: right; } +.controller-mapper { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: stretch; } +.controller-canvas { padding: 16px; overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: radial-gradient(circle at 50% 45%, rgba(86, 216, 230, 0.08), transparent 48%), #0d1219; } +.controller-caption { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 26px; } +.controller-caption strong { font-size: 0.8rem; } +.controller-live { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 0.66rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } +.controller-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 10px var(--lime); } +.controller-photo-wrap { position: relative; width: 100%; margin: 10px auto 4px; aspect-ratio: 1.62; transition: aspect-ratio 180ms ease; } +.controller-canvas[data-style="switch"] .controller-photo-wrap { aspect-ratio: 1.38; } +.controller-canvas[data-style="playstation"] .controller-photo-wrap { aspect-ratio: 1.8; } +.controller-photo { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; user-select: none; filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.38)); } +.controller-hotspots { position: absolute; inset: 0; } +.controller-hotspots button { + --x: 50%; + --y: 50%; + position: absolute; + left: var(--x); + top: var(--y); + display: grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + color: #eafcff; + border: 2px solid rgba(86, 216, 230, 0.92); + border-radius: 50%; + background: rgba(7, 18, 23, 0.8); + box-shadow: 0 0 0 4px rgba(86, 216, 230, 0.12), 0 4px 14px rgba(0, 0, 0, 0.38); + transform: translate(-50%, -50%); + font-size: 0.7rem; + font-weight: 850; + line-height: 1; + transition: transform 120ms ease, border-color 120ms ease, background 120ms ease; +} +.controller-hotspots button:hover, .controller-hotspots button:focus-visible { z-index: 2; transform: translate(-50%, -50%) scale(1.14); } +.controller-hotspots button.selected { z-index: 3; color: #170807; border-color: var(--coral); background: var(--coral); box-shadow: 0 0 0 5px rgba(255, 122, 107, 0.17), 0 6px 18px rgba(0, 0, 0, 0.46); transform: translate(-50%, -50%) scale(1.16); } +.controller-hotspots button[data-controller-button$="_trigger"] { width: 58px; height: 28px; border-radius: 11px; } +.controller-hotspots button.disabled-map { color: var(--muted); border-color: #5b6471; background: rgba(12, 15, 20, 0.82); box-shadow: none; } +.controller-hotspots button[data-controller-button$="_shoulder"] { width: 58px; height: 28px; border-radius: 11px; } +.controller-hotspots button[data-controller-button^="dpad_"] { width: 25px; height: 25px; font-size: 0.56rem; } +.controller-hotspots button[data-controller-button="select"], +.controller-hotspots button[data-controller-button="start"], +.controller-hotspots button[data-controller-button="capture"], +.controller-hotspots button[data-controller-button="system"] { width: 27px; height: 27px; font-size: 0.62rem; } + +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_shoulder"] { --x: 22%; --y: 12%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_trigger"] { --x: 22%; --y: 4%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_trigger"] { --x: 78%; --y: 4%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_shoulder"] { --x: 78%; --y: 12%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="left_stick"] { --x: 23%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="right_stick"] { --x: 63%; --y: 68%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_up"] { --x: 36%; --y: 62%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_right"] { --x: 40%; --y: 68%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_down"] { --x: 36%; --y: 74%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="dpad_left"] { --x: 32%; --y: 68%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="north"] { --x: 77%; --y: 33%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="east"] { --x: 84%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="south"] { --x: 77%; --y: 54%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="west"] { --x: 70%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="select"] { --x: 42%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="start"] { --x: 57%; --y: 43%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="capture"] { --x: 50%; --y: 52%; } +.controller-canvas:is([data-style="generic"], [data-style="xbox"]) [data-controller-button="system"] { --x: 50%; --y: 26%; } + +.controller-canvas[data-style="switch"] [data-controller-button="left_shoulder"] { --x: 27%; --y: 8%; } +.controller-canvas[data-style="switch"] [data-controller-button="right_shoulder"] { --x: 73%; --y: 8%; } +.controller-canvas[data-style="switch"] [data-controller-button="left_trigger"] { --x: 27%; --y: 2%; } +.controller-canvas[data-style="switch"] [data-controller-button="right_trigger"] { --x: 73%; --y: 2%; } +.controller-canvas[data-style="switch"] [data-controller-button="left_stick"] { --x: 24%; --y: 30%; } +.controller-canvas[data-style="switch"] [data-controller-button="right_stick"] { --x: 63%; --y: 51%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_up"] { --x: 35%; --y: 46%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_right"] { --x: 39%; --y: 51%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_down"] { --x: 35%; --y: 56%; } +.controller-canvas[data-style="switch"] [data-controller-button="dpad_left"] { --x: 31%; --y: 51%; } +.controller-canvas[data-style="switch"] [data-controller-button="north"] { --x: 77%; --y: 22%; } +.controller-canvas[data-style="switch"] [data-controller-button="east"] { --x: 85%; --y: 32%; } +.controller-canvas[data-style="switch"] [data-controller-button="south"] { --x: 77%; --y: 43%; } +.controller-canvas[data-style="switch"] [data-controller-button="west"] { --x: 69%; --y: 32%; } +.controller-canvas[data-style="switch"] [data-controller-button="select"] { --x: 39%; --y: 21%; } +.controller-canvas[data-style="switch"] [data-controller-button="start"] { --x: 61%; --y: 21%; } +.controller-canvas[data-style="switch"] [data-controller-button="capture"] { --x: 44%; --y: 31%; } +.controller-canvas[data-style="switch"] [data-controller-button="system"] { --x: 57%; --y: 32%; } + +.controller-canvas[data-style="playstation"] [data-controller-button="left_shoulder"] { --x: 21%; --y: 12%; } +.controller-canvas[data-style="playstation"] [data-controller-button="right_shoulder"] { --x: 79%; --y: 12%; } +.controller-canvas[data-style="playstation"] [data-controller-button="left_stick"] { --x: 34%; --y: 69%; } +.controller-canvas[data-style="playstation"] [data-controller-button="left_trigger"] { --x: 21%; --y: 3%; } +.controller-canvas[data-style="playstation"] [data-controller-button="right_trigger"] { --x: 79%; --y: 3%; } +.controller-canvas[data-style="playstation"] [data-controller-button="right_stick"] { --x: 66%; --y: 69%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_up"] { --x: 18%; --y: 42%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_right"] { --x: 24%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_down"] { --x: 18%; --y: 58%; } +.controller-canvas[data-style="playstation"] [data-controller-button="dpad_left"] { --x: 12%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="north"] { --x: 83%; --y: 39%; } +.controller-canvas[data-style="playstation"] [data-controller-button="east"] { --x: 91%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="south"] { --x: 83%; --y: 61%; } +.controller-canvas[data-style="playstation"] [data-controller-button="west"] { --x: 75%; --y: 50%; } +.controller-canvas[data-style="playstation"] [data-controller-button="select"] { --x: 25%; --y: 30%; } +.controller-canvas[data-style="playstation"] [data-controller-button="start"] { --x: 75%; --y: 30%; } +.controller-canvas[data-style="playstation"] [data-controller-button="capture"] { --x: 50%; --y: 36%; } +.controller-canvas[data-style="playstation"] [data-controller-button="system"] { --x: 50%; --y: 72%; } + +.controller-hint { margin-top: 2px; color: var(--muted); font-size: 0.7rem; text-align: center; } +.controller-credit { margin-top: 7px; color: #687484; font-size: 0.6rem; text-align: center; } +.controller-credit a { color: #8292a6; text-decoration-color: rgba(130, 146, 166, 0.4); } +.mapping-inspector { display: flex; flex-direction: column; align-items: flex-start; padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface-2); } +.selected-control-mark { display: grid; place-items: center; width: 54px; height: 54px; margin: 13px 0; color: #170807; border-radius: 18px; background: var(--coral); box-shadow: 0 8px 24px rgba(255, 122, 107, 0.2); font-size: 1.15rem; font-weight: 900; } +.mapping-inspector h4 { font-size: 1.12rem; } +.mapping-inspector > p:not(.eyebrow) { min-height: 48px; margin-top: 7px; color: var(--muted); font-size: 0.74rem; line-height: 1.5; } +.mapping-inspector .field-label { margin-top: 20px; } +.mapping-key { display: flex; flex-wrap: wrap; gap: 12px; margin-top: auto; padding-top: 22px; color: var(--muted); font-size: 0.65rem; } +.mapping-key span { display: inline-flex; align-items: center; gap: 6px; } +.key-dot { width: 8px; height: 8px; border: 2px solid var(--cyan); border-radius: 50%; } +.key-dot.disabled { border-color: #5b6471; } +.mapping-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; } +.mapping-grid.compact { grid-template-columns: repeat(4, minmax(140px, 1fr)); } +.control-card { padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); } +.control-card label, .number-field label { display: block; margin-bottom: 7px; color: var(--muted); font-size: 0.7rem; font-weight: 720; letter-spacing: 0.04em; } +.control-card .select { min-height: 36px; } +.analog-grid, .feedback-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.subpanel { padding: 18px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface-2); } +.subpanel-heading { align-items: flex-start; margin-bottom: 15px; } +.subpanel-heading span { max-width: 230px; color: var(--muted); font-size: 0.7rem; line-height: 1.4; text-align: right; } +.number-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; } +.number-field.wide { grid-column: 1 / -1; } +.toggle-row { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; } +.checkbox-pill { position: relative; } +.checkbox-pill input { position: absolute; opacity: 0; pointer-events: none; } +.checkbox-pill span { display: block; padding: 7px 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; font-size: 0.7rem; font-weight: 700; cursor: pointer; } +.checkbox-pill input:checked + span { color: #071417; border-color: var(--cyan); background: var(--cyan); } +.controller-choices { align-items: stretch; gap: 8px; } +.controller-choice span { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 58px; + min-height: 54px; + padding: 7px 8px; + border-radius: 14px; + text-align: center; +} +.controller-choice span[data-button$="_shoulder"], +.controller-choice span[data-button$="_trigger"], +.controller-choice span[data-button$="_stick"] { min-width: 76px; } +.controller-choice b { color: var(--ink); font-size: 1.05rem; font-weight: 900; line-height: 1; } +.controller-choice small { max-width: 76px; margin-top: 5px; color: var(--muted); font-size: 0.54rem; font-weight: 700; line-height: 1.1; } +.controller-choice input:checked + span b, +.controller-choice input:checked + span small { color: #071417; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="north"] b { color: #64d8a0; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="east"] b { color: #ff7881; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="south"] b { color: #72afff; } +.builtin-actions[data-controller-style="playstation"] .controller-choice input:not(:checked) + span[data-button="west"] b { color: #dd9bff; } +.check-grid { display: flex; flex-wrap: wrap; gap: 7px; } +.range-row { margin-bottom: 17px; } +.range-meta { display: flex; justify-content: space-between; margin-bottom: 8px; color: var(--muted); font-size: 0.72rem; } +.range-meta output { color: var(--ink); font-variant-numeric: tabular-nums; } +.builtin-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } +.action-card { padding: 17px; border: 1px solid var(--line); border-radius: 15px; background: var(--surface-2); } +.action-card-heading { min-height: 74px; margin-bottom: 13px; } +.action-card-heading h4 { margin-top: 4px; } +.action-card-heading > span { display: block; margin-top: 7px; color: var(--muted); font-size: 0.7rem; line-height: 1.4; } +.action-kind { color: var(--cyan); font-size: 0.61rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; } +input[type="range"] { width: 100%; accent-color: var(--cyan); } + +.macro-heading { align-items: center; } +.macro-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; } +.macro-steps { display: grid; gap: 12px; } +.macro-step { padding: 17px; border: 1px solid var(--line); border-left: 3px solid var(--coral); border-radius: 14px; background: var(--surface-2); } +.macro-step.end { border-left-color: var(--muted); opacity: 0.72; } +.step-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; } +.step-number { color: var(--coral); font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; } +.remove-step { padding: 5px 8px; color: var(--coral); border: 0; border-radius: 7px; background: rgba(255, 122, 107, 0.1); font-size: 0.7rem; } +.step-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; } +.step-group { margin-top: 13px; padding-top: 13px; border-top: 1px solid var(--line); } +.step-group-title { display: block; margin-bottom: 8px; color: var(--muted); font-size: 0.68rem; font-weight: 750; letter-spacing: 0.1em; text-transform: uppercase; } +.loading-card { display: grid; place-items: center; min-height: 280px; color: var(--muted); border: 1px dashed var(--line); border-radius: 22px; } +.spinner { width: 28px; height: 28px; margin-bottom: -80px; border: 3px solid var(--surface-3); border-top-color: var(--cyan); border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.toast { position: fixed; right: 24px; bottom: 24px; z-index: 40; max-width: min(420px, calc(100vw - 48px)); padding: 13px 16px; color: var(--ink); border: 1px solid var(--line); border-radius: 12px; background: #18202b; box-shadow: var(--shadow); opacity: 0; transform: translateY(12px); pointer-events: none; transition: 180ms ease; } +.toast.show { opacity: 1; transform: translateY(0); } +.toast.error { border-color: rgba(255, 122, 107, 0.5); } + +@media (max-width: 1050px) { + .workspace { grid-template-columns: 240px minmax(0, 1fr); } + .mapping-grid, .mapping-grid.compact { grid-template-columns: repeat(2, minmax(140px, 1fr)); } + .editor-header { align-items: flex-start; } +} +@media (max-width: 760px) { + .topbar { position: relative; align-items: flex-start; } + .connection { max-width: 48%; } + .workspace { display: block; width: min(100% - 24px, 680px); margin-top: 14px; } + .controller-mapper { grid-template-columns: 1fr; } + .mapping-inspector { min-height: 230px; } + .sidebar { position: relative; top: auto; margin-bottom: 18px; } + .editor-header, .panel-heading { align-items: flex-start; flex-direction: column; } + .header-actions { justify-content: flex-start; } + .builtin-actions { grid-template-columns: 1fr; } + .panel-heading > p { text-align: left; } + .analog-grid, .feedback-grid, .macro-controls { grid-template-columns: 1fr; } + .step-grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 470px) { + .topbar { padding-inline: 14px; } + .brand-mark { display: none; } + .connection { padding: 7px; font-size: 0.68rem; } + .mapping-grid, .mapping-grid.compact, .number-grid, .step-grid { grid-template-columns: 1fr; } + .button { flex: 1 1 auto; } +} diff --git a/src/switch_pico_bridge/web/profile_editor.html b/src/switch_pico_bridge/web/profile_editor.html new file mode 100644 index 0000000..572a5df --- /dev/null +++ b/src/switch_pico_bridge/web/profile_editor.html @@ -0,0 +1,195 @@ + + + + + + Switch Pico Profile Studio + + + +
+
+
+
+ +
+

Switch Pico

+

Profile Studio

+
+
+
+ + Connecting to adapter… +
+
+ +
+ + +
+
+
+

Global fallback

+
+

Profile 1

+ + +
+
+
+ + + +
+
+ + + +
+
+

Reading profile from the adapter…

+
+ + +
+
+ +
+ + + diff --git a/src/switch_pico_bridge/web/profile_editor.js b/src/switch_pico_bridge/web/profile_editor.js new file mode 100644 index 0000000..c275ea4 --- /dev/null +++ b/src/switch_pico_bridge/web/profile_editor.js @@ -0,0 +1,746 @@ +"use strict"; + +const state = { + schema: null, + identities: [], + identityIndex: 0, + profileIndex: 0, + profile: null, + original: "", + active: false, + selectedButton: "south", + token: "", + busy: false, +}; + +const elements = { + connection: document.querySelector("#connectionState"), + connectionText: document.querySelector("#connectionText"), + identity: document.querySelector("#identitySelect"), + profileList: document.querySelector("#profileList"), + profileOwner: document.querySelector("#profileOwner"), + profileTitle: document.querySelector("#profileTitle"), + activeBadge: document.querySelector("#activeBadge"), + dirtyBadge: document.querySelector("#dirtyBadge"), + loading: document.querySelector("#loadingCard"), + form: document.querySelector("#profileForm"), + controllerCanvas: document.querySelector("#controllerCanvas"), + controllerImage: document.querySelector("#controllerImage"), + controllerModel: document.querySelector("#controllerModel"), + controllerCredit: document.querySelector("#controllerCredit"), + controllerHotspots: document.querySelector("#controllerHotspots"), + selectedMapping: document.querySelector("#selectedMapping"), + selectedControlGlyph: document.querySelector("#selectedControlGlyph"), + selectedControlName: document.querySelector("#selectedControlName"), + selectedControlDescription: document.querySelector("#selectedControlDescription"), + analog: document.querySelector("#analogFields"), + rumble: document.querySelector("#rumbleFields"), + builtinActions: document.querySelector("#builtinActions"), + turbo: document.querySelector("#turboFields"), + macroControls: document.querySelector("#macroControls"), + macroSteps: document.querySelector("#macroSteps"), + refresh: document.querySelector("#refreshButton"), + resetDraft: document.querySelector("#resetDraftButton"), + activate: document.querySelector("#activateButton"), + save: document.querySelector("#saveButton"), + addMacroStep: document.querySelector("#addMacroStepButton"), + toast: document.querySelector("#toast"), +}; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function label(value) { + return value + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function canonical(value) { + return JSON.stringify(value); +} + +function isDirty() { + return state.profile !== null && canonical(state.profile) !== state.original; +} + +function setConnection(mode, text) { + elements.connection.dataset.state = mode; + elements.connectionText.textContent = text; +} + +let toastTimer = 0; +function toast(message, error = false) { + window.clearTimeout(toastTimer); + elements.toast.textContent = message; + elements.toast.classList.toggle("error", error); + elements.toast.classList.add("show"); + toastTimer = window.setTimeout(() => elements.toast.classList.remove("show"), 3600); +} + +async function api(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (options.method && options.method !== "GET") { + headers["X-Switch-Pico-Token"] = state.token; + } + const response = await fetch(path, { ...options, headers }); + let payload; + try { + payload = await response.json(); + } catch { + throw new Error(`Unexpected server response (${response.status})`); + } + if (!response.ok) { + throw new Error(payload.error || `Request failed (${response.status})`); + } + return payload; +} + +function setBusy(busy) { + state.busy = busy; + elements.save.disabled = busy || !isDirty(); + elements.activate.disabled = busy || state.active; + elements.resetDraft.disabled = busy; + elements.refresh.disabled = busy; + elements.identity.disabled = busy; + if (state.schema && state.profile) { + const stateStepCount = state.profile.macro.steps.filter( + (step) => step.type === "state" + ).length; + elements.addMacroStep.disabled = ( + busy || stateStepCount >= state.schema.maximum_macro_state_steps + ); + } + document.querySelectorAll(".profile-button").forEach((button) => { + button.disabled = busy; + }); +} + +function updateDirtyState() { + const dirty = isDirty(); + elements.dirtyBadge.hidden = !dirty; + elements.save.disabled = state.busy || !dirty; +} + +function confirmDiscard() { + return !isDirty() || window.confirm("Discard the unsaved changes to this profile?"); +} + +function renderIdentities() { + elements.identity.innerHTML = state.identities + .map((entry) => ``) + .join(""); + elements.identity.value = String(state.identityIndex); + const owner = state.identities[state.identityIndex]; + elements.profileOwner.textContent = owner ? owner.label : "No controller"; +} + +function renderProfileList() { + const owner = state.identities[state.identityIndex]; + elements.profileList.innerHTML = Array.from({ length: 4 }, (_, index) => { + const selected = index === state.profileIndex; + const active = owner && owner.active_profile === index + 1; + return ` + `; + }).join(""); + + elements.profileList.querySelectorAll(".profile-button").forEach((button) => { + button.addEventListener("click", async () => { + const next = Number(button.dataset.profileIndex); + if (next === state.profileIndex || !confirmDiscard()) return; + state.profileIndex = next; + await loadProfile(); + }); + }); +} + +function buttonOptions( + selected, + includeNone = true, + choices = state.schema.controls +) { + const none = includeNone + ? `` + : ""; + return none + choices.map((button) => ( + `` + )).join(""); +} + +const controllerArtwork = { + generic: { + source: "/assets/controller-xbox.svg", + alt: "Generic Xbox-layout controller artwork", + }, + xbox: { + source: "/assets/controller-xbox.svg", + alt: "Xbox controller artwork", + }, + switch: { + source: "/assets/controller-switch-pro.svg", + alt: "Nintendo Switch Pro Controller artwork", + }, + playstation: { + source: "/assets/controller-dualsense.svg", + alt: "Sony DualSense controller artwork", + }, +}; + +const controllerGlyphs = { + generic: { + north: "Y", east: "B", south: "A", west: "X", + left_shoulder: "LB", right_shoulder: "RB", + left_trigger: "LT", right_trigger: "RT", + select: "⧉", start: "☰", capture: "↥", system: "X", + }, + xbox: { + north: "Y", east: "B", south: "A", west: "X", + left_shoulder: "LB", right_shoulder: "RB", + left_trigger: "LT", right_trigger: "RT", + select: "⧉", start: "☰", capture: "↥", system: "X", + }, + switch: { + north: "X", east: "A", south: "B", west: "Y", + left_shoulder: "L", right_shoulder: "R", + left_trigger: "ZL", right_trigger: "ZR", + select: "−", start: "+", capture: "▣", system: "⌂", + }, + playstation: { + north: "△", east: "○", south: "×", west: "□", + left_shoulder: "L1", right_shoulder: "R1", + left_trigger: "L2", right_trigger: "R2", + select: "⧉", start: "☰", capture: "TP", system: "PS", + }, +}; + +const fixedControlGlyphs = { + dpad_up: "▲", + dpad_right: "▶", + dpad_down: "▼", + dpad_left: "◀", + left_stick: "L3", + right_stick: "R3", + select: "−", + start: "+", + capture: "▣", + system: "⌂", +}; + +function controlGlyph(button, style) { + return controllerGlyphs[style]?.[button] || fixedControlGlyphs[button] || "?"; +} + +function getControlMapping(button) { + if (button === "left_trigger") return state.profile.triggers.left.output; + if (button === "right_trigger") return state.profile.triggers.right.output; + return state.profile.button_map[button]; +} + +function setControlMapping(button, output) { + const triggerSides = { + left_trigger: "left", + right_trigger: "right", + }; + const side = triggerSides[button]; + if (side) { + const otherSide = side === "left" ? "right" : "left"; + if ( + (output === "left_trigger" || output === "right_trigger") && + state.profile.triggers[otherSide].output === output + ) { + state.profile.triggers[otherSide].output = button; + } + state.profile.triggers[side].output = output; + } else { + state.profile.button_map[button] = output; + } +} + + +function renderButtonMap() { + const owner = state.identities[state.identityIndex]; + const style = owner?.controller?.style || "generic"; + const artwork = controllerArtwork[style] || controllerArtwork.generic; + const selected = state.selectedButton; + const mappedOutput = getControlMapping(selected); + + elements.controllerCanvas.dataset.style = style; + elements.controllerImage.src = artwork.source; + elements.controllerImage.alt = artwork.alt; + elements.controllerModel.textContent = owner?.controller?.model || "Generic controller"; + elements.controllerCredit.href = "https://github.com/AL2009man/Gamepad-Asset-Pack"; + elements.controllerCredit.textContent = "Controller artwork by Al. Lopez · MIT"; + + elements.controllerHotspots.querySelectorAll("[data-controller-button]").forEach((hotspot) => { + const button = hotspot.dataset.controllerButton; + const output = getControlMapping(button); + hotspot.textContent = controlGlyph(button, style); + hotspot.classList.toggle("selected", button === selected); + hotspot.classList.toggle("disabled-map", output === null); + hotspot.title = `${label(button)} → ${output === null ? "Disabled" : label(output)}`; + hotspot.setAttribute("aria-label", hotspot.title); + hotspot.onclick = () => { + state.selectedButton = button; + renderButtonMap(); + }; + }); + + elements.selectedControlGlyph.textContent = controlGlyph(selected, style); + elements.selectedControlName.textContent = label(selected); + elements.selectedControlDescription.textContent = ( + `Physical ${label(selected)} currently produces ${mappedOutput === null ? "no output" : label(mappedOutput)}.` + ); + elements.selectedMapping.innerHTML = buttonOptions(mappedOutput); + elements.selectedMapping.onchange = () => { + setControlMapping(selected, elements.selectedMapping.value || null); + renderButtonMap(); + updateDirtyState(); + }; +} + +const analogDefinitions = [ + ["sticks", "left", "Left 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], + ]], + ["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], + ]], +]; + +function renderAnalog() { + elements.analog.innerHTML = analogDefinitions.map(([group, side, title, fields]) => { + const config = state.profile[group][side]; + const toggles = group === "sticks" ? ` +
+ ${["invert_x", "invert_y"].map((field) => ` + `).join("")} +
` : ""; + return ` +
+

${title}

${group === "sticks" ? "Signed axes · 32767 full scale" : "Unsigned · 65535 full scale"}
+
+ ${fields.map(([field, fieldLabel, min, max]) => ` +
+ + +
`).join("")} + ${toggles} +
+
`; + }).join(""); +} + +function renderFeedback() { + const rumble = state.profile.rumble; + elements.rumble.innerHTML = ` +

Rumble & confirmation

0 is silent · 255 is full strength
+ ${["weak_scale", "strong_scale"].map((field) => ` +
+
${rumble[field]}
+ +
`).join("")} +
+ + +
`; +} + +function renderTurbo() { + elements.turbo.innerHTML = state.schema.buttons.map((button) => ` +
+ + +
`).join(""); +} + +function macroNumber(index, field, value, min, max, title, disabled = false) { + return ` +
+ + +
`; +} + +function actionChordCard(action, title, description, selectedButtons, defaults) { + const inherited = selectedButtons.length === 0 && defaults?.length; + const effectiveButtons = inherited ? defaults : selectedButtons; + const selected = new Set(effectiveButtons); + const defaultText = inherited + ? `Using default: ${defaults.map(label).join(" + ")}.` + : defaults?.length + ? `Clear every selection to restore ${defaults.map(label).join(" + ")}.` + : "Empty disables this action."; + return ` +
+
+
${action === "custom_macro" ? "Custom macro" : "Built-in action"}

${title}

+ ${description} +
+
+ ${state.schema.controls.map((button) => ` + `).join("")} +
+

${defaultText}

+
`; +} + +function renderMacro() { + const controllerStyle = + state.identities[state.identityIndex]?.controller?.style || "generic"; + elements.builtinActions.dataset.controllerStyle = controllerStyle; + const macro = state.profile.macro; + elements.builtinActions.innerHTML = [ + actionChordCard( + "profile_switch", + "Cycle active profile", + "Advance through profile slots 1–4.", + state.profile.switching_chord, + state.schema.default_switching_chord + ), + actionChordCard( + "motion_toggle", + "Toggle motion", + "Enable or disable motion for this controller.", + state.profile.motion_toggle_chord, + state.schema.default_motion_toggle_chord + ), + actionChordCard( + "custom_macro", + "Run custom macro", + "Start the editable sequence below.", + macro.trigger, + null + ), + ].join(""); + elements.macroControls.innerHTML = ` +
+ + +
`; + + elements.macroSteps.innerHTML = macro.steps.map((step, index) => { + if (step.type === "end") { + return `
End marker · step ${index + 1}

The firmware requires this canonical final step.

`; + } + const overrides = new Set(step.overrides); + const outputButtons = new Set(step.output_buttons); + return ` +
+
+ State step ${index + 1} + +
+
+ ${macroNumber(index, "duration_ms", step.duration_ms, 0, 10000, "Duration (ms)")} + ${macroNumber(index, "left_stick.x", step.left_stick.x, -32768, 32767, "Left stick X", !overrides.has("left_stick"))} + ${macroNumber(index, "left_stick.y", step.left_stick.y, -32768, 32767, "Left stick Y", !overrides.has("left_stick"))} + ${macroNumber(index, "right_stick.x", step.right_stick.x, -32768, 32767, "Right stick X", !overrides.has("right_stick"))} + ${macroNumber(index, "right_stick.y", step.right_stick.y, -32768, 32767, "Right stick Y", !overrides.has("right_stick"))} + ${macroNumber(index, "triggers.left", step.triggers.left, 0, 65535, "Left trigger", !overrides.has("left_trigger"))} + ${macroNumber(index, "triggers.right", step.triggers.right, 0, 65535, "Right trigger", !overrides.has("right_trigger"))} +
+
+ Fields this step overrides +
+ ${state.schema.macro_overrides.map((name) => ` + `).join("")} +
+
+
+ Output buttons +
+ ${state.schema.buttons.map((button) => ` + `).join("")} +
+
+
`; + }).join(""); + const stateStepCount = macro.steps.filter((step) => step.type === "state").length; + elements.addMacroStep.disabled = state.busy || stateStepCount >= state.schema.maximum_macro_state_steps; +} + +function renderEditor() { + elements.profileTitle.textContent = `Profile ${state.profileIndex + 1}`; + elements.activeBadge.hidden = !state.active; + elements.activate.disabled = state.busy || state.active; + renderIdentities(); + renderProfileList(); + renderButtonMap(); + renderAnalog(); + renderFeedback(); + renderTurbo(); + renderMacro(); + elements.loading.hidden = true; + elements.form.hidden = false; + updateDirtyState(); +} + +async function loadProfile() { + setBusy(true); + elements.form.hidden = true; + elements.loading.hidden = false; + renderIdentities(); + renderProfileList(); + try { + const payload = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`); + state.profile = payload.profile; + state.original = canonical(payload.profile); + state.active = payload.active; + setConnection("ready", "Adapter connected"); + renderEditor(); + } catch (error) { + state.profile = null; + setConnection("error", "Adapter unavailable"); + elements.loading.querySelector("p").textContent = error.message; + toast(error.message, true); + } finally { + setBusy(false); + } +} + +async function loadLibrary() { + setBusy(true); + try { + const payload = await api("/api/profiles"); + state.identities = payload.identities; + if (!state.identities.some((entry) => entry.index === state.identityIndex)) { + state.identityIndex = 0; + } + await loadProfile(); + } catch (error) { + setConnection("error", "Adapter unavailable"); + elements.loading.querySelector("p").textContent = error.message; + toast(error.message, true); + } finally { + setBusy(false); + } +} + +function updateNestedStep(step, path, value) { + const parts = path.split("."); + let target = step; + for (const part of parts.slice(0, -1)) target = target[part]; + target[parts.at(-1)] = value; +} + +function handleFormChange(event) { + const target = event.target; + const kind = target.dataset.kind; + if (!kind || !state.profile) return; + if (kind === "analog-number") { + 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 === "rumble-range") { + state.profile.rumble[target.dataset.field] = Number(target.value); + document.querySelector(`#rumble-${target.dataset.field}-value`).value = target.value; + } else if (kind === "rumble-policy") { + state.profile.rumble.confirmation_policy = target.value; + } else if (kind === "action-chord") { + const fields = { + profile_switch: [state.profile, "switching_chord"], + motion_toggle: [state.profile, "motion_toggle_chord"], + custom_macro: [state.profile.macro, "trigger"], + }; + const [owner, field] = fields[target.dataset.action]; + const defaults = { + 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 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)); + } else if (kind === "turbo") { + state.profile.turbo[target.dataset.name] = target.value; + } else if (kind === "macro-selector") { + state.profile.macro[target.dataset.field] = target.value || null; + } else if (kind === "macro-number") { + updateNestedStep(state.profile.macro.steps[Number(target.dataset.index)], target.dataset.field, Number(target.value)); + } else if (kind === "macro-output") { + const step = state.profile.macro.steps[Number(target.dataset.index)]; + const selected = new Set(step.output_buttons); + target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); + step.output_buttons = state.schema.buttons.filter((name) => selected.has(name)); + } else if (kind === "macro-override") { + const step = state.profile.macro.steps[Number(target.dataset.index)]; + const selected = new Set(step.overrides); + target.checked ? selected.add(target.dataset.name) : selected.delete(target.dataset.name); + step.overrides = state.schema.macro_overrides.filter((name) => selected.has(name)); + if (!target.checked) { + if (target.dataset.name === "buttons") step.output_buttons = []; + if (target.dataset.name === "left_stick") step.left_stick = { x: 0, y: 0 }; + if (target.dataset.name === "right_stick") step.right_stick = { x: 0, y: 0 }; + if (target.dataset.name === "left_trigger") step.triggers.left = 0; + if (target.dataset.name === "right_trigger") step.triggers.right = 0; + } + renderMacro(); + } + updateDirtyState(); +} + +elements.form.addEventListener("input", handleFormChange); +elements.form.addEventListener("change", handleFormChange); + +elements.identity.addEventListener("change", async () => { + const previous = state.identityIndex; + if (!confirmDiscard()) { + elements.identity.value = String(previous); + return; + } + state.identityIndex = Number(elements.identity.value); + state.profileIndex = 0; + await loadProfile(); +}); + +elements.refresh.addEventListener("click", async () => { + if (confirmDiscard()) await loadLibrary(); +}); + +elements.resetDraft.addEventListener("click", () => { + if (!state.schema || !window.confirm("Replace this draft with the default profile? Nothing is saved until you choose Save to Pico.")) return; + state.profile = clone(state.schema.default_profile); + renderEditor(); + toast("Default profile loaded into the draft."); +}); + +elements.addMacroStep.addEventListener("click", () => { + const steps = state.profile.macro.steps; + const stateCount = steps.filter((step) => step.type === "state").length; + if (stateCount >= state.schema.maximum_macro_state_steps) return; + steps.splice(steps.length - 1, 0, { + type: "state", + overrides: ["buttons"], + duration_ms: 100, + output_buttons: [], + left_stick: { x: 0, y: 0 }, + right_stick: { x: 0, y: 0 }, + triggers: { left: 0, right: 0 }, + }); + renderMacro(); + updateDirtyState(); +}); + +elements.macroSteps.addEventListener("click", (event) => { + const button = event.target.closest("[data-remove-step]"); + if (!button) return; + state.profile.macro.steps.splice(Number(button.dataset.removeStep), 1); + renderMacro(); + updateDirtyState(); +}); + +elements.save.addEventListener("click", async () => { + if (!elements.form.reportValidity()) return; + setBusy(true); + try { + const result = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state.profile), + }); + state.original = canonical(state.profile); + updateDirtyState(); + toast(`Saved atomically · generation ${result.stored_generation} · CRC ${result.stored_crc}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +elements.activate.addEventListener("click", async () => { + if (isDirty()) { + toast("Save or discard the draft before activating this profile.", true); + return; + } + setBusy(true); + try { + const result = await api(`/api/profiles/${state.identityIndex}/${state.profileIndex + 1}/activate`, { method: "POST" }); + state.identities[state.identityIndex].active_profile = state.profileIndex + 1; + state.active = true; + renderEditor(); + toast(`Profile ${state.profileIndex + 1} is active · generation ${result.stored_generation}`); + } catch (error) { + toast(error.message, true); + } finally { + setBusy(false); + } +}); + +window.addEventListener("beforeunload", (event) => { + if (!isDirty()) return; + event.preventDefault(); + event.returnValue = ""; +}); + +async function start() { + try { + const schema = await api("/api/schema"); + state.schema = schema; + state.token = schema.mutation_token; + await loadLibrary(); + } catch (error) { + setConnection("error", "Editor failed to start"); + elements.loading.querySelector("p").textContent = error.message; + toast(error.message, true); + } +} + +start(); diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index 2933d48..cd2eeb9 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -1970,13 +1970,22 @@ void test_stateful_host_rumble_restore() { #endif } -void test_motion_hotkey() { +void test_motion_toggle_action() { start_pairing_backend(); uni_hid_device_t slot_zero = device(0); uni_hid_device_t slot_one = device(1); require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS && platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS, - "motion hotkey test controllers did not become ready"); + "motion action test controllers did not become ready"); + + Bluepad32SlotSnapshot backend_snapshot{}; + bluepad32_input_backend_snapshot(0, &backend_snapshot); + require(!bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation + 1u), + "stale connection toggled motion"); + require(bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation), + "live connection did not toggle motion"); uni_controller_t input{}; input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; @@ -1985,24 +1994,8 @@ void test_motion_hotkey() { ControllerState snapshot{}; require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == - (kDefaultMotionEnabled ? 3 : 0), - "slot 0 did not start with configured motion state"); - - input.gamepad.dpad = kMotionHotkeyDpadMask; - input.gamepad.buttons = kMotionHotkeyButtonMask; - input.gamepad.misc_buttons = kMotionHotkeyMiscMask; - platform_on_controller_data(&slot_zero, &input); - Bluepad32SlotSnapshot backend_snapshot{}; - bluepad32_input_backend_snapshot(0, &backend_snapshot); - require(read_controller_state(0, &snapshot) && - backend_snapshot.pre_hotkey_button_mask == - kMotionHotkeyLogicalButtonMask && - snapshot.motion_sample_count == - (kDefaultMotionEnabled ? 0 : 3) && - !snapshot.dpad_up && !snapshot.button_right_shoulder && - !snapshot.button_start, - "motion chord was not published pre-hotkey or suppressed " - "from normal output"); + (kDefaultMotionEnabled ? 0 : 3), + "motion toggle did not change slot motion publication"); process_rumble_timer(&g_rumble_timer); require(slot_zero.rumble_calls == 1 && @@ -2020,12 +2013,6 @@ void test_motion_hotkey() { : kMotionEnabledFeedbackStrongMagnitude), "motion toggle did not send distinct state feedback"); - platform_on_controller_data(&slot_zero, &input); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].motion_enabled == !kDefaultMotionEnabled && - slot_zero.rumble_calls == 1, - "held motion chord toggled or rumbled more than once"); - uni_controller_t peer_input{}; peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD; peer_input.gamepad.accel[0] = 8192; @@ -2033,32 +2020,27 @@ void test_motion_hotkey() { require(read_controller_state(1, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), - "slot 0 motion chord changed slot 1 motion state"); + "slot 0 motion action changed slot 1 motion state"); - input.gamepad = {}; - platform_on_controller_data(&slot_zero, &input); - input.gamepad.accel[0] = 8192; - input.gamepad.dpad = kMotionHotkeyDpadMask; - input.gamepad.buttons = kMotionHotkeyButtonMask; - input.gamepad.misc_buttons = kMotionHotkeyMiscMask; + require(bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation), + "second live motion toggle failed"); platform_on_controller_data(&slot_zero, &input); require(read_controller_state(0, &snapshot) && snapshot.motion_sample_count == (kDefaultMotionEnabled ? 3 : 0), - "released motion chord did not re-arm or restore motion"); - process_rumble_timer(&g_rumble_timer); - require(g_slots[0].motion_enabled == kDefaultMotionEnabled && - slot_zero.rumble_calls == 2, - "second motion chord did not restore configured state"); + "second motion toggle did not restore configured state"); platform_on_device_disconnected(&slot_zero); + require(!bluepad32_input_backend_toggle_motion( + 0, backend_snapshot.connection_generation), + "disconnected generation toggled motion"); uni_hid_device_t replacement = device(0); require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS && g_slots[0].motion_enabled == kDefaultMotionEnabled && - !g_slots[0].motion_hotkey_latched && g_slots[0].pre_hotkey_button_mask == 0 && !g_slots[0].feedback_pending, - "disconnect did not reset slot 0 motion hotkey state"); + "disconnect did not reset slot 0 motion action state"); } void test_protocol_neutral_analog_state() { @@ -2392,7 +2374,7 @@ int main(int argc, char** argv) { } else if (scenario == "profile-feedback") { test_profile_feedback_scheduler(); } else if (scenario == "motion-hotkey") { - test_motion_hotkey(); + test_motion_toggle_action(); } else if (scenario == "analog-state") { test_protocol_neutral_analog_state(); } else if (scenario == "rumble-mode") { diff --git a/tests/controller_profile_runtime_test.cpp b/tests/controller_profile_runtime_test.cpp index a166a24..b810f0f 100644 --- a/tests/controller_profile_runtime_test.cpp +++ b/tests/controller_profile_runtime_test.cpp @@ -28,6 +28,9 @@ unsigned active_snapshot_count = 0; std::array activation_attempts{}; size_t activation_attempt_count = 0; unsigned activation_busy_attempts = 0; +uint8_t last_motion_toggle_slot = 0; +uint32_t last_motion_toggle_connection_generation = 0; +unsigned motion_toggle_count = 0; void require(bool condition, const char* message) { if (!condition) { @@ -54,6 +57,9 @@ void prepare_profiles() { activation_attempts = {}; activation_attempt_count = 0; activation_busy_attempts = 0; + last_motion_toggle_slot = 0; + last_motion_toggle_connection_generation = 0; + motion_toggle_count = 0; for (uint8_t slot = 0; slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) { FakeProfileRow& row = rows[slot]; @@ -390,8 +396,9 @@ void test_initial_profile_indication_once_per_connection() { void test_default_switching_retry_commit_and_feedback() { prepare_profiles(); ControllerProfile& initial_profile = rows[0].profiles[0]; - initial_profile.macro_trigger = static_cast( - ControllerProfileLogicalButton::kLeftShoulder); + initial_profile.macro_trigger_mask = + logical_button_bit( + ControllerProfileLogicalButton::kLeftShoulder); initial_profile.macro_step_count = 2; initial_profile.macro_steps[0].type = ControllerProfileMacroStepType::kState; @@ -526,8 +533,9 @@ void test_identity_promotion_preserves_held_switching() { } void configure_motion_suppression_probe(ControllerProfile* profile) { - profile->macro_trigger = static_cast( - ControllerProfileLogicalButton::kDpadUp); + profile->macro_trigger_mask = + logical_button_bit( + ControllerProfileLogicalButton::kDpadUp); profile->macro_step_count = 2; profile->macro_steps[0].type = ControllerProfileMacroStepType::kState; @@ -673,11 +681,50 @@ void test_switching_slot_isolation() { } +void test_profile_motion_toggle_supports_trigger_chords() { + prepare_profiles(); + const uint32_t custom_chord = + logical_button_bit(ControllerProfileLogicalButton::kSouth) | + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + rows[0].profiles[0].motion_toggle_chord = custom_chord; + Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9); + + (void)runtime_transform(0, snapshot, 0); + require(motion_toggle_count == 0, + "profile refresh toggled motion without a chord"); + + apply_button_mask( + logical_button_bit(ControllerProfileLogicalButton::kSouth), + &snapshot); + snapshot.state.left_trigger = UINT16_MAX; + ControllerProfileTransformResult output = + runtime_transform(0, snapshot, 1); + require(motion_toggle_count == 1 && + last_motion_toggle_slot == 0 && + last_motion_toggle_connection_generation == 9 && + controller_profile_extract_button_mask(output.state) == 0 && + output.state.left_trigger == 0, + "trigger-backed motion chord did not toggle and consume inputs"); + + (void)runtime_transform(0, snapshot, 2); + require(motion_toggle_count == 1, + "held trigger-backed motion chord toggled twice"); + snapshot = make_snapshot(0, 9); + (void)runtime_transform(0, snapshot, 3); + apply_button_mask( + logical_button_bit(ControllerProfileLogicalButton::kSouth), + &snapshot); + snapshot.state.left_trigger = UINT16_MAX; + (void)runtime_transform(0, snapshot, 4); + require(motion_toggle_count == 2, + "released trigger-backed motion chord did not re-arm"); +} + void configure_synthetic_profile(uint8_t slot) { ControllerProfile& profile = rows[slot].profiles[0]; profile = controller_profile_default(rows[slot].identity, 0); - profile.macro_trigger = - static_cast(ControllerProfileLogicalButton::kSouth); + profile.macro_trigger_mask = + logical_button_bit(ControllerProfileLogicalButton::kSouth); profile.macro_cancel = static_cast(ControllerProfileLogicalButton::kCapture); profile.macro_step_count = 2; @@ -837,6 +884,14 @@ void test_runtime_slot_synthetic_isolation() { } // namespace +bool bluepad32_input_backend_toggle_motion( + uint8_t slot, uint32_t connection_generation) { + last_motion_toggle_slot = slot; + last_motion_toggle_connection_generation = connection_generation; + ++motion_toggle_count; + return true; +} + uint32_t profile_service_database_generation() { return database_generation; } @@ -889,6 +944,7 @@ int main() { test_identity_promotion_preserves_held_switching(); test_switching_uses_pre_hotkey_buttons_only(); test_custom_switching_chord_and_wrap(); + test_profile_motion_toggle_supports_trigger_chords(); test_switching_slot_isolation(); test_all_runtime_cancellation_causes(); test_runtime_slot_synthetic_isolation(); diff --git a/tests/controller_profile_test.cpp b/tests/controller_profile_test.cpp index b9de8ed..a74d6c2 100644 --- a/tests/controller_profile_test.cpp +++ b/tests/controller_profile_test.cpp @@ -45,9 +45,9 @@ void test_profile_wire_schema() { uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; require(controller_profile_encode(profile, encoded, sizeof(encoded)), "default profile did not encode"); - require(encoded[0] == 2 && encoded[1] == 0 && + require(encoded[0] == 4 && encoded[1] == 0 && encoded[2] == 0 && encoded[3] == 1, - "profile header is not little-endian v2/256"); + "profile header is not little-endian v4/256"); for (uint8_t index = 0; index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) { require(encoded[4 + index] == index, @@ -64,9 +64,15 @@ void test_profile_wire_schema() { static_cast( CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), "default trigger encoding changed"); + require(encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + encoded[61] == 0 && + encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + encoded[71] == 0, + "default trigger mappings are not identity"); require(encoded[72] == 0xff && encoded[73] == 0xff && - encoded[74] == 3 && encoded[78] == 0xff && - encoded[79] == 0xff && encoded[80] == 1, + encoded[74] == 3 && encoded[78] == 0 && + encoded[79] == 0 && encoded[80] == 1 && + encoded[81] == CONTROLLER_PROFILE_NO_BUTTON, "default rumble or macro encoding changed"); for (uint8_t index = 0; index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) { @@ -77,12 +83,40 @@ void test_profile_wire_schema() { ControllerProfile decoded{}; require(controller_profile_decode(encoded, sizeof(encoded), &decoded), "default profile did not decode"); + ControllerProfile action_profile = profile; + action_profile.switching_chord = + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) | + (1u << static_cast( + ControllerProfileLogicalButton::kSouth)); + action_profile.macro_trigger_mask = + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) | + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL); + action_profile.motion_toggle_chord = + (1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL); + action_profile.macro_cancel = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode( + action_profile, action_encoded, sizeof(action_encoded)) && + action_encoded[75] == 0x2d && + action_encoded[81] == + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + controller_profile_decode( + action_encoded, sizeof(action_encoded), &decoded) && + decoded.switching_chord == + action_profile.switching_chord && + decoded.motion_toggle_chord == + action_profile.motion_toggle_chord && + decoded.macro_trigger_mask == + action_profile.macro_trigger_mask && + decoded.macro_cancel == action_profile.macro_cancel, + "trigger-backed action masks did not round-trip"); encoded[252] = 1; require(!controller_profile_decode(encoded, sizeof(encoded), &decoded), "nonzero reserved profile byte was accepted"); ControllerProfile invalid = profile; - invalid.button_map[0] = 16; + invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; require(!controller_profile_validate(invalid), "invalid direct output was accepted"); invalid = profile; @@ -156,11 +190,14 @@ void test_legacy_profile_migration() { require(controller_profile_encode(migrated, encoded, sizeof(encoded)), "migrated default profile did not encode"); for (size_t index = 0; index < sizeof(encoded); ++index) { - const bool schema_byte = index == 0; - const bool threshold_byte = + const bool migration_byte = + index == 0 || (index >= 58 && index < 60) || - (index >= 68 && index < 70); - if (!schema_byte && !threshold_byte) { + index == 60 || + (index >= 68 && index < 70) || + index == 70 || + index == 78 || index == 79 || index == 81; + if (!migration_byte) { require(encoded[index] == kLegacyDefaultProfile[index], "legacy default profile changed an unrelated byte"); } @@ -177,8 +214,12 @@ void test_legacy_profile_migration() { CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) && encoded[69] == static_cast( - CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8), - "migrated default profile did not encode as v2"); + CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) && + encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + encoded[78] == 0 && encoded[79] == 0 && + encoded[81] == CONTROLLER_PROFILE_NO_BUTTON, + "migrated default profile did not encode as v4"); require(controller_profile_decode( kLegacyNarrowRawRangeProfile, @@ -196,11 +237,14 @@ void test_legacy_profile_migration() { require(controller_profile_encode(migrated, encoded, sizeof(encoded)), "migrated narrow-raw-range profile did not encode"); for (size_t index = 0; index < sizeof(encoded); ++index) { - const bool schema_byte = index == 0; - const bool threshold_byte = + const bool migration_byte = + index == 0 || (index >= 58 && index < 60) || - (index >= 68 && index < 70); - if (!schema_byte && !threshold_byte) { + index == 60 || + (index >= 68 && index < 70) || + index == 70 || + index == 78 || index == 79 || index == 81; + if (!migration_byte) { require( encoded[index] == kLegacyNarrowRawRangeProfile[index], "narrow-raw-range migration changed unrelated profile data"); @@ -214,13 +258,68 @@ void test_legacy_profile_migration() { require(migrated.triggers[0].digital_threshold == 0x1234 && migrated.triggers[1].digital_threshold == 0xabcd, "legacy custom thresholds were not preserved"); + require(migrated.triggers[0].output == + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + migrated.triggers[1].output == + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL, + "legacy trigger identity mappings were not restored"); require(controller_profile_encode(migrated, encoded, sizeof(encoded)), "legacy custom-threshold profile did not re-encode"); + const uint8_t legacy_macro_trigger = + kLegacyCustomThresholdProfile[78]; + const uint16_t expected_macro_trigger = + legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT + ? static_cast(1u << legacy_macro_trigger) + : 0; + require(migrated.macro_trigger_mask == expected_macro_trigger && + migrated.macro_cancel == + kLegacyCustomThresholdProfile[79], + "legacy macro trigger was not migrated to a chord"); for (size_t index = 1; index < sizeof(encoded); ++index) { - require(encoded[index] == kLegacyCustomThresholdProfile[index], - "legacy custom-threshold profile changed data"); + const bool migration_byte = + index == 60 || index == 70 || + index == 78 || index == 79 || index == 81; + if (!migration_byte) { + require(encoded[index] == + kLegacyCustomThresholdProfile[index], + "legacy custom-threshold profile changed data"); + } } + const ControllerProfile previous_source = + controller_profile_default(controller_identity_global(), 0); + uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{}; + require(controller_profile_encode( + previous_source, previous_encoded, + sizeof(previous_encoded)), + "previous-schema fixture did not encode"); + previous_encoded[0] = static_cast( + CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION); + previous_encoded[60] = 0; + previous_encoded[70] = 0; + previous_encoded[78] = static_cast( + ControllerProfileLogicalButton::kSouth); + previous_encoded[79] = static_cast( + ControllerProfileLogicalButton::kCapture); + previous_encoded[81] = 0; + previous_encoded[98] = 0; + previous_encoded[99] = 0; + require(controller_profile_decode( + previous_encoded, sizeof(previous_encoded), &migrated) && + migrated.triggers[0].output == + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL && + migrated.triggers[1].output == + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL && + migrated.macro_trigger_mask == + static_cast( + 1u << static_cast( + ControllerProfileLogicalButton::kSouth)) && + migrated.macro_cancel == + static_cast( + ControllerProfileLogicalButton::kCapture) && + migrated.motion_toggle_chord == 0, + "v2 profile controls did not migrate to v4"); + ControllerProfile current = controller_profile_default(controller_identity_global(), 0); current.triggers[0].digital_threshold = 0x8000; diff --git a/tests/controller_profile_transform_test.cpp b/tests/controller_profile_transform_test.cpp index bd68722..f27434b 100644 --- a/tests/controller_profile_transform_test.cpp +++ b/tests/controller_profile_transform_test.cpp @@ -63,6 +63,8 @@ uint16_t transform_left_trigger( uint16_t value) { ControllerProfile profile = default_profile(); profile.triggers[0] = configuration; + profile.triggers[0].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; ControllerState state{}; state.left_trigger = value; return controller_profile_transform(state, profile).state.left_trigger; @@ -107,9 +109,9 @@ void test_button_masks_and_direct_mapping() { "disabled button mapping still produced output"); ControllerProfile invalid = default_profile(); - invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; + invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT; require(!controller_profile_validate(invalid), - "logical output 16 was accepted"); + "logical output 18 was accepted"); invalid.button_map[0] = 0xfe; require(!controller_profile_validate(invalid), "logical output 0xfe was accepted"); @@ -271,6 +273,8 @@ void test_trigger_boundaries_curves_and_thresholds() { ControllerProfile profile = default_profile(); profile.triggers[0] = slow; + profile.triggers[0].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; profile.triggers[0].digital_threshold = 12345; profile.triggers[1].digital_threshold = 54321; ControllerState state{}; @@ -282,6 +286,41 @@ void test_trigger_boundaries_curves_and_thresholds() { "profile-owned digital thresholds were not returned"); } +void test_trigger_and_button_cross_mapping() { + ControllerProfile profile = default_profile(); + profile.triggers[0].output = + static_cast(ControllerProfileLogicalButton::kSouth); + profile.triggers[0].digital_threshold = 30000; + profile.triggers[1].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + profile.triggers[1].digital_threshold = 40000; + profile.button_map[ + static_cast(ControllerProfileLogicalButton::kEast)] = + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + + ControllerState input{}; + input.left_trigger = 29999; + input.right_trigger = 45000; + input.button_east = true; + ControllerProfileTransformResult output = + controller_profile_transform(input, profile); + require(!output.state.button_south && + output.state.left_trigger == 45000 && + output.state.right_trigger == UINT16_MAX && + output.left_trigger_digital_threshold == 40000, + "cross-mapped triggers lost analog or button output"); + + input.left_trigger = 30000; + output = controller_profile_transform(input, profile); + require(output.state.button_south, + "trigger threshold did not produce its mapped button"); + + profile.triggers[0].output = + CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL; + require(!controller_profile_validate(profile), + "two analog triggers targeting one output were accepted"); +} + void test_default_whole_state_equivalence() { ControllerState input{}; input.dpad_up = true; @@ -378,6 +417,7 @@ int main() { test_stick_center_boundaries_and_inversion(); test_stick_curves_and_monotonicity(); test_trigger_boundaries_curves_and_thresholds(); + test_trigger_and_button_cross_mapping(); test_default_whole_state_equivalence(); test_rumble_scaling_and_confirmation_policy(); return 0; diff --git a/tests/controller_synthetic_input_test.cpp b/tests/controller_synthetic_input_test.cpp index b2af3e6..7637e46 100644 --- a/tests/controller_synthetic_input_test.cpp +++ b/tests/controller_synthetic_input_test.cpp @@ -39,7 +39,7 @@ ControllerProfile profile_with_macro( ControllerProfileLogicalButton::kCapture) { ControllerProfile profile = controller_profile_default(controller_identity_global(), 0); - profile.macro_trigger = button_index(trigger); + profile.macro_trigger_mask = button_bit(trigger); profile.macro_cancel = button_index(cancel); return profile; } @@ -100,6 +100,54 @@ void test_immediate_press_release_dpad_and_explicit_end() { "explicit end did not clear overrides and restore physical input"); } +void test_macro_trigger_chord_requires_every_button() { + ControllerProfile profile = + profile_with_macro(ControllerProfileLogicalButton::kSouth); + profile.macro_trigger_mask = + button_bit(ControllerProfileLogicalButton::kSouth) | + (1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL); + profile.macro_cancel = + CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL; + profile.macro_step_count = 2; + profile.macro_steps[0].type = + ControllerProfileMacroStepType::kState; + profile.macro_steps[0].override_flags = + kControllerProfileOverrideButtons; + profile.macro_steps[0].duration_ms = 100; + profile.macro_steps[0].output_button_mask = + button_bit(ControllerProfileLogicalButton::kNorth); + set_end(&profile, 1); + + ControllerSyntheticInputContext context{}; + ControllerState input = + state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth)); + ControllerProfileTransformResult output = + controller_synthetic_input_apply(&context, input, profile, 0); + require(!context.macro_active && + has_button(output, ControllerProfileLogicalButton::kSouth), + "partial macro chord triggered or consumed a normal button"); + + input = state_with_buttons( + button_bit(ControllerProfileLogicalButton::kSouth)); + input.left_trigger = UINT16_MAX; + output = controller_synthetic_input_apply( + &context, input, profile, 1); + require(context.macro_active && + has_button(output, ControllerProfileLogicalButton::kNorth) && + !has_button(output, ControllerProfileLogicalButton::kSouth) && + output.state.left_trigger == 0, + "completed trigger-backed macro chord did not consume inputs"); + + input = controller_neutral_state(); + input.right_trigger = UINT16_MAX; + output = controller_synthetic_input_apply( + &context, input, profile, 2); + require(!context.macro_active && + output.state.right_trigger == 0, + "trigger-backed macro cancellation leaked or stayed active"); +} + void test_optional_field_overrides_and_motion_preservation() { ControllerProfile profile = profile_with_macro(ControllerProfileLogicalButton::kSelect); @@ -454,6 +502,7 @@ void test_four_contexts_are_isolated() { int main() { test_immediate_press_release_dpad_and_explicit_end(); + test_macro_trigger_chord_requires_every_button(); test_optional_field_overrides_and_motion_preservation(); test_zero_max_wait_and_scheduled_catch_up(); test_consumption_cancel_precedence_and_duplicate_contributors(); diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 72588bd..6be39c5 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -483,7 +483,7 @@ class FakeDevice: def custom_profile() -> config_manager.ControllerProfile: return config_manager.ControllerProfile( button_map=( - 1, + config_manager.LOGICAL_CONTROLS.index("left_trigger"), 0, 2, 3, @@ -506,14 +506,21 @@ def custom_profile() -> config_manager.ControllerProfile: right_stick=config_manager.StickConfig( 789, -321, 500, 31000, 192, False, True ), - left_trigger=config_manager.TriggerConfig(100, 65000, 320, 32000), - right_trigger=config_manager.TriggerConfig(200, 64000, 224, 33000), + left_trigger=config_manager.TriggerConfig( + 100, 65000, 320, 32000, + config_manager.LOGICAL_CONTROLS.index("right_trigger"), + ), + right_trigger=config_manager.TriggerConfig( + 200, 64000, 224, 33000, + config_manager.LOGICAL_BUTTONS.index("north"), + ), weak_rumble_scale=77, strong_rumble_scale=201, confirmation_policy=2, - switching_chord=(1 << 6) | (1 << 7), - macro_trigger=0, - macro_cancel=1, + switching_chord=(1 << 6) | (1 << 16), + motion_toggle_chord=(1 << 5) | (1 << 17), + macro_trigger_mask=(1 << 0) | (1 << 16) | (1 << 17), + macro_cancel=config_manager.LOGICAL_CONTROLS.index("right_trigger"), macro_steps=( config_manager.MacroStep( 0, @@ -1113,13 +1120,18 @@ def test_identity_and_profile_binary_json_round_trip() -> None: config_manager.PROFILE_SCHEMA_VERSION, config_manager.PROFILE_SIZE, ) - assert encoded[75] == encoded[81] == 0 - assert encoded[98:100] == b"\x00\x00" + assert encoded[75] == 0x2D + assert encoded[60:62] == bytes((17, 0)) + assert encoded[70:72] == bytes((3, 0)) + assert encoded[81] == config_manager.LOGICAL_CONTROLS.index("right_trigger") + assert struct.unpack_from(" None: 0, config_manager.PROFILE_LEGACY_SCHEMA_VERSION, ) + legacy_default_wire[60:62] = b"\x00\x00" + legacy_default_wire[70:72] = b"\x00\x00" + legacy_default_wire[78] = config_manager.PROFILE_NONE_BUTTON + legacy_default_wire[79] = config_manager.PROFILE_NONE_BUTTON + legacy_default_wire[81] = 0 + legacy_default_wire[98:100] = b"\x00\x00" struct.pack_into( " None: == config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD ) - legacy_custom_wire = bytearray(encoded) + previous_wire = bytearray(default_wire) struct.pack_into( " None: assert migrated_json.right_trigger.digital_threshold == 33000 assert migrated_json.left_trigger.lower_deadzone == 30000 assert migrated_json.left_trigger.upper_saturation == 40000 + assert migrated_json.left_trigger.output == 16 + assert migrated_json.right_trigger.output == 17 + assert migrated_json.macro_trigger_mask == 1 + assert migrated_json.motion_toggle_chord == 0 def test_trigger_threshold_uses_transformed_output_domain() -> None: for threshold in (0, 0xFFFF): - trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold) - assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger + trigger = config_manager.TriggerConfig( + 30000, 40000, 256, threshold, 16 + ) + assert ( + config_manager.TriggerConfig.from_bytes( + trigger.to_bytes(), + schema_version=config_manager.PROFILE_SCHEMA_VERSION, + source_index=0, + ) + == trigger + ) assert ( config_manager.TriggerConfig.from_json_object( - trigger.to_json_object(), "trigger" + trigger.to_json_object(), + "trigger", + schema_version=config_manager.PROFILE_SCHEMA_VERSION, + source_index=0, ) == trigger ) @@ -1242,7 +1291,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None: config_manager.ConfigManagerError, match="trigger digital_threshold", ): - config_manager.TriggerConfig(30000, 40000, 256, threshold) + config_manager.TriggerConfig(30000, 40000, 256, threshold, 16) for lower_deadzone, upper_saturation in ( (40000, 40000), @@ -1253,7 +1302,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None: match="lower_deadzone must be below upper_saturation", ): config_manager.TriggerConfig( - lower_deadzone, upper_saturation, 256, 0 + lower_deadzone, upper_saturation, 256, 0, 16 ) @@ -1369,6 +1418,53 @@ def test_profile_cli_surfaces_late_storage_failure( ] +def test_profile_edit_cli_starts_local_web_editor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from switch_pico_bridge import profile_web + + calls: list[dict[str, object]] = [] + monkeypatch.setattr( + profile_web, + "run_profile_editor", + lambda **kwargs: calls.append(kwargs), + ) + monkeypatch.setattr( + config_manager, + "_candidate_devices", + lambda: (_ for _ in ()).throw( + AssertionError("editor startup must not require a connected Pico") + ), + ) + + assert ( + config_manager.main( + [ + "--bus", + "3", + "--address", + "7", + "--timeout", + "8", + "profiles", + "edit", + "--port", + "9000", + "--no-browser", + ] + ) + == 0 + ) + assert calls == [ + { + "bus": 3, + "address": 7, + "timeout": 8.0, + "port": 9000, + "open_browser": False, + } + ] + def test_profile_cli_json_round_trip_activate_and_reset( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1485,9 +1581,9 @@ def test_malformed_profiles_are_rejected_before_usb( tmp_path: Path, ) -> None: malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes()) - malformed_binary[75] = 1 + malformed_binary[75] = 0xC0 with pytest.raises( - config_manager.ConfigManagerError, match="reserved fields" + config_manager.ConfigManagerError, match="action flags" ): config_manager.ControllerProfile.from_bytes(malformed_binary) diff --git a/tests/test_profile_web.py b/tests/test_profile_web.py new file mode 100644 index 0000000..04dfca7 --- /dev/null +++ b/tests/test_profile_web.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +import pytest + +from switch_pico_bridge import config_manager, profile_web +from tests.test_config_manager import FakeDevice, custom_profile + + +@contextmanager +def running_server( + monkeypatch: pytest.MonkeyPatch, device: FakeDevice +) -> Iterator[tuple[str, str]]: + server = profile_web.ProfileEditorServer( + ("127.0.0.1", 0), + bus=None, + device_address=None, + timeout=1.0, + ) + monkeypatch.setattr(server, "find_device", lambda: device) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", server.mutation_token + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def request_json( + url: str, + *, + method: str = "GET", + value: Any = None, + token: str | None = None, +) -> tuple[int, dict[str, Any]]: + data = None + headers: dict[str, str] = {} + if value is not None: + data = json.dumps(value).encode("utf-8") + headers["Content-Type"] = "application/json" + if token is not None: + headers["X-Switch-Pico-Token"] = token + request = urllib.request.Request( + url, data=data, headers=headers, method=method + ) + try: + with urllib.request.urlopen(request, timeout=2) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +def test_editor_serves_assets_and_complete_schema( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with running_server(monkeypatch, FakeDevice()) as (base_url, _): + with urllib.request.urlopen(f"{base_url}/", timeout=2) as response: + page = response.read().decode("utf-8") + assert response.headers["Content-Security-Policy"] + with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response: + script_size = len(response.read()) + assert response.headers["Content-Type"].startswith("text/javascript") + with urllib.request.urlopen( + f"{base_url}/assets/controller-switch-pro.svg", timeout=2 + ) as response: + artwork_size = len(response.read()) + assert response.headers["Content-Type"] == "image/svg+xml" + + + status, schema = request_json(f"{base_url}/api/schema") + + assert status == 200 + assert "Profile Studio" in page + assert script_size > 1000 + assert artwork_size > 10000 + assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS) + assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS) + assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES) + assert schema["turbo_modes"] == list(config_manager.TURBO_MODES) + assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES) + assert ( + config_manager.ControllerProfile.from_json( + json.dumps(schema["default_profile"]) + ) + == config_manager.ControllerProfile.default() + ) + + +def test_editor_identifies_connected_controller_artwork( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + switch = config_manager.ControllerIdentity( + True, + config_manager.TRANSPORT_CLASSIC, + 0, + bytes.fromhex("010203040506"), + 0x057E, + 0x2009, + ) + dualsense = config_manager.ControllerIdentity( + True, + config_manager.TRANSPORT_CLASSIC, + 0, + bytes.fromhex("111213141516"), + 0x054C, + 0x0CE6, + ) + device.profile_identities = [ + device.global_identity, + switch, + dualsense, + device.stable_identity, + ] + device.active_profiles = { + identity.to_bytes(): 0 for identity in device.profile_identities + } + + with running_server(monkeypatch, device) as (base_url, _): + status, listing = request_json(f"{base_url}/api/profiles") + + assert status == 200 + assert [ + identity["controller"] for identity in listing["identities"] + ] == [ + {"model": "Generic controller", "style": "generic"}, + {"model": "Nintendo Switch Pro Controller", "style": "switch"}, + {"model": "Sony DualSense", "style": "playstation"}, + {"model": "Xbox controller", "style": "xbox"}, + ] + + +def test_editor_reads_writes_and_activates_profiles_atomically( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + with running_server(monkeypatch, device) as (base_url, token): + status, listing = request_json(f"{base_url}/api/profiles") + assert status == 200 + assert listing["identities"][1]["active_profile"] == 2 + assert listing["identities"][1]["controller"] == { + "model": "Xbox controller", + "style": "xbox", + } + + status, selected = request_json(f"{base_url}/api/profiles/1/3") + assert status == 200 + assert selected["active"] is False + + profile = custom_profile().to_json_object() + status, stored = request_json( + f"{base_url}/api/profiles/1/3", + method="PUT", + value=profile, + token=token, + ) + assert status == 200 + assert stored["stored_generation"] == 8 + assert ( + config_manager.ControllerProfile.from_bytes( + device.profiles[(device.stable_identity.to_bytes(), 2)] + ) + == custom_profile() + ) + assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16] + + status, activated = request_json( + f"{base_url}/api/profiles/1/3/activate", + method="POST", + token=token, + ) + + assert status == 200 + assert activated["stored_generation"] == 9 + assert device.active_profiles[device.stable_identity.to_bytes()] == 2 + + +def test_editor_rejects_invalid_or_unauthorized_mutations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = FakeDevice() + original = device.profiles[(device.global_identity.to_bytes(), 0)] + with running_server(monkeypatch, device) as (base_url, token): + status, unauthorized = request_json( + f"{base_url}/api/profiles/0/1", + method="PUT", + value=config_manager.ControllerProfile.default().to_json_object(), + ) + assert status == 403 + assert "token" in unauthorized["error"] + + malformed = config_manager.ControllerProfile.default().to_json_object() + malformed["sticks"]["left"]["outer_saturation"] = 0 + status, invalid = request_json( + f"{base_url}/api/profiles/0/1", + method="PUT", + value=malformed, + token=token, + ) + + assert status == 400 + assert "outer_saturation" in invalid["error"] + assert device.profiles[(device.global_identity.to_bytes(), 0)] == original