Add graphical controller profile editor
This commit is contained in:
parent
c2bfaa1d92
commit
eb03d94183
29 changed files with 6965 additions and 308 deletions
15
README.md
15
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 dinput
|
||||||
uv run switch-pico-config mode mac
|
uv run switch-pico-config mode mac
|
||||||
uv run switch-pico-config profiles list
|
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 export 1 profile.json --identity 0
|
||||||
uv run switch-pico-config profiles import 2 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
|
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.
|
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.
|
`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
|
### 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`.
|
- The chord cycles persistent profiles `1 → 2 → 3 → 4 → 1`.
|
||||||
- Chord buttons are consumed locally and are not forwarded to the host.
|
- 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.
|
- 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.
|
- 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
|
### 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 longer rumble confirms motion disabled.
|
||||||
- A shorter rumble confirms motion enabled.
|
- 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.
|
- Other controller slots are unaffected.
|
||||||
- Motion returns to enabled after disconnect or reboot.
|
- 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
|
### Per-slot controller colors
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,4 @@ where = ["src"]
|
||||||
include = ["switch_pico_bridge*"]
|
include = ["switch_pico_bridge*"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
switch_pico_bridge = ["controller_db/gamecontrollerdb.txt"]
|
switch_pico_bridge = ["controller_db/gamecontrollerdb.txt", "web/*", "web/assets/*"]
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,6 @@ constexpr SwitchRgbColor kProfileLightbarPalette[CONTROLLER_PROFILE_COUNT] = {
|
||||||
{0xff, 0xaa, 0x00},
|
{0xff, 0xaa, 0x00},
|
||||||
{0xcc, 0x33, 0xff},
|
{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 =
|
constexpr bool kDefaultMotionEnabled =
|
||||||
SWITCH_MOTION_DEFAULT_ENABLED != 0;
|
SWITCH_MOTION_DEFAULT_ENABLED != 0;
|
||||||
constexpr uint16_t kMotionDisabledFeedbackDurationMs =
|
constexpr uint16_t kMotionDisabledFeedbackDurationMs =
|
||||||
|
|
@ -76,9 +70,6 @@ constexpr uint8_t kMotionEnabledFeedbackStrongMagnitude =
|
||||||
|
|
||||||
static_assert(kProfileFeedbackPhaseDurationMs == 75);
|
static_assert(kProfileFeedbackPhaseDurationMs == 75);
|
||||||
static_assert(CONTROLLER_PROFILE_COUNT == 4);
|
static_assert(CONTROLLER_PROFILE_COUNT == 4);
|
||||||
static_assert(kMotionHotkeyDpadMask != 0);
|
|
||||||
static_assert(kMotionHotkeyButtonMask != 0);
|
|
||||||
static_assert(kMotionHotkeyMiscMask != 0);
|
|
||||||
static_assert(kMotionDisabledFeedbackDurationMs > 0);
|
static_assert(kMotionDisabledFeedbackDurationMs > 0);
|
||||||
static_assert(kMotionEnabledFeedbackDurationMs > 0);
|
static_assert(kMotionEnabledFeedbackDurationMs > 0);
|
||||||
|
|
||||||
|
|
@ -153,7 +144,6 @@ struct BackendSlot {
|
||||||
bool active;
|
bool active;
|
||||||
bool rumble_pending;
|
bool rumble_pending;
|
||||||
bool motion_enabled;
|
bool motion_enabled;
|
||||||
bool motion_hotkey_latched;
|
|
||||||
bool feedback_pending;
|
bool feedback_pending;
|
||||||
uint32_t feedback_until_ms;
|
uint32_t feedback_until_ms;
|
||||||
uint8_t pending_profile_feedback_count;
|
uint8_t pending_profile_feedback_count;
|
||||||
|
|
@ -708,10 +698,6 @@ constexpr uint16_t logical_button_mask(
|
||||||
: 0u));
|
: 0u));
|
||||||
}
|
}
|
||||||
|
|
||||||
constexpr uint16_t kMotionHotkeyLogicalButtonMask =
|
|
||||||
logical_button_mask(
|
|
||||||
kMotionHotkeyDpadMask, kMotionHotkeyButtonMask,
|
|
||||||
kMotionHotkeyMiscMask);
|
|
||||||
|
|
||||||
uint16_t logical_button_mask(const uni_gamepad_t& gamepad) {
|
uint16_t logical_button_mask(const uni_gamepad_t& gamepad) {
|
||||||
return logical_button_mask(
|
return logical_button_mask(
|
||||||
|
|
@ -810,10 +796,6 @@ ControllerState map_gamepad(const uni_gamepad_t& gamepad,
|
||||||
}
|
}
|
||||||
struct HotkeyDecision {
|
struct HotkeyDecision {
|
||||||
bool motion_enabled;
|
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,
|
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) {
|
void reset_slot_hotkeys(BackendSlot& slot) {
|
||||||
slot.motion_enabled = kDefaultMotionEnabled;
|
slot.motion_enabled = kDefaultMotionEnabled;
|
||||||
slot.motion_hotkey_latched = false;
|
|
||||||
slot.pre_hotkey_button_mask = 0;
|
slot.pre_hotkey_button_mask = 0;
|
||||||
slot.feedback_pending = false;
|
slot.feedback_pending = false;
|
||||||
slot.feedback_until_ms = 0;
|
slot.feedback_until_ms = 0;
|
||||||
|
|
@ -844,44 +825,12 @@ void reset_slot_hotkeys(BackendSlot& slot) {
|
||||||
|
|
||||||
|
|
||||||
HotkeyDecision update_controller_hotkeys(
|
HotkeyDecision update_controller_hotkeys(
|
||||||
uint8_t slot_index, uni_hid_device_t* device,
|
uint8_t slot_index, uni_hid_device_t* device) {
|
||||||
const uni_gamepad_t& gamepad) {
|
HotkeyDecision decision{kDefaultMotionEnabled};
|
||||||
const bool motion_pressed =
|
|
||||||
(gamepad.dpad & kMotionHotkeyDpadMask) ==
|
|
||||||
kMotionHotkeyDpadMask &&
|
|
||||||
(gamepad.buttons & kMotionHotkeyButtonMask) ==
|
|
||||||
kMotionHotkeyButtonMask &&
|
|
||||||
(gamepad.misc_buttons & kMotionHotkeyMiscMask) ==
|
|
||||||
kMotionHotkeyMiscMask;
|
|
||||||
HotkeyDecision decision{
|
|
||||||
kDefaultMotionEnabled, 0, 0, 0, 0};
|
|
||||||
|
|
||||||
critical_section_enter_blocking(&g_state_lock);
|
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 (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;
|
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);
|
critical_section_exit(&g_state_lock);
|
||||||
return decision;
|
return decision;
|
||||||
|
|
@ -1672,12 +1621,8 @@ void platform_on_controller_data(uni_hid_device_t* device,
|
||||||
const uint16_t pre_hotkey_button_mask =
|
const uint16_t pre_hotkey_button_mask =
|
||||||
logical_button_mask(gamepad);
|
logical_button_mask(gamepad);
|
||||||
const HotkeyDecision hotkeys = update_controller_hotkeys(
|
const HotkeyDecision hotkeys = update_controller_hotkeys(
|
||||||
static_cast<uint8_t>(slot_index), device, gamepad);
|
static_cast<uint8_t>(slot_index), device);
|
||||||
gamepad.dpad &= ~hotkeys.suppress_dpad;
|
const uint16_t output_button_mask = pre_hotkey_button_mask;
|
||||||
gamepad.buttons &= ~hotkeys.suppress_buttons;
|
|
||||||
gamepad.misc_buttons &= ~hotkeys.suppress_misc_buttons;
|
|
||||||
const uint16_t output_button_mask = static_cast<uint16_t>(
|
|
||||||
pre_hotkey_button_mask & ~hotkeys.suppress_logical_buttons);
|
|
||||||
publish_device_state(
|
publish_device_state(
|
||||||
static_cast<uint8_t>(slot_index), device,
|
static_cast<uint8_t>(slot_index), device,
|
||||||
pre_hotkey_button_mask,
|
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;
|
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) {
|
void bluepad32_input_backend_report_sent(uint8_t slot_index) {
|
||||||
if (!g_initialized || !valid_slot(slot_index)) {
|
if (!g_initialized || !valid_slot(slot_index)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,10 @@ void bluepad32_input_backend_pairing_snapshot(
|
||||||
void bluepad32_input_backend_diagnostics(
|
void bluepad32_input_backend_diagnostics(
|
||||||
Bluepad32BackendDiagnostics* out);
|
Bluepad32BackendDiagnostics* out);
|
||||||
void bluepad32_input_backend_report_sent(uint8_t slot);
|
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(
|
void bluepad32_input_backend_queue_rumble(
|
||||||
uint8_t slot, const ControllerRumbleOutput& rumble);
|
uint8_t slot, const ControllerRumbleOutput& rumble);
|
||||||
// Enqueue bounded local profile confirmation for the matching live connection
|
// Enqueue bounded local profile confirmation for the matching live connection
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
// Compile-time AIO controller hotkey configuration.
|
// Compile-time AIO controller action feedback configuration.
|
||||||
|
|
||||||
#pragma once
|
#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
|
#define SWITCH_MOTION_DEFAULT_ENABLED 1
|
||||||
|
|
||||||
// A longer pulse confirms disabled; a shorter pulse confirms enabled.
|
// A longer pulse confirms disabled; a shorter pulse confirms enabled.
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ constexpr uint8_t kMacroOverrideMask =
|
||||||
kControllerProfileOverrideLeftTrigger |
|
kControllerProfileOverrideLeftTrigger |
|
||||||
kControllerProfileOverrideRightTrigger;
|
kControllerProfileOverrideRightTrigger;
|
||||||
constexpr uint16_t kLegacyDefaultDigitalThreshold = 0x8000;
|
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) {
|
uint16_t profile_read_u16(const uint8_t* input) {
|
||||||
return static_cast<uint16_t>(input[0]) |
|
return static_cast<uint16_t>(input[0]) |
|
||||||
|
|
@ -50,6 +52,11 @@ bool valid_button(uint8_t button) {
|
||||||
button == CONTROLLER_PROFILE_NO_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 valid_macro_step(const ControllerProfileMacroStep& step,
|
||||||
bool must_end) {
|
bool must_end) {
|
||||||
if (must_end) {
|
if (must_end) {
|
||||||
|
|
@ -140,19 +147,24 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity,
|
||||||
stick.invert_x = false;
|
stick.invert_x = false;
|
||||||
stick.invert_y = 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.lower_deadzone = 0;
|
||||||
trigger.upper_saturation = UINT16_MAX;
|
trigger.upper_saturation = UINT16_MAX;
|
||||||
trigger.curve_q8_8 = 256;
|
trigger.curve_q8_8 = 256;
|
||||||
trigger.digital_threshold =
|
trigger.digital_threshold =
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD;
|
||||||
|
trigger.output = static_cast<uint8_t>(
|
||||||
|
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index);
|
||||||
}
|
}
|
||||||
profile.weak_rumble_scale = UINT8_MAX;
|
profile.weak_rumble_scale = UINT8_MAX;
|
||||||
profile.strong_rumble_scale = UINT8_MAX;
|
profile.strong_rumble_scale = UINT8_MAX;
|
||||||
profile.confirmation_policy =
|
profile.confirmation_policy =
|
||||||
ControllerProfileConfirmationPolicy::kRumbleAndLed;
|
ControllerProfileConfirmationPolicy::kRumbleAndLed;
|
||||||
profile.switching_chord = 0;
|
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_cancel = CONTROLLER_PROFILE_NO_BUTTON;
|
||||||
profile.macro_step_count = 1;
|
profile.macro_step_count = 1;
|
||||||
for (ControllerProfileMacroStep& step : profile.macro_steps) {
|
for (ControllerProfileMacroStep& step : profile.macro_steps) {
|
||||||
|
|
@ -164,7 +176,7 @@ ControllerProfile controller_profile_default(const ControllerIdentity& identity,
|
||||||
|
|
||||||
bool controller_profile_validate(const ControllerProfile& profile) {
|
bool controller_profile_validate(const ControllerProfile& profile) {
|
||||||
for (uint8_t output : profile.button_map) {
|
for (uint8_t output : profile.button_map) {
|
||||||
if (!valid_button(output)) {
|
if (!valid_control_output(output)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,18 +186,31 @@ bool controller_profile_validate(const ControllerProfile& profile) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bool routed_triggers[2]{};
|
||||||
for (const ControllerProfileTriggerConfiguration& trigger :
|
for (const ControllerProfileTriggerConfiguration& trigger :
|
||||||
profile.triggers) {
|
profile.triggers) {
|
||||||
if (trigger.lower_deadzone >= trigger.upper_saturation ||
|
if (trigger.lower_deadzone >= trigger.upper_saturation ||
|
||||||
trigger.curve_q8_8 == 0) {
|
trigger.curve_q8_8 == 0 ||
|
||||||
|
!valid_control_output(trigger.output)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (trigger.output >= CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||||
|
trigger.output <= CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL) {
|
||||||
|
const uint8_t target = static_cast<uint8_t>(
|
||||||
|
trigger.output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||||
|
if (routed_triggers[target]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
routed_triggers[target] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (static_cast<uint8_t>(profile.confirmation_policy) >
|
if (static_cast<uint8_t>(profile.confirmation_policy) >
|
||||||
static_cast<uint8_t>(
|
static_cast<uint8_t>(
|
||||||
ControllerProfileConfirmationPolicy::kRumbleAndLed) ||
|
ControllerProfileConfirmationPolicy::kRumbleAndLed) ||
|
||||||
!valid_button(profile.macro_trigger) ||
|
!valid_control_output(profile.macro_cancel) ||
|
||||||
!valid_button(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 == 0 ||
|
||||||
profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
|
profile.macro_step_count > CONTROLLER_PROFILE_MACRO_STEP_CAPACITY) {
|
||||||
return false;
|
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[2], trigger.upper_saturation);
|
||||||
profile_write_u16(&encoded[4], trigger.curve_q8_8);
|
profile_write_u16(&encoded[4], trigger.curve_q8_8);
|
||||||
profile_write_u16(&encoded[6], trigger.digital_threshold);
|
profile_write_u16(&encoded[6], trigger.digital_threshold);
|
||||||
|
encoded[8] = trigger.output;
|
||||||
}
|
}
|
||||||
output[72] = profile.weak_rumble_scale;
|
output[72] = profile.weak_rumble_scale;
|
||||||
output[73] = profile.strong_rumble_scale;
|
output[73] = profile.strong_rumble_scale;
|
||||||
output[74] = static_cast<uint8_t>(profile.confirmation_policy);
|
output[74] = static_cast<uint8_t>(profile.confirmation_policy);
|
||||||
profile_write_u16(&output[76], profile.switching_chord);
|
output[75] = static_cast<uint8_t>(
|
||||||
output[78] = profile.macro_trigger;
|
((profile.switching_chord >> 16) & 0x03u) |
|
||||||
output[79] = profile.macro_cancel;
|
(((profile.macro_trigger_mask >> 16) & 0x03u) << 2) |
|
||||||
|
(((profile.motion_toggle_chord >> 16) & 0x03u) << 4));
|
||||||
|
profile_write_u16(
|
||||||
|
&output[76], static_cast<uint16_t>(profile.switching_chord));
|
||||||
|
profile_write_u16(
|
||||||
|
&output[78], static_cast<uint16_t>(profile.macro_trigger_mask));
|
||||||
output[80] = profile.macro_step_count;
|
output[80] = profile.macro_step_count;
|
||||||
|
output[81] = profile.macro_cancel;
|
||||||
|
profile_write_u16(
|
||||||
|
&output[98], static_cast<uint16_t>(profile.motion_toggle_chord));
|
||||||
for (uint8_t index = 0;
|
for (uint8_t index = 0;
|
||||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||||
output[82 + index] =
|
output[82 + index] =
|
||||||
|
|
@ -275,14 +309,26 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const uint16_t schema_version = profile_read_u16(&input[0]);
|
const uint16_t schema_version = profile_read_u16(&input[0]);
|
||||||
if ((schema_version != CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
|
const bool has_control_mapping =
|
||||||
schema_version != CONTROLLER_PROFILE_SCHEMA_VERSION) ||
|
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_read_u16(&input[2]) != CONTROLLER_PROFILE_ENCODED_SIZE ||
|
||||||
!profile_bytes_are_zero(&input[31], 5) ||
|
!profile_bytes_are_zero(&input[31], 5) ||
|
||||||
!profile_bytes_are_zero(&input[47], 5) ||
|
!profile_bytes_are_zero(&input[47], 5) ||
|
||||||
!profile_bytes_are_zero(&input[60], 2) ||
|
!trigger_extension_valid ||
|
||||||
!profile_bytes_are_zero(&input[70], 2) || input[75] != 0 ||
|
(has_action_trigger_bits ? (input[75] & 0xc0u) != 0
|
||||||
input[81] != 0 || !profile_bytes_are_zero(&input[98], 2) ||
|
: 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)) {
|
!profile_bytes_are_zero(&input[252], 4)) {
|
||||||
return false;
|
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.upper_saturation = profile_read_u16(&encoded[2]);
|
||||||
trigger.curve_q8_8 = profile_read_u16(&encoded[4]);
|
trigger.curve_q8_8 = profile_read_u16(&encoded[4]);
|
||||||
trigger.digital_threshold = profile_read_u16(&encoded[6]);
|
trigger.digital_threshold = profile_read_u16(&encoded[6]);
|
||||||
|
trigger.output =
|
||||||
|
has_control_mapping
|
||||||
|
? encoded[8]
|
||||||
|
: static_cast<uint8_t>(
|
||||||
|
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL + index);
|
||||||
if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
|
if (schema_version == CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION &&
|
||||||
trigger.digital_threshold == kLegacyDefaultDigitalThreshold) {
|
trigger.digital_threshold == kLegacyDefaultDigitalThreshold) {
|
||||||
trigger.digital_threshold =
|
trigger.digital_threshold =
|
||||||
|
|
@ -323,8 +374,29 @@ bool controller_profile_decode(const uint8_t* input, size_t input_size,
|
||||||
profile.confirmation_policy =
|
profile.confirmation_policy =
|
||||||
static_cast<ControllerProfileConfirmationPolicy>(input[74]);
|
static_cast<ControllerProfileConfirmationPolicy>(input[74]);
|
||||||
profile.switching_chord = profile_read_u16(&input[76]);
|
profile.switching_chord = profile_read_u16(&input[76]);
|
||||||
profile.macro_trigger = input[78];
|
profile.motion_toggle_chord =
|
||||||
profile.macro_cancel = input[79];
|
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<uint32_t>(1u << input[78])
|
||||||
|
: 0;
|
||||||
|
profile.macro_cancel = input[79];
|
||||||
|
}
|
||||||
|
if (has_action_trigger_bits) {
|
||||||
|
profile.switching_chord |=
|
||||||
|
static_cast<uint32_t>(input[75] & 0x03u) << 16;
|
||||||
|
profile.macro_trigger_mask |=
|
||||||
|
static_cast<uint32_t>((input[75] >> 2) & 0x03u) << 16;
|
||||||
|
profile.motion_toggle_chord |=
|
||||||
|
static_cast<uint32_t>((input[75] >> 4) & 0x03u) << 16;
|
||||||
|
}
|
||||||
profile.macro_step_count = input[80];
|
profile.macro_step_count = input[80];
|
||||||
for (uint8_t index = 0;
|
for (uint8_t index = 0;
|
||||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,15 @@
|
||||||
#include "core/controller_identity.h"
|
#include "core/controller_identity.h"
|
||||||
|
|
||||||
constexpr uint16_t CONTROLLER_PROFILE_LEGACY_SCHEMA_VERSION = 1;
|
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 size_t CONTROLLER_PROFILE_ENCODED_SIZE = 256;
|
||||||
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4;
|
constexpr uint8_t CONTROLLER_PROFILE_COUNT = 4;
|
||||||
constexpr uint8_t CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT = 16;
|
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 uint8_t CONTROLLER_PROFILE_MACRO_STEP_CAPACITY = 8;
|
||||||
constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000;
|
constexpr uint16_t CONTROLLER_PROFILE_MAX_WAIT_MS = 10000;
|
||||||
constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff;
|
constexpr uint8_t CONTROLLER_PROFILE_NO_BUTTON = 0xff;
|
||||||
|
|
@ -99,6 +104,8 @@ struct ControllerProfileTriggerConfiguration {
|
||||||
// Compared against the transformed uint16 trigger output.
|
// Compared against the transformed uint16 trigger output.
|
||||||
uint16_t digital_threshold =
|
uint16_t digital_threshold =
|
||||||
CONTROLLER_PROFILE_DEFAULT_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 {
|
struct ControllerProfileMacroStep {
|
||||||
|
|
@ -123,8 +130,9 @@ struct ControllerProfile {
|
||||||
uint8_t strong_rumble_scale = UINT8_MAX;
|
uint8_t strong_rumble_scale = UINT8_MAX;
|
||||||
ControllerProfileConfirmationPolicy confirmation_policy =
|
ControllerProfileConfirmationPolicy confirmation_policy =
|
||||||
ControllerProfileConfirmationPolicy::kRumbleAndLed;
|
ControllerProfileConfirmationPolicy::kRumbleAndLed;
|
||||||
uint16_t switching_chord = 0;
|
uint32_t switching_chord = 0;
|
||||||
uint8_t macro_trigger = CONTROLLER_PROFILE_NO_BUTTON;
|
uint32_t motion_toggle_chord = 0;
|
||||||
|
uint32_t macro_trigger_mask = 0;
|
||||||
uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON;
|
uint8_t macro_cancel = CONTROLLER_PROFILE_NO_BUTTON;
|
||||||
uint8_t macro_step_count = 1;
|
uint8_t macro_step_count = 1;
|
||||||
ControllerProfileTurboMode
|
ControllerProfileTurboMode
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,9 @@ struct ControllerProfileRuntimeContext {
|
||||||
uint32_t configuration_reset_generation = 0;
|
uint32_t configuration_reset_generation = 0;
|
||||||
bool switching_chord_held = false;
|
bool switching_chord_held = false;
|
||||||
bool switching_chord_armed = true;
|
bool switching_chord_armed = true;
|
||||||
|
bool motion_toggle_chord_held = false;
|
||||||
bool switching_activation_requested = 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;
|
uint8_t switching_target_profile_index = 0;
|
||||||
uint32_t switching_transaction_id = 0;
|
uint32_t switching_transaction_id = 0;
|
||||||
bool profile_change_pending = false;
|
bool profile_change_pending = false;
|
||||||
|
|
@ -49,12 +50,19 @@ uint32_t next_activation_transaction_id() {
|
||||||
return 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
|
return profile.switching_chord == 0
|
||||||
? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD
|
? CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD
|
||||||
: profile.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() {
|
void initialize_defaults() {
|
||||||
if (g_initialized) {
|
if (g_initialized) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -77,7 +85,7 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
||||||
const ControllerIdentity& identity,
|
const ControllerIdentity& identity,
|
||||||
uint32_t connection_generation,
|
uint32_t connection_generation,
|
||||||
uint32_t observed_database_generation,
|
uint32_t observed_database_generation,
|
||||||
uint16_t current_input_button_mask) {
|
const ControllerState& current_input) {
|
||||||
ProfileServiceActiveProfileSnapshot snapshot{};
|
ProfileServiceActiveProfileSnapshot snapshot{};
|
||||||
profile_service_active_profile_snapshot(identity, &snapshot);
|
profile_service_active_profile_snapshot(identity, &snapshot);
|
||||||
|
|
||||||
|
|
@ -106,8 +114,6 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
||||||
context->pending_initial_profile_indication = {};
|
context->pending_initial_profile_indication = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
controller_synthetic_input_cancel(&context->synthetic,
|
|
||||||
current_input_button_mask);
|
|
||||||
context->active = true;
|
context->active = true;
|
||||||
context->connection_generation = connection_generation;
|
context->connection_generation = connection_generation;
|
||||||
context->identity = identity;
|
context->identity = identity;
|
||||||
|
|
@ -117,6 +123,15 @@ void refresh_profile(ControllerProfileRuntimeContext* context,
|
||||||
context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0;
|
context->active_profile_index = snapshot.valid ? snapshot.profile_index : 0;
|
||||||
context->profile_snapshot_valid = snapshot.valid;
|
context->profile_snapshot_valid = snapshot.valid;
|
||||||
context->profile = snapshot.valid ? snapshot.profile : g_default_profile;
|
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) {
|
if (!context->initial_profile_indication_resolved && snapshot.valid) {
|
||||||
context->initial_profile_indication_resolved = true;
|
context->initial_profile_indication_resolved = true;
|
||||||
const uint8_t policy = static_cast<uint8_t>(
|
const uint8_t policy = static_cast<uint8_t>(
|
||||||
|
|
@ -173,30 +188,25 @@ ControllerProfileRuntimeContext* update_context(
|
||||||
context.database_generation != database_generation) {
|
context.database_generation != database_generation) {
|
||||||
refresh_profile(
|
refresh_profile(
|
||||||
&context, snapshot.identity, snapshot.connection_generation,
|
&context, snapshot.identity, snapshot.connection_generation,
|
||||||
database_generation,
|
database_generation, snapshot.state);
|
||||||
controller_profile_extract_button_mask(snapshot.state));
|
|
||||||
}
|
}
|
||||||
return &context;
|
return &context;
|
||||||
}
|
}
|
||||||
void process_profile_switching(
|
void process_profile_switching(
|
||||||
ControllerProfileRuntimeContext* context,
|
ControllerProfileRuntimeContext* context,
|
||||||
const ControllerIdentity& identity,
|
const ControllerIdentity& identity,
|
||||||
uint16_t switching_input_button_mask,
|
uint32_t switching_input_control_mask,
|
||||||
uint16_t current_input_button_mask,
|
|
||||||
ControllerState* consumed_input) {
|
ControllerState* consumed_input) {
|
||||||
if (context == nullptr || consumed_input == nullptr) {
|
if (context == nullptr || consumed_input == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context->switching_chord_held) {
|
if (context->switching_chord_held) {
|
||||||
if ((switching_input_button_mask &
|
if ((switching_input_control_mask &
|
||||||
context->held_switching_chord) ==
|
context->held_switching_chord) ==
|
||||||
context->held_switching_chord) {
|
context->held_switching_chord) {
|
||||||
controller_profile_apply_button_mask(
|
controller_profile_remove_control_mask(
|
||||||
static_cast<uint16_t>(
|
context->held_switching_chord, consumed_input);
|
||||||
current_input_button_mask &
|
|
||||||
~context->held_switching_chord),
|
|
||||||
consumed_input);
|
|
||||||
if (!context->switching_activation_requested) {
|
if (!context->switching_activation_requested) {
|
||||||
const ConfigurationTransactionStatus status =
|
const ConfigurationTransactionStatus status =
|
||||||
profile_service_activate_internal(
|
profile_service_activate_internal(
|
||||||
|
|
@ -214,10 +224,10 @@ void process_profile_switching(
|
||||||
context->switching_transaction_id = 0;
|
context->switching_transaction_id = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint16_t chord =
|
const uint32_t chord =
|
||||||
effective_switching_chord(context->profile);
|
effective_switching_chord(context->profile);
|
||||||
const bool chord_fully_held =
|
const bool chord_fully_held =
|
||||||
(switching_input_button_mask & chord) == chord;
|
(switching_input_control_mask & chord) == chord;
|
||||||
if (!chord_fully_held) {
|
if (!chord_fully_held) {
|
||||||
context->switching_chord_armed = true;
|
context->switching_chord_armed = true;
|
||||||
return;
|
return;
|
||||||
|
|
@ -235,10 +245,8 @@ void process_profile_switching(
|
||||||
CONTROLLER_PROFILE_COUNT);
|
CONTROLLER_PROFILE_COUNT);
|
||||||
context->switching_transaction_id =
|
context->switching_transaction_id =
|
||||||
next_activation_transaction_id();
|
next_activation_transaction_id();
|
||||||
controller_profile_apply_button_mask(
|
controller_profile_remove_control_mask(
|
||||||
static_cast<uint16_t>(
|
context->held_switching_chord, consumed_input);
|
||||||
current_input_button_mask & ~context->held_switching_chord),
|
|
||||||
consumed_input);
|
|
||||||
const ConfigurationTransactionStatus status =
|
const ConfigurationTransactionStatus status =
|
||||||
profile_service_activate_internal(
|
profile_service_activate_internal(
|
||||||
context->switching_transaction_id, identity,
|
context->switching_transaction_id, identity,
|
||||||
|
|
@ -269,11 +277,30 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
|
||||||
return g_neutral_output;
|
return g_neutral_output;
|
||||||
}
|
}
|
||||||
ControllerState consumed_input = snapshot.state;
|
ControllerState consumed_input = snapshot.state;
|
||||||
const uint16_t current_input_button_mask =
|
const uint32_t state_control_mask =
|
||||||
controller_profile_extract_button_mask(snapshot.state);
|
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(
|
process_profile_switching(
|
||||||
context, snapshot.identity, snapshot.pre_hotkey_button_mask,
|
context, snapshot.identity, input_control_mask,
|
||||||
current_input_button_mask, &consumed_input);
|
&consumed_input);
|
||||||
|
|
||||||
const uint32_t reset_generation =
|
const uint32_t reset_generation =
|
||||||
configuration_service_reset_generation();
|
configuration_service_reset_generation();
|
||||||
|
|
@ -286,7 +313,8 @@ ControllerProfileTransformResult controller_profile_runtime_transform(
|
||||||
reset_generation) {
|
reset_generation) {
|
||||||
controller_synthetic_input_cancel(
|
controller_synthetic_input_cancel(
|
||||||
&context->synthetic,
|
&context->synthetic,
|
||||||
controller_profile_extract_button_mask(consumed_input));
|
controller_profile_extract_control_mask(
|
||||||
|
consumed_input, context->profile));
|
||||||
context->output_mode = output_mode;
|
context->output_mode = output_mode;
|
||||||
context->configuration_reset_generation = reset_generation;
|
context->configuration_reset_generation = reset_generation;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_SWITCHING_CHORD =
|
||||||
ControllerProfileLogicalButton::kSelect)) |
|
ControllerProfileLogicalButton::kSelect)) |
|
||||||
(1u << static_cast<uint8_t>(
|
(1u << static_cast<uint8_t>(
|
||||||
ControllerProfileLogicalButton::kStart)));
|
ControllerProfileLogicalButton::kStart)));
|
||||||
|
constexpr uint16_t CONTROLLER_PROFILE_DEFAULT_MOTION_TOGGLE_CHORD =
|
||||||
|
static_cast<uint16_t>(
|
||||||
|
(1u << static_cast<uint8_t>(
|
||||||
|
ControllerProfileLogicalButton::kDpadUp)) |
|
||||||
|
(1u << static_cast<uint8_t>(
|
||||||
|
ControllerProfileLogicalButton::kRightShoulder)) |
|
||||||
|
(1u << static_cast<uint8_t>(
|
||||||
|
ControllerProfileLogicalButton::kStart)));
|
||||||
|
|
||||||
struct ControllerProfileRuntimeProfileChangeEvent {
|
struct ControllerProfileRuntimeProfileChangeEvent {
|
||||||
uint32_t connection_generation = 0;
|
uint32_t connection_generation = 0;
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,26 @@ uint16_t transform_trigger(
|
||||||
kQ16One);
|
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<uint16_t>(1u << output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (output < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT) {
|
||||||
|
const uint8_t trigger_index = static_cast<uint8_t>(
|
||||||
|
output - CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||||
|
if (value > output_triggers[trigger_index]) {
|
||||||
|
output_triggers[trigger_index] = value;
|
||||||
|
}
|
||||||
|
output_thresholds[trigger_index] = digital_threshold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
uint16_t controller_profile_extract_button_mask(const ControllerState& state) {
|
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;
|
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<uint32_t>(
|
||||||
|
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<uint16_t>(
|
||||||
|
buttons & ~static_cast<uint16_t>(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,
|
void controller_profile_apply_button_mask(uint16_t button_mask,
|
||||||
ControllerState* state) {
|
ControllerState* state) {
|
||||||
if (state == nullptr) {
|
if (state == nullptr) {
|
||||||
|
|
@ -297,9 +355,42 @@ ControllerProfileTransformResult controller_profile_transform(
|
||||||
result.state = input;
|
result.state = input;
|
||||||
const uint16_t input_button_mask =
|
const uint16_t input_button_mask =
|
||||||
controller_profile_extract_button_mask(input);
|
controller_profile_extract_button_mask(input);
|
||||||
controller_profile_apply_button_mask(
|
uint16_t output_button_mask =
|
||||||
controller_profile_map_button_mask(input_button_mask, profile),
|
controller_profile_map_button_mask(input_button_mask, profile);
|
||||||
&result.state);
|
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<uint16_t>(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,
|
transform_stick(profile.sticks[0], input.left_stick_x,
|
||||||
input.left_stick_y, &result.state.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,
|
transform_stick(profile.sticks[1], input.right_stick_x,
|
||||||
input.right_stick_y, &result.state.right_stick_x,
|
input.right_stick_y, &result.state.right_stick_x,
|
||||||
&result.state.right_stick_y);
|
&result.state.right_stick_y);
|
||||||
result.state.left_trigger = transform_trigger(input.left_trigger,
|
result.state.left_trigger = output_triggers[0];
|
||||||
profile.triggers[0]);
|
result.state.right_trigger = output_triggers[1];
|
||||||
result.state.right_trigger = transform_trigger(input.right_trigger,
|
result.left_trigger_digital_threshold = output_thresholds[0];
|
||||||
profile.triggers[1]);
|
result.right_trigger_digital_threshold = output_thresholds[1];
|
||||||
result.left_trigger_digital_threshold =
|
|
||||||
profile.triggers[0].digital_threshold;
|
|
||||||
result.right_trigger_digital_threshold =
|
|
||||||
profile.triggers[1].digital_threshold;
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ struct ControllerProfileTransformResult {
|
||||||
};
|
};
|
||||||
|
|
||||||
uint16_t controller_profile_extract_button_mask(const ControllerState& state);
|
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,
|
void controller_profile_apply_button_mask(uint16_t button_mask,
|
||||||
ControllerState* state);
|
ControllerState* state);
|
||||||
uint16_t controller_profile_map_button_mask(
|
uint16_t controller_profile_map_button_mask(
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,12 @@ constexpr uint16_t button_bit(uint8_t button) {
|
||||||
return static_cast<uint16_t>(1u << button);
|
return static_cast<uint16_t>(1u << button);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool is_bound_button(uint8_t button) {
|
constexpr uint32_t control_bit(uint8_t control) {
|
||||||
return button < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
|
return static_cast<uint32_t>(1u << control);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_bound_control(uint8_t control) {
|
||||||
|
return control < CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear_binding(ControllerSyntheticBindingState* binding) {
|
void clear_binding(ControllerSyntheticBindingState* binding) {
|
||||||
|
|
@ -127,12 +131,12 @@ void apply_macro_override(const ControllerProfileMacroStep& step,
|
||||||
|
|
||||||
void controller_synthetic_input_cancel(
|
void controller_synthetic_input_cancel(
|
||||||
ControllerSyntheticInputContext* context,
|
ControllerSyntheticInputContext* context,
|
||||||
uint16_t current_input_button_mask) {
|
uint32_t current_input_control_mask) {
|
||||||
if (context == nullptr) {
|
if (context == nullptr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*context = {};
|
*context = {};
|
||||||
context->previous_input_button_mask = current_input_button_mask;
|
context->previous_input_control_mask = current_input_control_mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
ControllerProfileTransformResult controller_synthetic_input_apply(
|
ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||||
|
|
@ -142,26 +146,35 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||||
return controller_profile_transform(input, profile);
|
return controller_profile_transform(input, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint16_t input_button_mask =
|
const uint32_t input_control_mask =
|
||||||
controller_profile_extract_button_mask(input);
|
controller_profile_extract_control_mask(input, profile);
|
||||||
uint16_t rising_button_mask = static_cast<uint16_t>(
|
uint32_t rising_control_mask =
|
||||||
input_button_mask & ~context->previous_input_button_mask);
|
input_control_mask & ~context->previous_input_control_mask;
|
||||||
const bool cancel_pressed =
|
const bool cancel_pressed =
|
||||||
is_bound_button(profile.macro_cancel) &&
|
is_bound_control(profile.macro_cancel) &&
|
||||||
(input_button_mask & button_bit(profile.macro_cancel)) != 0;
|
(input_control_mask & control_bit(profile.macro_cancel)) != 0;
|
||||||
if (cancel_pressed) {
|
if (cancel_pressed) {
|
||||||
controller_synthetic_input_cancel(context, input_button_mask);
|
controller_synthetic_input_cancel(context, input_control_mask);
|
||||||
rising_button_mask = 0;
|
rising_control_mask = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool macro_started = false;
|
bool macro_started = false;
|
||||||
if (!cancel_pressed && is_bound_button(profile.macro_trigger) &&
|
const bool trigger_chord_completed =
|
||||||
(rising_button_mask & button_bit(profile.macro_trigger)) != 0) {
|
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);
|
macro_started = start_macro(context, profile, now_ms);
|
||||||
}
|
}
|
||||||
if (!macro_started) {
|
if (!macro_started) {
|
||||||
advance_macro(context, profile, now_ms);
|
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;
|
uint16_t gated_input_button_mask = 0;
|
||||||
for (uint8_t input_button = 0;
|
for (uint8_t input_button = 0;
|
||||||
|
|
@ -169,15 +182,15 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||||
++input_button) {
|
++input_button) {
|
||||||
ControllerSyntheticBindingState& binding =
|
ControllerSyntheticBindingState& binding =
|
||||||
context->bindings[input_button];
|
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) {
|
input_button == profile.macro_cancel) {
|
||||||
clear_binding(&binding);
|
clear_binding(&binding);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const bool pressed = (input_control_mask & bit) != 0;
|
||||||
const uint16_t bit = button_bit(input_button);
|
const bool rising = (rising_control_mask & bit) != 0;
|
||||||
const bool pressed = (input_button_mask & bit) != 0;
|
|
||||||
const bool rising = (rising_button_mask & bit) != 0;
|
|
||||||
switch (profile.turbo_modes[input_button]) {
|
switch (profile.turbo_modes[input_button]) {
|
||||||
case ControllerProfileTurboMode::kOff:
|
case ControllerProfileTurboMode::kOff:
|
||||||
clear_binding(&binding);
|
clear_binding(&binding);
|
||||||
|
|
@ -219,6 +232,14 @@ ControllerProfileTransformResult controller_synthetic_input_apply(
|
||||||
ControllerState gated_input = input;
|
ControllerState gated_input = input;
|
||||||
controller_profile_apply_button_mask(gated_input_button_mask,
|
controller_profile_apply_button_mask(gated_input_button_mask,
|
||||||
&gated_input);
|
&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 =
|
ControllerProfileTransformResult result =
|
||||||
controller_profile_transform(gated_input, profile);
|
controller_profile_transform(gated_input, profile);
|
||||||
if (context->macro_active &&
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ struct ControllerSyntheticInputContext {
|
||||||
bool macro_active = false;
|
bool macro_active = false;
|
||||||
uint8_t macro_step_index = 0;
|
uint8_t macro_step_index = 0;
|
||||||
uint32_t macro_deadline_ms = 0;
|
uint32_t macro_deadline_ms = 0;
|
||||||
uint16_t previous_input_button_mask = 0;
|
uint32_t previous_input_control_mask = 0;
|
||||||
ControllerSyntheticBindingState
|
ControllerSyntheticBindingState
|
||||||
bindings[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
bindings[CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT]{};
|
||||||
};
|
};
|
||||||
|
|
@ -25,7 +25,7 @@ struct ControllerSyntheticInputContext {
|
||||||
// restart until they are released and pressed again.
|
// restart until they are released and pressed again.
|
||||||
void controller_synthetic_input_cancel(
|
void controller_synthetic_input_cancel(
|
||||||
ControllerSyntheticInputContext* context,
|
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
|
// Apply raw-input consumption, mapped physical contributions, Turbo/Auto Burst
|
||||||
// gating, and finally the active macro step's field overrides.
|
// gating, and finally the active macro step's field overrides.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import struct
|
import struct
|
||||||
|
|
@ -99,7 +100,9 @@ TRANSPORT_UNKNOWN = 0
|
||||||
TRANSPORT_CLASSIC = 1
|
TRANSPORT_CLASSIC = 1
|
||||||
TRANSPORT_BLE = 2
|
TRANSPORT_BLE = 2
|
||||||
PROFILE_LEGACY_SCHEMA_VERSION = 1
|
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_SIZE = 256
|
||||||
PROFILE_CAPACITY = 4
|
PROFILE_CAPACITY = 4
|
||||||
PROFILE_IDENTITY_CAPACITY = 16
|
PROFILE_IDENTITY_CAPACITY = 16
|
||||||
|
|
@ -131,6 +134,8 @@ LOGICAL_BUTTONS = (
|
||||||
"dpad_left",
|
"dpad_left",
|
||||||
"dpad_right",
|
"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")
|
RUMBLE_POLICIES = ("none", "rumble", "led", "rumble_and_led")
|
||||||
TURBO_MODES = ("off", "turbo", "auto_burst")
|
TURBO_MODES = ("off", "turbo", "auto_burst")
|
||||||
MACRO_STEP_TYPES = ("state", "end")
|
MACRO_STEP_TYPES = ("state", "end")
|
||||||
|
|
@ -332,6 +337,21 @@ def _button_name(value: int) -> str | None:
|
||||||
return None
|
return None
|
||||||
return LOGICAL_BUTTONS[value]
|
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:
|
def _button_mask_from_json(value: Any, name: str) -> int:
|
||||||
if type(value) is not list:
|
if type(value) is not list:
|
||||||
|
|
@ -347,6 +367,28 @@ def _button_mask_from_json(value: Any, name: str) -> int:
|
||||||
mask |= bit
|
mask |= bit
|
||||||
return mask
|
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]:
|
def _button_mask_to_json(mask: int) -> list[str]:
|
||||||
return [
|
return [
|
||||||
|
|
@ -570,6 +612,7 @@ class TriggerConfig:
|
||||||
upper_saturation: int
|
upper_saturation: int
|
||||||
curve_q8_8: int
|
curve_q8_8: int
|
||||||
digital_threshold: int
|
digital_threshold: int
|
||||||
|
output: int
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
_require_int(
|
_require_int(
|
||||||
|
|
@ -586,39 +629,83 @@ class TriggerConfig:
|
||||||
_require_int(
|
_require_int(
|
||||||
self.digital_threshold, "trigger digital_threshold", 0, 0xFFFF
|
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
|
@classmethod
|
||||||
def from_bytes(cls, payload: bytes) -> TriggerConfig:
|
def from_bytes(
|
||||||
if len(payload) != 10 or payload[8:] != b"\x00\x00":
|
cls,
|
||||||
|
payload: bytes,
|
||||||
|
*,
|
||||||
|
schema_version: int,
|
||||||
|
source_index: int,
|
||||||
|
) -> TriggerConfig:
|
||||||
|
if len(payload) != 10 or payload[9] != 0:
|
||||||
raise ConfigManagerError("invalid trigger configuration encoding")
|
raise ConfigManagerError("invalid trigger configuration encoding")
|
||||||
return cls(*struct.unpack("<HHHH", payload[:8]))
|
if (
|
||||||
|
schema_version < PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
|
||||||
|
and payload[8] != 0
|
||||||
|
):
|
||||||
|
raise ConfigManagerError("invalid legacy trigger configuration")
|
||||||
|
output = (
|
||||||
|
payload[8]
|
||||||
|
if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
|
||||||
|
else len(LOGICAL_BUTTONS) + source_index
|
||||||
|
)
|
||||||
|
lower, upper, curve_q8_8, threshold = struct.unpack(
|
||||||
|
"<HHHH", payload[:8]
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
lower, upper, curve_q8_8, threshold, output
|
||||||
|
)
|
||||||
|
|
||||||
def to_bytes(self) -> bytes:
|
def to_bytes(self) -> bytes:
|
||||||
return struct.pack(
|
return struct.pack(
|
||||||
"<HHHH2x",
|
"<HHHHBB",
|
||||||
self.lower_deadzone,
|
self.lower_deadzone,
|
||||||
self.upper_saturation,
|
self.upper_saturation,
|
||||||
self.curve_q8_8,
|
self.curve_q8_8,
|
||||||
self.digital_threshold,
|
self.digital_threshold,
|
||||||
|
self.output,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_json_object(self) -> dict[str, int]:
|
def to_json_object(self) -> dict[str, int | str | None]:
|
||||||
return {
|
return {
|
||||||
"lower_deadzone": self.lower_deadzone,
|
"lower_deadzone": self.lower_deadzone,
|
||||||
"upper_saturation": self.upper_saturation,
|
"upper_saturation": self.upper_saturation,
|
||||||
"curve_q8_8": self.curve_q8_8,
|
"curve_q8_8": self.curve_q8_8,
|
||||||
"digital_threshold": self.digital_threshold,
|
"digital_threshold": self.digital_threshold,
|
||||||
|
"output": _control_name(self.output),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@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 = (
|
fields = (
|
||||||
"lower_deadzone",
|
"lower_deadzone",
|
||||||
"upper_saturation",
|
"upper_saturation",
|
||||||
"curve_q8_8",
|
"curve_q8_8",
|
||||||
"digital_threshold",
|
"digital_threshold",
|
||||||
)
|
)
|
||||||
|
if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
|
||||||
|
fields += ("output",)
|
||||||
obj = _require_object(value, fields, name)
|
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(
|
return cls(
|
||||||
_require_int(
|
_require_int(
|
||||||
obj["lower_deadzone"],
|
obj["lower_deadzone"],
|
||||||
|
|
@ -641,8 +728,10 @@ class TriggerConfig:
|
||||||
0,
|
0,
|
||||||
0xFFFF,
|
0xFFFF,
|
||||||
),
|
),
|
||||||
|
output,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig:
|
def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig:
|
||||||
if (
|
if (
|
||||||
trigger.digital_threshold
|
trigger.digital_threshold
|
||||||
|
|
@ -654,6 +743,7 @@ def _migrate_legacy_trigger_threshold(trigger: TriggerConfig) -> TriggerConfig:
|
||||||
trigger.upper_saturation,
|
trigger.upper_saturation,
|
||||||
trigger.curve_q8_8,
|
trigger.curve_q8_8,
|
||||||
PROFILE_DEFAULT_DIGITAL_THRESHOLD,
|
PROFILE_DEFAULT_DIGITAL_THRESHOLD,
|
||||||
|
trigger.output,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -877,7 +967,8 @@ class ControllerProfile:
|
||||||
strong_rumble_scale: int
|
strong_rumble_scale: int
|
||||||
confirmation_policy: int
|
confirmation_policy: int
|
||||||
switching_chord: int
|
switching_chord: int
|
||||||
macro_trigger: int
|
motion_toggle_chord: int
|
||||||
|
macro_trigger_mask: int
|
||||||
macro_cancel: int
|
macro_cancel: int
|
||||||
macro_steps: tuple[MacroStep, ...]
|
macro_steps: tuple[MacroStep, ...]
|
||||||
turbo_modes: tuple[int, ...]
|
turbo_modes: tuple[int, ...]
|
||||||
|
|
@ -892,9 +983,9 @@ class ControllerProfile:
|
||||||
for mapping in self.button_map:
|
for mapping in self.button_map:
|
||||||
if type(mapping) is not int or (
|
if type(mapping) is not int or (
|
||||||
mapping != PROFILE_NONE_BUTTON
|
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(
|
if not isinstance(self.left_stick, StickConfig) or not isinstance(
|
||||||
self.right_stick, StickConfig
|
self.right_stick, StickConfig
|
||||||
):
|
):
|
||||||
|
|
@ -905,6 +996,15 @@ class ControllerProfile:
|
||||||
raise ConfigManagerError(
|
raise ConfigManagerError(
|
||||||
"profile triggers must be TriggerConfig values"
|
"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(
|
_require_int(
|
||||||
self.weak_rumble_scale, "weak rumble scale", 0, 0xFF
|
self.weak_rumble_scale, "weak rumble scale", 0, 0xFF
|
||||||
)
|
)
|
||||||
|
|
@ -917,16 +1017,29 @@ class ControllerProfile:
|
||||||
0,
|
0,
|
||||||
len(RUMBLE_POLICIES) - 1,
|
len(RUMBLE_POLICIES) - 1,
|
||||||
)
|
)
|
||||||
_require_int(self.switching_chord, "switching chord", 0, 0xFFFF)
|
_require_int(
|
||||||
for value, name in (
|
self.switching_chord,
|
||||||
(self.macro_trigger, "macro trigger"),
|
"switching chord",
|
||||||
(self.macro_cancel, "macro cancel"),
|
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 (
|
raise ConfigManagerError("invalid macro cancel")
|
||||||
value != PROFILE_NONE_BUTTON
|
|
||||||
and not 0 <= value < len(LOGICAL_BUTTONS)
|
|
||||||
):
|
|
||||||
raise ConfigManagerError(f"invalid {name}")
|
|
||||||
if (
|
if (
|
||||||
type(self.macro_steps) is not tuple
|
type(self.macro_steps) is not tuple
|
||||||
or not 1 <= len(self.macro_steps) <= PROFILE_MACRO_STEP_CAPACITY
|
or not 1 <= len(self.macro_steps) <= PROFILE_MACRO_STEP_CAPACITY
|
||||||
|
|
@ -947,20 +1060,32 @@ class ControllerProfile:
|
||||||
@classmethod
|
@classmethod
|
||||||
def default(cls) -> ControllerProfile:
|
def default(cls) -> ControllerProfile:
|
||||||
stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False)
|
stick = StickConfig(0, 0, 0, 0x7FFF, 256, False, False)
|
||||||
trigger = TriggerConfig(
|
left_trigger = TriggerConfig(
|
||||||
0, 0xFFFF, 256, PROFILE_DEFAULT_DIGITAL_THRESHOLD
|
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(
|
return cls(
|
||||||
button_map=tuple(range(len(LOGICAL_BUTTONS))),
|
button_map=tuple(range(len(LOGICAL_BUTTONS))),
|
||||||
left_stick=stick,
|
left_stick=stick,
|
||||||
right_stick=stick,
|
right_stick=stick,
|
||||||
left_trigger=trigger,
|
left_trigger=left_trigger,
|
||||||
right_trigger=trigger,
|
right_trigger=right_trigger,
|
||||||
weak_rumble_scale=0xFF,
|
weak_rumble_scale=0xFF,
|
||||||
strong_rumble_scale=0xFF,
|
strong_rumble_scale=0xFF,
|
||||||
confirmation_policy=3,
|
confirmation_policy=3,
|
||||||
switching_chord=0,
|
switching_chord=0,
|
||||||
macro_trigger=PROFILE_NONE_BUTTON,
|
motion_toggle_chord=0,
|
||||||
|
macro_trigger_mask=0,
|
||||||
macro_cancel=PROFILE_NONE_BUTTON,
|
macro_cancel=PROFILE_NONE_BUTTON,
|
||||||
macro_steps=(MacroStep.end(),),
|
macro_steps=(MacroStep.end(),),
|
||||||
turbo_modes=(0,) * len(LOGICAL_BUTTONS),
|
turbo_modes=(0,) * len(LOGICAL_BUTTONS),
|
||||||
|
|
@ -973,15 +1098,26 @@ class ControllerProfile:
|
||||||
raise ConfigManagerError("invalid profile size")
|
raise ConfigManagerError("invalid profile size")
|
||||||
version, size = struct.unpack_from("<HH", payload)
|
version, size = struct.unpack_from("<HH", payload)
|
||||||
if (
|
if (
|
||||||
version
|
version < PROFILE_LEGACY_SCHEMA_VERSION
|
||||||
not in (PROFILE_LEGACY_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION)
|
or version > PROFILE_SCHEMA_VERSION
|
||||||
or size != PROFILE_SIZE
|
or size != PROFILE_SIZE
|
||||||
):
|
):
|
||||||
raise ConfigManagerError("unsupported profile schema")
|
raise ConfigManagerError("unsupported profile schema")
|
||||||
if payload[75] != 0 or payload[81] != 0:
|
has_control_mapping = (
|
||||||
raise ConfigManagerError("profile reserved fields must be zero")
|
version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION
|
||||||
if payload[98:100] != b"\x00\x00" or payload[252:] != bytes(4):
|
)
|
||||||
|
has_action_trigger_bits = version == PROFILE_SCHEMA_VERSION
|
||||||
|
if payload[252:] != bytes(4):
|
||||||
raise ConfigManagerError("profile reserved fields must be zero")
|
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]
|
macro_count = payload[80]
|
||||||
if not 1 <= macro_count <= PROFILE_MACRO_STEP_CAPACITY:
|
if not 1 <= macro_count <= PROFILE_MACRO_STEP_CAPACITY:
|
||||||
raise ConfigManagerError("invalid macro step count")
|
raise ConfigManagerError("invalid macro step count")
|
||||||
|
|
@ -999,11 +1135,38 @@ class ControllerProfile:
|
||||||
step != MacroStep.end() for step in all_steps[macro_count:]
|
step != MacroStep.end() for step in all_steps[macro_count:]
|
||||||
):
|
):
|
||||||
raise ConfigManagerError("unused macro steps must be canonical end")
|
raise ConfigManagerError("unused macro steps must be canonical end")
|
||||||
left_trigger = TriggerConfig.from_bytes(payload[52:62])
|
left_trigger = TriggerConfig.from_bytes(
|
||||||
right_trigger = TriggerConfig.from_bytes(payload[62:72])
|
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:
|
if version == PROFILE_LEGACY_SCHEMA_VERSION:
|
||||||
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
|
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
|
||||||
right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
|
right_trigger = _migrate_legacy_trigger_threshold(right_trigger)
|
||||||
|
if has_control_mapping:
|
||||||
|
macro_trigger_mask = struct.unpack_from("<H", payload, 78)[0]
|
||||||
|
macro_cancel = payload[81]
|
||||||
|
motion_toggle_chord = struct.unpack_from("<H", payload, 98)[0]
|
||||||
|
else:
|
||||||
|
legacy_trigger = payload[78]
|
||||||
|
if legacy_trigger != PROFILE_NONE_BUTTON and not (
|
||||||
|
0 <= legacy_trigger < len(LOGICAL_BUTTONS)
|
||||||
|
):
|
||||||
|
raise ConfigManagerError("invalid legacy macro trigger")
|
||||||
|
macro_trigger_mask = (
|
||||||
|
0
|
||||||
|
if legacy_trigger == PROFILE_NONE_BUTTON
|
||||||
|
else 1 << legacy_trigger
|
||||||
|
)
|
||||||
|
macro_cancel = payload[79]
|
||||||
|
motion_toggle_chord = 0
|
||||||
|
switching_chord = struct.unpack_from("<H", payload, 76)[0]
|
||||||
|
if has_action_trigger_bits:
|
||||||
|
action_bits = payload[75]
|
||||||
|
switching_chord |= (action_bits & 0x03) << 16
|
||||||
|
macro_trigger_mask |= ((action_bits >> 2) & 0x03) << 16
|
||||||
|
motion_toggle_chord |= ((action_bits >> 4) & 0x03) << 16
|
||||||
return cls(
|
return cls(
|
||||||
button_map=tuple(payload[4:20]),
|
button_map=tuple(payload[4:20]),
|
||||||
left_stick=StickConfig.from_bytes(payload[20:36]),
|
left_stick=StickConfig.from_bytes(payload[20:36]),
|
||||||
|
|
@ -1013,9 +1176,10 @@ class ControllerProfile:
|
||||||
weak_rumble_scale=payload[72],
|
weak_rumble_scale=payload[72],
|
||||||
strong_rumble_scale=payload[73],
|
strong_rumble_scale=payload[73],
|
||||||
confirmation_policy=payload[74],
|
confirmation_policy=payload[74],
|
||||||
switching_chord=struct.unpack_from("<H", payload, 76)[0],
|
switching_chord=switching_chord,
|
||||||
macro_trigger=payload[78],
|
motion_toggle_chord=motion_toggle_chord,
|
||||||
macro_cancel=payload[79],
|
macro_trigger_mask=macro_trigger_mask,
|
||||||
|
macro_cancel=macro_cancel,
|
||||||
macro_steps=all_steps[:macro_count],
|
macro_steps=all_steps[:macro_count],
|
||||||
turbo_modes=tuple(payload[82:98]),
|
turbo_modes=tuple(payload[82:98]),
|
||||||
)
|
)
|
||||||
|
|
@ -1030,24 +1194,24 @@ class ControllerProfile:
|
||||||
payload[36:52] = self.right_stick.to_bytes()
|
payload[36:52] = self.right_stick.to_bytes()
|
||||||
payload[52:62] = self.left_trigger.to_bytes()
|
payload[52:62] = self.left_trigger.to_bytes()
|
||||||
payload[62:72] = self.right_trigger.to_bytes()
|
payload[62:72] = self.right_trigger.to_bytes()
|
||||||
payload[72:76] = bytes(
|
payload[72:75] = bytes(
|
||||||
(
|
(
|
||||||
self.weak_rumble_scale,
|
self.weak_rumble_scale,
|
||||||
self.strong_rumble_scale,
|
self.strong_rumble_scale,
|
||||||
self.confirmation_policy,
|
self.confirmation_policy,
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
struct.pack_into("<H", payload, 76, self.switching_chord)
|
payload[75] = (
|
||||||
payload[78:82] = bytes(
|
((self.switching_chord >> 16) & 0x03)
|
||||||
(
|
| (((self.macro_trigger_mask >> 16) & 0x03) << 2)
|
||||||
self.macro_trigger,
|
| (((self.motion_toggle_chord >> 16) & 0x03) << 4)
|
||||||
self.macro_cancel,
|
|
||||||
len(self.macro_steps),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
struct.pack_into("<H", payload, 76, self.switching_chord & 0xFFFF)
|
||||||
|
struct.pack_into("<H", payload, 78, self.macro_trigger_mask & 0xFFFF)
|
||||||
|
payload[80] = len(self.macro_steps)
|
||||||
|
payload[81] = self.macro_cancel
|
||||||
payload[82:98] = bytes(self.turbo_modes)
|
payload[82:98] = bytes(self.turbo_modes)
|
||||||
|
struct.pack_into("<H", payload, 98, self.motion_toggle_chord & 0xFFFF)
|
||||||
for index in range(PROFILE_MACRO_STEP_CAPACITY):
|
for index in range(PROFILE_MACRO_STEP_CAPACITY):
|
||||||
step = (
|
step = (
|
||||||
self.macro_steps[index]
|
self.macro_steps[index]
|
||||||
|
|
@ -1063,7 +1227,7 @@ class ControllerProfile:
|
||||||
"schema_version": PROFILE_SCHEMA_VERSION,
|
"schema_version": PROFILE_SCHEMA_VERSION,
|
||||||
"size": PROFILE_SIZE,
|
"size": PROFILE_SIZE,
|
||||||
"button_map": {
|
"button_map": {
|
||||||
name: _button_name(self.button_map[index])
|
name: _control_name(self.button_map[index])
|
||||||
for index, name in enumerate(LOGICAL_BUTTONS)
|
for index, name in enumerate(LOGICAL_BUTTONS)
|
||||||
},
|
},
|
||||||
"sticks": {
|
"sticks": {
|
||||||
|
|
@ -1081,10 +1245,13 @@ class ControllerProfile:
|
||||||
self.confirmation_policy
|
self.confirmation_policy
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"switching_chord": _button_mask_to_json(self.switching_chord),
|
"switching_chord": _control_mask_to_json(self.switching_chord),
|
||||||
|
"motion_toggle_chord": _control_mask_to_json(
|
||||||
|
self.motion_toggle_chord
|
||||||
|
),
|
||||||
"macro": {
|
"macro": {
|
||||||
"trigger": _button_name(self.macro_trigger),
|
"trigger": _control_mask_to_json(self.macro_trigger_mask),
|
||||||
"cancel": _button_name(self.macro_cancel),
|
"cancel": _control_name(self.macro_cancel),
|
||||||
"steps": [
|
"steps": [
|
||||||
step.to_json_object() for step in self.macro_steps
|
step.to_json_object() for step in self.macro_steps
|
||||||
],
|
],
|
||||||
|
|
@ -1100,6 +1267,19 @@ class ControllerProfile:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json_object(cls, value: Any) -> ControllerProfile:
|
def from_json_object(cls, value: Any) -> 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 = (
|
fields = (
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"size",
|
"size",
|
||||||
|
|
@ -1111,16 +1291,10 @@ class ControllerProfile:
|
||||||
"macro",
|
"macro",
|
||||||
"turbo",
|
"turbo",
|
||||||
)
|
)
|
||||||
|
if schema_version >= PROFILE_CONTROL_MAPPING_SCHEMA_VERSION:
|
||||||
|
fields += ("motion_toggle_chord",)
|
||||||
obj = _require_object(value, fields, "profile")
|
obj = _require_object(value, fields, "profile")
|
||||||
schema_version = _require_int(
|
if _require_int(obj["size"], "profile.size", 0, 0xFFFF) != PROFILE_SIZE:
|
||||||
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
|
|
||||||
):
|
|
||||||
raise ConfigManagerError("unsupported profile schema")
|
raise ConfigManagerError("unsupported profile schema")
|
||||||
button_map = _require_object(
|
button_map = _require_object(
|
||||||
obj["button_map"], LOGICAL_BUTTONS, "profile.button_map"
|
obj["button_map"], LOGICAL_BUTTONS, "profile.button_map"
|
||||||
|
|
@ -1150,17 +1324,67 @@ class ControllerProfile:
|
||||||
"profile.macro.steps must contain one to eight steps"
|
"profile.macro.steps must contain one to eight steps"
|
||||||
)
|
)
|
||||||
left_trigger = TriggerConfig.from_json_object(
|
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(
|
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:
|
if schema_version == PROFILE_LEGACY_SCHEMA_VERSION:
|
||||||
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
|
left_trigger = _migrate_legacy_trigger_threshold(left_trigger)
|
||||||
right_trigger = _migrate_legacy_trigger_threshold(right_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(
|
return cls(
|
||||||
button_map=tuple(
|
button_map=tuple(
|
||||||
_button_index(
|
_control_index(
|
||||||
button_map[name], f"profile.button_map.{name}"
|
button_map[name], f"profile.button_map.{name}"
|
||||||
)
|
)
|
||||||
for name in LOGICAL_BUTTONS
|
for name in LOGICAL_BUTTONS
|
||||||
|
|
@ -1190,15 +1414,10 @@ class ControllerProfile:
|
||||||
RUMBLE_POLICIES,
|
RUMBLE_POLICIES,
|
||||||
"profile.rumble.confirmation_policy",
|
"profile.rumble.confirmation_policy",
|
||||||
),
|
),
|
||||||
switching_chord=_button_mask_from_json(
|
switching_chord=switching_chord,
|
||||||
obj["switching_chord"], "profile.switching_chord"
|
motion_toggle_chord=motion_toggle_chord,
|
||||||
),
|
macro_trigger_mask=macro_trigger_mask,
|
||||||
macro_trigger=_button_index(
|
macro_cancel=macro_cancel,
|
||||||
macro["trigger"], "profile.macro.trigger"
|
|
||||||
),
|
|
||||||
macro_cancel=_button_index(
|
|
||||||
macro["cancel"], "profile.macro.cancel"
|
|
||||||
),
|
|
||||||
macro_steps=tuple(
|
macro_steps=tuple(
|
||||||
MacroStep.from_json_object(
|
MacroStep.from_json_object(
|
||||||
step, f"profile.macro.steps[{index}]"
|
step, f"profile.macro.steps[{index}]"
|
||||||
|
|
@ -1535,6 +1754,8 @@ def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]:
|
||||||
_raise_status(envelope)
|
_raise_status(envelope)
|
||||||
if envelope.schema_version not in (
|
if envelope.schema_version not in (
|
||||||
PROFILE_LEGACY_SCHEMA_VERSION,
|
PROFILE_LEGACY_SCHEMA_VERSION,
|
||||||
|
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
|
||||||
|
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION,
|
||||||
PROFILE_SCHEMA_VERSION,
|
PROFILE_SCHEMA_VERSION,
|
||||||
):
|
):
|
||||||
raise ConfigManagerError("unsupported profile-list schema")
|
raise ConfigManagerError("unsupported profile-list schema")
|
||||||
|
|
@ -1600,6 +1821,8 @@ def read_selected_profile(device: UsbDevice) -> ControllerProfile:
|
||||||
_raise_status(envelope)
|
_raise_status(envelope)
|
||||||
if envelope.schema_version not in (
|
if envelope.schema_version not in (
|
||||||
PROFILE_LEGACY_SCHEMA_VERSION,
|
PROFILE_LEGACY_SCHEMA_VERSION,
|
||||||
|
PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
|
||||||
|
PROFILE_CONTROL_MAPPING_SCHEMA_VERSION,
|
||||||
PROFILE_SCHEMA_VERSION,
|
PROFILE_SCHEMA_VERSION,
|
||||||
):
|
):
|
||||||
raise ConfigManagerError("unsupported profile schema")
|
raise ConfigManagerError("unsupported profile schema")
|
||||||
|
|
@ -2138,6 +2361,20 @@ def _identity_index(value: str) -> int:
|
||||||
)
|
)
|
||||||
return index
|
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:
|
def _add_identity_argument(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|
@ -2194,7 +2431,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
config_reset.add_argument("--yes", action="store_true")
|
config_reset.add_argument("--yes", action="store_true")
|
||||||
|
|
||||||
profiles = commands.add_parser(
|
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(
|
profile_commands = profiles.add_subparsers(
|
||||||
dest="profile_command", required=True
|
dest="profile_command", required=True
|
||||||
|
|
@ -2202,6 +2440,21 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
profile_commands.add_parser(
|
profile_commands.add_parser(
|
||||||
"list", help="list profile identities and active profiles"
|
"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(
|
profile_export = profile_commands.add_parser(
|
||||||
"export", help="export a profile as JSON"
|
"export", help="export a profile as JSON"
|
||||||
)
|
)
|
||||||
|
|
@ -2275,6 +2528,18 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
|
||||||
imported_profile: ControllerProfile | None = None
|
imported_profile: ControllerProfile | None = None
|
||||||
try:
|
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":
|
if args.command == "profiles" and args.profile_command == "import":
|
||||||
imported_profile = _load_profile(args.path)
|
imported_profile = _load_profile(args.path)
|
||||||
device = find_pico(args.bus, args.address, args.timeout)
|
device = find_pico(args.bus, args.address, args.timeout)
|
||||||
|
|
|
||||||
396
src/switch_pico_bridge/profile_web.py
Normal file
396
src/switch_pico_bridge/profile_web.py
Normal file
|
|
@ -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()
|
||||||
35
src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt
Normal file
35
src/switch_pico_bridge/web/assets/GAMEPAD_ASSET_LICENSE.txt
Normal file
|
|
@ -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.
|
||||||
2013
src/switch_pico_bridge/web/assets/controller-dualsense.svg
Normal file
2013
src/switch_pico_bridge/web/assets/controller-dualsense.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 205 KiB |
463
src/switch_pico_bridge/web/assets/controller-switch-pro.svg
Normal file
463
src/switch_pico_bridge/web/assets/controller-switch-pro.svg
Normal file
|
|
@ -0,0 +1,463 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="419.12717mm"
|
||||||
|
height="304.54602mm"
|
||||||
|
viewBox="0 0 419.12717 304.54603"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||||
|
sodipodi:docname="Switch Pro Controller VSCView.svg"
|
||||||
|
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Templates\Switch Pro Controller Overlay.png"
|
||||||
|
inkscape:export-xdpi="89.994171"
|
||||||
|
inkscape:export-ydpi="89.994171"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="0.5"
|
||||||
|
inkscape:cx="709"
|
||||||
|
inkscape:cy="694"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="995"
|
||||||
|
inkscape:window-x="-9"
|
||||||
|
inkscape:window-y="-9"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg5" />
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
|
id="path-effect11354"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
|
id="path-effect11187"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
|
id="path-effect41470"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
id="layer2"
|
||||||
|
inkscape:label="Nintendo Switch Pro Controller"
|
||||||
|
style="display:inline"
|
||||||
|
transform="translate(-80.073102,14.620127)">
|
||||||
|
<g
|
||||||
|
id="g10592"
|
||||||
|
inkscape:label="Digital Triggers"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
id="path1458"
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:8.31496;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
inkscape:label="ZR Trigger"
|
||||||
|
d="m 1193.8594,3.9244459 c -30.6291,0.049759 -51.9558,10.8927201 -82.25,24.1087571 l -30.9864,15.619141 v 0 c 60.6006,-2.534721 177.2868,6.012391 206.2911,17.507812 17.5526,6.941643 47.521,19.514293 73.8007,43.001954 C 1310.1862,40.291377 1254.6354,3.8257017 1193.8594,3.9244459 Z"
|
||||||
|
sodipodi:nodetypes="scccccs"
|
||||||
|
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Theme SVG\Theme Assets\Active Presses\Button Color\NSwitchPro_ZR.png"
|
||||||
|
inkscape:export-xdpi="89.874435"
|
||||||
|
inkscape:export-ydpi="89.874435"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.073102,-14.558417)" />
|
||||||
|
<path
|
||||||
|
id="path8519"
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:8.31496;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
inkscape:label="ZL Trigger"
|
||||||
|
d="M 388.70312,3.9244459 C 327.84634,3.8255797 272.22886,40.385003 221.64648,104.41406 247.97833,80.800743 278.04726,68.120999 295.64844,61.160156 324.65272,49.664735 441.33889,41.117623 501.93945,43.652344 v 0 L 470.95312,28.033203 C 440.65896,14.817164 419.33216,3.9742049 388.70312,3.9244459 Z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.073102,-14.558417)"
|
||||||
|
sodipodi:nodetypes="scccccs"
|
||||||
|
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009man Work Projects\VSCView - Controller Theme Layout\VSCView - Controller Theme (Ver. 1.20)\Nintendo Switch Controller Images\Switch Pro Controller\Theme SVG\Theme Assets\Active Presses\Button Color\NSwitchPro_ZL.png"
|
||||||
|
inkscape:export-xdpi="90.144424"
|
||||||
|
inkscape:export-ydpi="90.144424" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g3226"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:label="Controller Body"
|
||||||
|
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\TEMPLA~1\SWITCH~4.PNG"
|
||||||
|
inkscape:export-xdpi="89.994171"
|
||||||
|
inkscape:export-ydpi="89.994171">
|
||||||
|
<g
|
||||||
|
id="g20999"
|
||||||
|
inkscape:label="Shoulders"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#3a3b40;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.8;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 278.583,-2.464337 c -28.76946,0 -85.94915,1.52074839 -111.02858,7.9960404 -25.07936,6.4590456 269.26062,6.4590456 244.18129,0 C 386.63998,-0.94358861 329.46023,-2.464337 300.69077,-2.464337 Z m 0,0"
|
||||||
|
id="path1675-3"
|
||||||
|
inkscape:label="Shoulder Top" />
|
||||||
|
<g
|
||||||
|
id="g3215"
|
||||||
|
inkscape:label="Bumpers"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.2;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 349.00293,5.0036016 20.78687,-9.8062225 c 16.0339,-0.670645 43.10528,3.3847582 50.77933,6.426255 7.69035,3.041349 24.39363,10.1544219 30.7451,27.7326099"
|
||||||
|
id="path5183"
|
||||||
|
sodipodi:nodetypes="cccc"
|
||||||
|
inkscape:label="R Bumper" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.2;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 229.86272,5.0036016 -20.78687,-9.8062225 c -16.0339,-0.670645 -43.10528,3.3847582 -50.77933,6.426255 -7.69035,3.041349 -24.39363,10.1544219 -30.7451,27.7326099"
|
||||||
|
id="path2365"
|
||||||
|
sodipodi:nodetypes="cccc"
|
||||||
|
inkscape:label="L Bumper" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#3a3b40;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:2.8;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 278.583,3.9619915 c -28.76946,0 -85.94915,1.5206749 -111.02858,7.9795715 -25.07936,6.475292 -47.95454,23.203229 -47.95454,23.203229 -1.22458,2.387321 -2.92887,6.475293 -2.92887,6.475293 27.03871,4.692961 105.59767,6.818626 168.0186,6.93317 54.12641,0.118702 146.98623,3.335721 180.52342,0.01486 0,0 -3.30226,-9.876446 -5.131,-13.441106 0,0 -23.26699,-16.711541 -48.34632,-23.186833 C 386.63998,5.4812083 329.46023,3.9604598 300.69077,3.9604598 Z"
|
||||||
|
id="path1685-5"
|
||||||
|
sodipodi:nodetypes="cccccccccc"
|
||||||
|
inkscape:label="Shoulder Middle" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
id="path9077"
|
||||||
|
style="color:#000000;display:inline;fill:#303136;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 1492.3419,363.6276 c 0,0 -120.2829,187.36702 -214.886,314.43124 -47.6216,63.96202 -90.2553,112.22577 -99.0594,118.48022 27.4793,18.41386 45.2809,61.44345 90.4551,155.73242 68.1907,142.33002 119.3493,163.89862 176.1855,159.57232 56.836,-4.3279 127.8652,-70.4537 132.1231,-182.56255 2.9038,-76.49795 -44.0083,-367.63608 -84.8183,-565.65365 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||||
|
inkscape:label="Secondary Color - Right Grip"
|
||||||
|
sodipodi:nodetypes="cscsccc" />
|
||||||
|
<path
|
||||||
|
id="path9067"
|
||||||
|
style="color:#000000;display:inline;fill:#303136;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="M 402.42219,795.85127 C 399.38105,793.06819 336.76422,723.08734 320.52137,703.52495 258.47949,628.80361 167.18978,498.80406 115.64482,412.79566 95.419032,379.04672 87.37911,367.31405 87.37911,367.31405 46.574325,565.32969 0.25264151,852.79352 3.15625,929.28125 c 4.2576,112.10885 75.287003,178.23635 132.12305,182.56255 56.836,4.3277 107.99474,-17.2423 176.18554,-159.57232 45.17551,-94.29154 63.47682,-138.00721 90.95735,-156.42021 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||||
|
inkscape:label="Secondary Color - Left Grip"
|
||||||
|
sodipodi:nodetypes="csscccsc" />
|
||||||
|
<path
|
||||||
|
id="path9073"
|
||||||
|
style="color:#000000;display:inline;opacity:1;fill:#3a3b40;fill-opacity:1;stroke-width:6.04724;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 747.63867,60.839844 c -110.83321,0 -331.01859,5.748832 -427.6582,30.222656 -96.57774,24.41158 -184.70117,87.69531 -184.70117,87.69531 -11.01001,20.95955 -28.77243,92.5631 -47.282585,181.69038 0.873431,0.0997 0.707604,2.43957 1.16188,3.1922 0.715225,1.38029 27.588795,50.1741 66.836665,108.25343 66.51882,98.43512 163.05602,227.8697 177.48473,243.46334 32.4081,35.02463 70.21689,79.2567 70.90251,79.45725 12.98151,-7.07235 30.71466,-10.76753 51.98078,-10.76753 h 667.58982 c 21.3568,0 38.2517,2.93993 51.2679,10.08307 1.7345,-0.43644 25.6031,-26.33704 59.4122,-64.27553 13.2152,-14.82933 41.5543,-49.99609 41.5543,-49.99609 0,0 59.0253,-77.54759 113.8892,-157.48009 54.4807,-79.37419 104.8128,-161.13504 101.9565,-161.74543 -18.5107,-89.12547 -35.9671,-160.91574 -46.9961,-181.875 0,0 -88.0614,-63.28373 -184.7012,-87.69531 C 1163.7579,66.588676 943.50977,60.839844 832.73828,60.839844 Z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||||
|
inkscape:label="Primary Color - Front"
|
||||||
|
sodipodi:nodetypes="sccccsscsscscscccss" />
|
||||||
|
<path
|
||||||
|
id="path44935"
|
||||||
|
style="color:#000000;display:inline;fill:#211e1e;stroke-width:1;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 278.38666,9.1209533 c -29.36903,0 -87.49413,1.4566687 -113.4706,8.0351677 -25.88784,6.543567 -49.30656,23.408391 -49.30655,23.408391 -0.16443,0.117573 -0.29839,0.272371 -0.39274,0.451134 a 0.50005001,0.50005001 0 0 0 -0.002,0 c -1.28805,2.452052 -2.7102,6.774486 -4.34548,12.705167 -1.63548,5.931445 -3.44791,13.429921 -5.35367,22.011576 -3.81155,17.163289 -7.995508,38.658521 -11.851476,60.523441 -3.855972,21.86493 -7.382584,44.0963 -9.876917,62.7326 -2.494304,18.63606 -3.96839,33.60292 -3.677812,41.25743 1.146538,30.19005 20.208895,48.33477 36.160065,49.54891 7.71368,0.58736 15.31861,-0.64567 23.09006,-6.83782 7.76932,-6.19044 15.73229,-17.19509 24.79694,-36.11511 9.02811,-18.84372 13.9548,-29.97353 18.73994,-36.1425 2.39403,-3.08636 4.69,-4.92631 7.52564,-6.06474 2.83367,-1.13765 6.27904,-1.56631 10.89804,-1.56631 h 176.63315 c 4.61903,0 8.06488,0.42866 10.89856,1.56631 2.83565,1.13843 5.1311,2.97839 7.52512,6.06474 4.78521,6.16902 9.71394,17.29881 18.74201,36.1425 9.06463,18.92 17.02604,29.92469 24.79538,36.11511 7.77147,6.1921 15.37586,7.42499 23.08955,6.83782 15.95108,-1.21461 35.01344,-19.35886 36.16006,-49.54891 0.29056,-7.6544 -1.18272,-22.62137 -3.67574,-41.25743 -2.49301,-18.63629 -6.01778,-40.86765 -9.87273,-62.7326 -3.85495,-21.86495 -8.03917,-43.362164 -11.85148,-60.525507 -1.90614,-8.581679 -3.7191,-16.077965 -5.35574,-22.00951 -1.6364,-5.93072 -3.05906,-10.252647 -4.34961,-12.705167 a 0.50005001,0.50005001 0 0 0 -0.002,0 c -0.0944,-0.178761 -0.22833,-0.333558 -0.39275,-0.451134 -0.004,-0.0028 -23.40438,-16.865401 -49.30654,-23.408391 -25.9608,-6.578724 -84.10235,-8.0351677 -113.45509,-8.0351677 z m 0,2.5998407 h 22.51593 c 29.26218,0 87.6711,1.584111 112.8143,7.955071 5.3e-4,1.38e-4 0.001,-1.38e-4 0.002,0 a 0.50005001,0.50005001 0 0 0 0,0.0021 c 24.79081,6.262258 47.36709,22.251704 48.14021,22.802741 1.05212,2.078752 2.46999,6.229985 4.04317,11.931573 1.61812,5.864421 3.42424,13.330539 5.32422,21.884452 3.79997,17.107826 7.97854,38.573109 11.82822,60.408199 3.84968,21.83505 7.36883,44.04288 9.85521,62.62925 2.48634,18.58613 3.92719,33.63042 3.65456,40.81249 -1.10646,29.13294 -19.64105,45.98175 -33.75608,47.05656 -7.32887,0.55788 -14.00144,-0.48695 -21.27105,-6.2792 -7.26742,-5.79049 -15.09723,-16.4695 -24.0745,-35.20716 -9.01368,-18.81362 -13.85919,-29.94323 -19.0314,-36.6112 -2.58745,-3.33571 -5.31452,-5.56064 -8.61291,-6.88485 -3.29649,-1.32345 -7.08838,-1.75183 -11.86543,-1.75183 h -176.6325 c -4.77702,0 -8.56894,0.42838 -11.86543,1.75183 -3.29844,1.32423 -6.02399,3.54923 -8.61136,6.88485 -5.17218,6.66792 -10.01976,17.79755 -19.03346,36.6112 -8.97728,18.73766 -16.80657,29.41662 -24.07399,35.20716 -7.2696,5.7923 -13.94274,6.83726 -21.27156,6.2792 -14.11497,-1.07438 -32.649174,-17.92362 -33.755566,-47.05656 -0.27264,-7.18195 1.16849,-22.22637 3.656108,-40.81249 2.487652,-18.58637 6.008639,-40.79417 9.859345,-62.62925 3.850713,-21.83509 8.031053,-43.300284 11.830283,-60.408199 1.8996,-8.553937 3.70513,-16.019931 5.32216,-21.884452 1.5725,-5.70302 2.99039,-9.854922 4.04109,-11.93364 0.78309,-0.557723 23.367,-16.539757 48.1366,-22.800674 a 0.50005001,0.50005001 0 0 0 0,-5.3e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 0.50005001,0.50005001 0 0 0 0,-5.29e-4 c 5.3e-4,-1.38e-4 10e-4,1.38e-4 0.002,0 25.16012,-6.37119 83.55126,-7.955071 112.8298,-7.955071 z"
|
||||||
|
inkscape:label="Controller Outline" />
|
||||||
|
<path
|
||||||
|
id="path15766"
|
||||||
|
style="color:#000000;display:inline;opacity:1;fill:#211e1e;fill-opacity:1;stroke-width:0.2;stroke-linecap:square;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 103.54984,89.758219 c -0.46441,0.240657 -0.80478,0.65855 -0.56351,1.122682 8.42782,16.264539 56.93552,89.848909 83.91219,114.452959 0.38624,0.35299 0.64705,0.10389 0.99937,-0.28288 0.35301,-0.3863 0.66878,-0.77094 0.28172,-1.12321 -26.41372,-24.09041 -75.45466,-98.34021 -83.58167,-114.024199 -0.1369,-0.265665 -0.95889,-0.191562 -1.0481,-0.145352 z m 371.93828,-0.254497 -0.10335,0.0041 c -0.33848,0.01543 -0.64484,0.210875 -0.80099,0.511598 -8.10964,15.68127 -57.07626,89.81787 -83.48999,113.90829 -0.38727,0.35208 -0.30372,0.78502 0.0491,1.17149 0.35227,0.38708 0.84561,0.58765 1.23193,0.2346 26.97647,-24.60363 75.48404,-98.17394 83.89668,-114.441079 0.23913,-0.46419 0.0557,-1.03981 -0.40823,-1.279509 -0.11581,-0.05996 -0.2459,-0.07335 -0.37518,-0.08217 z"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccccc"
|
||||||
|
inkscape:label="Outline Color Blend" />
|
||||||
|
<g
|
||||||
|
id="g20546"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:label="Joystick Outline">
|
||||||
|
<g
|
||||||
|
id="g20740"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:label="Analog Stick Outline Body - Right">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 377.83935,140.67905 c 0,18.82089 -15.0705,34.07712 -33.66779,34.07712 -18.5973,0 -33.66772,-15.25623 -33.66772,-34.07712 0,-18.82088 15.07042,-34.07703 33.66772,-34.07703 18.59729,0 33.66779,15.25615 33.66779,34.07703 z m 0,0"
|
||||||
|
id="path1711-8"
|
||||||
|
inkscape:label="Outline #2" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#2c2c2c;fill-opacity:1;fill-rule:nonzero;stroke:#211e1e;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 370.998,140.67905 c 0,14.99458 -12.00083,27.16027 -26.82644,27.16027 -14.82554,0 -26.82652,-12.16569 -26.82652,-27.16027 0,-14.99457 12.00098,-27.14386 26.82652,-27.14386 14.82561,0 26.82644,12.14929 26.82644,27.14386"
|
||||||
|
id="path1713-1"
|
||||||
|
inkscape:label="Outline #1" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g20744"
|
||||||
|
style="display:inline"
|
||||||
|
inkscape:label="Analog Stick Outline Body - Left">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 207.91654,82.385032 c 0,18.820888 -15.07045,34.077108 -33.66774,34.077108 -18.59729,0 -33.66783,-15.25622 -33.66783,-34.077108 0,-18.82096 15.07054,-34.077037 33.66783,-34.077037 18.59729,0 33.66774,15.256077 33.66774,34.077037 z m 0,0"
|
||||||
|
id="path1695-1"
|
||||||
|
inkscape:label="Outline #2" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#2c2c2c;fill-opacity:1;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 201.07528,82.385032 c 0,14.99457 -12.01729,27.143938 -26.82648,27.143938 -14.82564,0 -26.82648,-12.149368 -26.82648,-27.143938 0,-14.99457 12.00084,-27.160262 26.82648,-27.160262 14.80919,0 26.82648,12.165692 26.82648,27.160262 z m 0,0"
|
||||||
|
id="path1699-1"
|
||||||
|
inkscape:label="Outline #1" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
id="path14321"
|
||||||
|
style="display:inline;fill:#c7c7c7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.297881"
|
||||||
|
d="m 287.64024,16.987688 v 5.61e-4 h -7.18474 c -4.55541,0 -8.25167,3.691333 -8.25167,8.246746 V 40.91088 c 0,4.555458 3.69626,8.246199 8.25167,8.246199 h 7.18474 c 0.1302,0 0.23893,-0.103795 0.23893,-0.233995 V 17.222229 c 0,-0.1302 -0.10873,-0.234541 -0.23893,-0.234541 z m 3.19589,0 c -0.11456,0 -0.20769,0.09424 -0.20769,0.208785 V 48.92307 c 0,0.1302 0.10381,0.233996 0.23399,0.233996 h 5.26348 c 4.5554,0 8.24674,-3.690743 8.24674,-8.246201 l -5.6e-4,-15.676431 c 0,-4.555413 -3.6908,-8.246746 -8.2462,-8.246746 z m -10.38063,2.74928 h 4.67 v 26.670816 h -4.67 c -1.47336,0 -2.85306,-0.572603 -3.88911,-1.613838 -1.04123,-1.036003 -1.61384,-2.415507 -1.61384,-3.883628 V 25.234434 c 0,-1.46812 0.57261,-2.847867 1.61384,-3.889106 1.03605,-1.036003 2.41575,-1.60836 3.88911,-1.60836 z m -0.14084,3.878693 c -1.67116,0 -3.02437,1.353716 -3.02437,3.02492 0,1.665928 1.35321,3.024922 3.02437,3.024922 1.67121,0 3.02492,-1.358994 3.02492,-3.024922 0,-1.671204 -1.35371,-3.02492 -3.02492,-3.02492 z m 16.90998,8.03851 c 1.67114,0 3.02492,1.35895 3.02492,3.024919 0,1.671206 -1.35378,3.024373 -3.02492,3.024373 -1.67121,0 -3.02492,-1.353167 -3.02492,-3.024373 0,-1.665969 1.35371,-3.024919 3.02492,-3.024919 z m 12.75507,22.209604 c -1.21304,0 -2.19689,0.984148 -2.19689,2.202384 0,1.218235 0.98385,2.207317 2.19689,2.207317 1.21824,0 2.2024,-0.989082 2.2024,-2.207317 0,-1.218236 -0.98416,-2.202384 -2.2024,-2.202384 z m -45.34587,0.135355 v 4.138989 h 0.95791 v -2.519672 l 1.87412,2.519672 h 0.90091 V 53.99913 h -0.95789 v 2.478023 l -1.85331,-2.478023 z m 6.62744,0 v 4.138989 h 1.03625 V 53.99913 Z m 3.93075,0 v 4.138989 h 0.95296 v -2.519672 l 1.87907,2.519672 h 0.89541 V 53.99913 h -0.95788 v 2.478023 l -1.85332,-2.478023 z m 6.46084,0 v 0.994606 h 1.28066 v 3.144383 h 1.00501 v -3.144383 h 1.24943 V 53.99913 Z m 6.34631,0 v 4.138989 h 3.25398 v -0.885007 h -2.2961 V 56.46181 h 2.12402 v -0.869667 h -2.12402 v -0.712937 h 2.2961 V 53.99913 Z m 6.45589,0 v 4.138989 h 0.95242 v -2.519672 l 1.87961,2.519672 h 0.89542 V 53.99913 h -0.95788 v 2.478023 l -1.85332,-2.478023 z m 6.99679,0 v 4.138989 h 1.72891 c 1.17657,0 2.12896,-0.926652 2.12896,-2.07196 0,-1.140206 -0.95239,-2.067029 -2.12896,-2.067029 z m 8.52785,0.895421 c 0.64557,0 1.17162,0.526025 1.17162,1.171608 0,0.650864 -0.52609,1.171061 -1.17162,1.171061 -0.64035,0 -1.16613,-0.520331 -1.16613,-1.171061 0,-0.645537 0.52578,-1.171608 1.16613,-1.171608 z m -7.55956,0.08822 h 0.80226 c 0.5987,0 1.08284,0.484688 1.08284,1.083382 0,0.603927 -0.48414,1.087766 -1.08284,1.087766 h -0.80226 z m -2.38431,4.784525 c -2.30114,0 -4.16475,1.864213 -4.16475,4.165296 0,2.295982 1.86361,4.159815 4.16475,4.159815 1.47859,0 2.78567,-0.775887 3.51975,-1.942089 l -1.4478,-1.259831 c -0.36465,0.739317 -1.13484,1.249423 -2.03031,1.249423 -1.24427,0 -2.25389,-0.98908 -2.25389,-2.207314 0,-1.223426 1.00962,-2.212795 2.25389,-2.212795 0.89547,0 1.66585,0.51015 2.03031,1.249422 l 1.4478,-1.259834 C 302.82151,60.543141 301.51443,59.7673 300.03584,59.7673 Z m -32.22638,0.140836 c -1.61911,0 -3.49289,0.78078 -3.49289,2.40514 0,1.327587 0.95255,1.936495 3.11313,2.311434 1.57748,0.270735 2.24558,0.406117 2.22322,0.921724 -0.0156,0.535922 -0.68738,0.83295 -1.86921,0.83295 -1.67115,0 -2.7438,-0.760066 -2.7438,-0.760066 l -0.84828,1.536022 c 0,0 1.45729,0.87953 3.48249,0.87953 2.76447,0 3.80581,-1.093181 3.80581,-2.540496 0,-1.213046 -0.63007,-1.993651 -3.56634,-2.509259 -0.87986,-0.156193 -1.66074,-0.286757 -1.71796,-0.734309 -0.0312,-0.21333 0.11992,-0.697596 1.6922,-0.697596 1.17659,0 2.37938,0.562239 2.37938,0.562239 l 0.64006,-1.561778 c 0,0 -1.43703,-0.645535 -3.09781,-0.645535 z m 4.25901,0.10905 2.21225,7.809447 h 2.02045 l 1.30148,-4.597113 1.29108,4.597113 h 2.02538 l 2.22813,-7.809447 h -1.93167 l -1.35354,5.09195 -1.4889,-5.09195 h -1.54644 l -1.47848,5.071127 -1.34862,-5.071127 z m 12.67178,0 v 7.809447 h 1.9525 v -7.809447 z m 3.55045,0 v 1.874684 h 2.41062 v 5.934763 h 1.89497 v -5.934763 h 2.35308 v -1.874684 z m 16.7379,0 v 7.814926 h 1.83249 v -3.009573 h 3.24905 v 3.009577 h 1.83741 v -7.814926 h -1.83741 v 2.967929 h -3.24905 v -2.967929 z"
|
||||||
|
inkscape:label="Nintendo Switch Logo" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g10760"
|
||||||
|
inkscape:label="Analog Joysticks"
|
||||||
|
style="display:inline">
|
||||||
|
<g
|
||||||
|
id="g2495"
|
||||||
|
inkscape:label="Right Joystick"
|
||||||
|
style="display:inline;stroke-width:0.901068"
|
||||||
|
transform="matrix(1.1098172,0,0,1.1097717,-37.796036,-15.434424)"
|
||||||
|
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\THEMES~1\THEMEA~1\ACTIVE~1\BUTTON~1\NSWITC~4.PNG"
|
||||||
|
inkscape:export-xdpi="90.207504"
|
||||||
|
inkscape:export-ydpi="90.207504">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.44171;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 366.06709,140.67905 c 0,12.23121 -9.79667,22.15669 -21.89553,22.15669 -12.08246,0 -21.89553,-9.92548 -21.89553,-22.15669 0,-12.23112 9.81307,-22.1566 21.89553,-22.1566 12.09886,0 21.89553,9.92548 21.89553,22.1566"
|
||||||
|
id="path1717-9"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
id="path1719-5"
|
||||||
|
style="color:#000000;fill:#211e1e;stroke-width:3.77953;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 998.16797,457.98242 c -52.54376,0 -95.24609,43.2182 -95.24609,96.33985 0,53.12169 42.70233,96.33203 95.24609,96.33203 52.61003,0 95.24803,-43.21336 95.24803,-96.33203 0,-53.11863 -42.638,-96.33985 -95.24803,-96.33985 z m 0,6.80664 c 48.88923,0 88.43943,40.04719 88.43943,89.53321 0,49.48601 -39.5502,89.5332 -88.43943,89.5332 -48.81792,0 -88.4375,-40.05021 -88.4375,-89.5332 0,-49.483 39.61958,-89.53321 88.4375,-89.53321 z m -0.0312,19.52344 c -38.2086,0 -69.17774,31.42482 -69.17774,70.00977 0,38.64299 30.9725,70.00195 69.17774,70.00195 38.20533,0 69.24223,-31.35568 69.24223,-70.00195 0,-38.58827 -31.0336,-70.00977 -69.24223,-70.00977 z m 0,6.04492 c 34.89833,0 63.19733,28.61127 63.19733,63.96485 0,35.4169 -28.2957,63.95703 -63.19733,63.95703 -34.90163,0 -63.13281,-28.53685 -63.13281,-63.95703 0,-35.35685 28.23449,-63.96485 63.13281,-63.96485 z"
|
||||||
|
transform="matrix(0.23840262,0,0,0.2384124,106.2058,8.5222802)"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g2489"
|
||||||
|
inkscape:label="Left Joystick"
|
||||||
|
style="display:inline;stroke-width:0.901069"
|
||||||
|
transform="matrix(1.1098161,0,0,1.1097704,-19.135317,-9.0434377)"
|
||||||
|
inkscape:export-filename="C:\Users\AL200\OneDrive\Pictures\AL2009~1\VSCVIE~1\VSCVIE~1.20)\NINTEN~1\SWITCH~1\THEMES~1\THEMEA~1\ACTIVE~1\BUTTON~1\NSWITC~3.PNG"
|
||||||
|
inkscape:export-xdpi="87.630684"
|
||||||
|
inkscape:export-ydpi="87.630684">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.44171;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 196.14423,82.385032 c 0,12.231124 -9.81296,22.156678 -21.89551,22.156678 -12.09884,0 -21.89543,-9.925554 -21.89543,-22.156678 0,-12.231198 9.79659,-22.156755 21.89543,-22.156755 12.08255,0 21.89551,9.925557 21.89551,22.156755"
|
||||||
|
id="path1701-7"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
id="path1707-6"
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.44171;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 190.02859,82.385027 c 0,8.814462 -7.05807,15.969873 -15.77265,15.969873 -8.71458,0 -15.78701,-7.155411 -15.78701,-15.969873 0,-8.814527 7.07243,-15.969941 15.78701,-15.969941 8.71458,0 15.77265,7.155414 15.77265,15.969941 z m 0,0 m 6.11564,5e-6 c 0,12.231124 -9.81296,22.156678 -21.89551,22.156678 -12.09884,0 -21.89543,-9.925554 -21.89543,-22.156678 0,-12.231198 9.79659,-22.156755 21.89543,-22.156755 12.08255,0 21.89551,9.925557 21.89551,22.156755 z m 0,0"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1427"
|
||||||
|
inkscape:label="Face Buttons"
|
||||||
|
style="display:inline">
|
||||||
|
<g
|
||||||
|
id="g10427"
|
||||||
|
inkscape:label="X Button">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 414.49511,53.671306 c 0,-8.323055 -6.66169,-15.043682 -14.87451,-15.043682 -8.21289,0 -14.85826,6.720627 -14.85826,15.043682 0,8.306659 6.64537,15.043533 14.85826,15.043533 8.21282,0 14.87451,-6.736874 14.87451,-15.043533"
|
||||||
|
id="path1737-4"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 414.49511,53.671306 c 0,-8.323055 -6.66169,-15.043607 -14.87451,-15.043607 -8.21289,0 -14.85826,6.720552 -14.85826,15.043607 0,8.306659 6.64537,15.043681 14.85826,15.043681 8.21282,0 14.87451,-6.737022 14.87451,-15.043681 z m 0,0"
|
||||||
|
id="path1739-4"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path5694"
|
||||||
|
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||||
|
d="m 395.50194,48.189305 0.23257,0.379307 3.00548,4.923729 -3.45093,5.644618 h 1.79472 l 2.5528,-4.183724 2.5373,4.183724 h 1.79473 l -3.45094,-5.644618 3.23805,-5.303036 h -1.77715 l -2.34199,3.842145 -2.34145,-3.842145 z"
|
||||||
|
inkscape:label="Text" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g10432"
|
||||||
|
inkscape:label="Y Button">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 367.25896,67.68476 c -8.21282,0 -14.87451,6.720553 -14.87451,15.043607 0,8.306734 6.66169,15.043682 14.87451,15.043682 8.21289,0 14.87459,-6.736948 14.87459,-15.043682 0,-8.323054 -6.6617,-15.043607 -14.87459,-15.043607"
|
||||||
|
id="path1733-7"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 367.25904,67.68476 c -8.2129,0 -14.87459,6.720553 -14.87459,15.043607 0,8.306734 6.66169,15.043682 14.87459,15.043682 8.21281,0 14.87451,-6.736948 14.87451,-15.043682 0,-8.323054 -6.6617,-15.043607 -14.87451,-15.043607 z m 0,0"
|
||||||
|
id="path1735-4"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path5704"
|
||||||
|
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||||
|
d="m 363.00989,77.245929 0.23048,0.379306 3.29488,5.402255 v 5.167643 h 1.51154 V 83.02749 l 3.47472,-5.781561 h -1.77562 l -2.47272,4.086055 -2.50372,-4.086055 z"
|
||||||
|
inkscape:label="Text" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g10422"
|
||||||
|
inkscape:label="A Button">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 432.60263,97.772049 c 8.21289,0 14.87458,-6.736948 14.87458,-15.043682 0,-8.30666 -6.66169,-15.060003 -14.87458,-15.060003 -8.21282,0 -14.87459,6.753343 -14.87459,15.060003 0,8.306734 6.66177,15.043682 14.87459,15.043682"
|
||||||
|
id="path1725-4"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 432.60263,97.772049 c 8.21281,0 14.87451,-6.736948 14.87451,-15.043682 0,-8.30666 -6.6617,-15.060003 -14.87451,-15.060003 -8.2129,0 -14.87467,6.753343 -14.87467,15.060003 0,8.306734 6.66177,15.043682 14.87467,15.043682 z m 0,0"
|
||||||
|
id="path1727-2"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path5684"
|
||||||
|
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||||
|
d="m 432.3457,76.134888 -5.11546,10.947135 h 1.71101 l 1.59991,-3.433384 h 4.14031 l 1.61541,3.433384 h 1.67587 l -5.04672,-10.947135 h -0.1602 z m 0.28731,3.064412 1.35911,2.935221 h -2.74196 z"
|
||||||
|
inkscape:label="Text" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g10417"
|
||||||
|
inkscape:label="B Button"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 384.76234,111.19683 c 0,8.30666 6.64537,15.04361 14.85826,15.04361 8.21282,0 14.87451,-6.73695 14.87451,-15.04361 0,-8.32305 -6.66169,-15.059925 -14.87451,-15.059925 -8.21289,0 -14.85826,6.736875 -14.85826,15.059925"
|
||||||
|
id="path1729-5"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 384.76234,111.19683 c 0,8.30666 6.64544,15.04361 14.85826,15.04361 8.21282,0 14.87451,-6.73695 14.87451,-15.04361 0,-8.32305 -6.66169,-15.060074 -14.87451,-15.060074 -8.21282,0 -14.85826,6.737024 -14.85826,15.060074 z m 0,0"
|
||||||
|
id="path1731-8"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path5714"
|
||||||
|
style="color:#000000;display:inline;fill:#ffffff;stroke-width:0.499999;stroke-miterlimit:10;-inkscape-stroke:none"
|
||||||
|
d="m 399.47225,105.71448 -2.29081,5.3e-4 v 0.24959 10.69961 h 3.09181 c 1.07363,0 1.95181,-0.30075 2.62154,-0.93586 h 0.003 v -0.002 c 0.64646,-0.62979 0.97462,-1.41132 0.97462,-2.30425 0,-0.44095 -0.0871,-0.86836 -0.26718,-1.24799 l -0.003,-0.002 c -0.17555,-0.38581 -0.41021,-0.71541 -0.72864,-0.99839 l -0.004,-0.002 -0.003,-0.002 c -0.24373,-0.20363 -0.61497,-0.35596 -0.98599,-0.51004 0.29816,-0.22146 0.60555,-0.43885 0.77721,-0.74207 0.25247,-0.41527 0.3788,-0.88916 0.3788,-1.4025 0,-0.53531 -0.14348,-1.04004 -0.41031,-1.48622 l -0.003,-0.004 -0.004,-0.006 c -0.28318,-0.42562 -0.66442,-0.77237 -1.15033,-0.98857 h -0.003 c -0.49593,-0.22406 -1.16128,-0.31626 -1.99625,-0.31626 z m -0.77668,1.52962 h 0.94101 c 0.7048,0 1.16608,0.13228 1.42214,0.34985 l 0.006,0.006 0.006,0.004 c 0.28122,0.21413 0.40618,0.49457 0.40618,0.91209 0,0.31298 -0.0794,0.58368 -0.24804,0.83768 -0.18725,0.25995 -0.40392,0.43401 -0.70126,0.54725 -0.27373,0.10158 -0.79496,0.17932 -1.47846,0.17932 h -0.35346 z m 0.33742,4.39456 c 0.85844,0 1.47833,0.0404 1.76993,0.11111 0.4972,0.12048 0.85116,0.33801 1.10897,0.63872 l 0.003,0.004 0.003,0.002 c 0.27053,0.2999 0.39274,0.6386 0.39274,1.04283 0,0.32553 -0.096,0.61375 -0.28318,0.88884 v 0.002 l -0.003,0.002 c -0.18621,0.28793 -0.41764,0.47979 -0.73432,0.60927 h -0.003 -0.003 c -0.28475,0.12139 -0.8182,0.20877 -1.55083,0.20877 h -1.0392 v -3.5078 z"
|
||||||
|
inkscape:label="Text" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1458"
|
||||||
|
inkscape:label="Home"
|
||||||
|
style="display:inline">
|
||||||
|
<g
|
||||||
|
id="g3754"
|
||||||
|
inkscape:label="LED"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
id="path1749-3"
|
||||||
|
style="display:none;mix-blend-mode:lighten;fill:#b0b0b0;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.04724;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
inkscape:label="OFF"
|
||||||
|
d="m 902.57812,287.30469 c -25.42484,0 -46.0996,20.88841 -46.0996,46.66015 0,25.77147 20.67476,46.59961 46.0996,46.59961 25.48661,0 46.09766,-20.82814 46.09766,-46.59961 0,-25.77174 -20.61105,-46.66015 -46.09766,-46.66015 z m 0,12.66992 c 18.5751,0 33.57032,15.20215 33.57032,33.99023 0,18.72584 -14.99522,33.99219 -33.57032,33.99219 -18.51337,0 -33.57226,-15.26635 -33.57226,-33.99219 0,-18.78808 15.05889,-33.99023 33.57226,-33.99023 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.072992,-5.9766386)" />
|
||||||
|
<path
|
||||||
|
id="path4088"
|
||||||
|
style="display:inline;mix-blend-mode:lighten;fill:#2a4fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:6.04724;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
inkscape:label="ON"
|
||||||
|
d="m 902.57812,287.30469 c -25.42484,0 -46.0996,20.88841 -46.0996,46.66015 0,25.77147 20.67476,46.59961 46.0996,46.59961 25.48661,0 46.09766,-20.82814 46.09766,-46.59961 0,-25.77174 -20.61105,-46.66015 -46.09766,-46.66015 z m 0,12.66992 c 18.5751,0 33.57032,15.20215 33.57032,33.99023 0,18.72584 -14.99522,33.99219 -33.57032,33.99219 -18.51337,0 -33.57226,-15.26635 -33.57226,-33.99219 0,-18.78808 15.05889,-33.99023 33.57226,-33.99023 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.072992,-5.9766386)" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 318.87991,73.39148 c 4.91466,0 8.88234,4.022539 8.88234,8.993552 0,4.954544 -3.96768,8.993478 -8.88234,8.993478 -4.89833,0 -8.88233,-4.038934 -8.88233,-8.993478 0,-4.971013 3.984,-8.993552 8.88233,-8.993552"
|
||||||
|
id="path4084"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
id="path1751-4"
|
||||||
|
style="color:#000000;display:inline;fill:#211e1e;stroke-width:3.77953;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 900.68555,283.15039 c -27.08126,0 -49.125,22.29152 -49.125,49.67969 0,27.38843 22.04865,49.62109 49.125,49.62109 27.13383,0 49.11914,-22.23751 49.11914,-49.62109 0,-27.38333 -21.98043,-49.67969 -49.11914,-49.67969 z m 0,6.04492 c 23.83442,0 43.07422,19.47576 43.07422,43.63477 0,24.1593 -19.23489,43.57617 -43.07422,43.57617 -23.77391,0 -43.08008,-19.42172 -43.08008,-43.57617 0,-24.15416 19.31108,-43.63477 43.08008,-43.63477 z m 0,6.6211 c -20.1687,0 -36.59766,16.61035 -36.59766,37.01367 0,20.34695 16.42224,37.01367 36.59766,37.01367 20.23162,0 36.59375,-16.67339 36.59375,-37.01367 0,-20.39667 -16.36882,-37.01367 -36.59375,-37.01367 z m 0,6.04687 c 16.92521,0 30.54687,13.7879 30.54687,30.9668 0,17.11191 -13.62835,30.96679 -30.54687,30.9668 -16.85129,0 -30.55274,-13.86156 -30.55274,-30.9668 0,-17.17225 13.69472,-30.9668 30.55274,-30.9668 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.297882"
|
||||||
|
d="m 320.56433,84.51897 c 0,0.245744 -0.0984,0.344162 -0.33691,0.344162 h -2.69787 c -0.23909,0 -0.33751,-0.09842 -0.33751,-0.344162 v -2.016361 c 0,-0.239102 0.0984,-0.336311 0.33751,-0.336311 h 2.69787 c 0.23185,0 0.32302,0.09117 0.33691,0.323631 z m 3.56907,-3.280941 c -1.67914,-1.426277 -3.35829,-2.852432 -5.03743,-4.278708 -0.15457,-0.140683 -0.28076,-0.140683 -0.43533,0 -1.67214,1.426276 -3.35829,2.852431 -5.03744,4.278708 -0.29525,0.252988 -0.1757,0.589904 0.22462,0.589904 h 0.65336 c 0.23186,0 0.33027,0.09842 0.33027,0.337519 v 4.376944 c 0,0.245743 0.0984,0.343557 0.34416,0.343557 h 7.41215 c 0.2391,0 0.33751,-0.09842 0.33751,-0.343557 v -4.376944 c 0,-0.239101 0.0984,-0.337519 0.33028,-0.337519 h 0.65336 c 0.40032,0 0.52711,-0.336916 0.22462,-0.589904"
|
||||||
|
id="path14283"
|
||||||
|
inkscape:label="Icon" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1451"
|
||||||
|
inkscape:label="Capture"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
|
||||||
|
d="m 255.83852,90.838877 c -2.02465,0 -3.67381,-1.667934 -3.67381,-3.711846 v -8.813649 c 0,-2.043912 1.64916,-3.695451 3.67381,-3.695451 h 8.57199 c 2.02473,0 3.65741,1.651539 3.65741,3.695451 v 8.813649 c 0,2.043912 -1.63268,3.711846 -3.65741,3.711846 z m 0,0"
|
||||||
|
id="path1759-9"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#211e1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
|
||||||
|
d="m 264.41051,73.751285 h -8.58839 c -2.46546,0 -4.50643,2.06038 -4.50643,4.562097 v 8.813649 c 0,2.518112 2.04097,4.562172 4.50643,4.562172 h 8.58839 c 2.48186,0 4.50651,-2.04406 4.50651,-4.562172 v -8.813649 c 0,-2.501717 -2.02465,-4.562097 -4.50651,-4.562097 m 0,1.716971 c 1.5512,0 2.80845,1.27534 2.80845,2.845126 v 8.813649 c 0,1.569787 -1.25725,2.861595 -2.80845,2.861595 h -8.58839 c -1.55112,0 -2.8083,-1.291808 -2.8083,-2.861595 v -8.813649 c 0,-1.569786 1.25718,-2.845126 2.8083,-2.845126 h 8.58839"
|
||||||
|
id="path1761-8"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#545759;fill-opacity:1;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 265.42287,82.728367 c 0,2.959595 -2.3675,5.363385 -5.30656,5.363385 -2.92265,0 -5.30641,-2.40379 -5.30641,-5.363385 0,-2.97599 2.38376,-5.379707 5.30641,-5.379707 2.93906,0 5.30656,2.403717 5.30656,5.379707 z m 0,0"
|
||||||
|
id="path1765-8"
|
||||||
|
inkscape:label="Symbol" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1445"
|
||||||
|
inkscape:label="Minus"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 247.75631,51.005934 c 0,5.281706 -4.21264,9.549506 -9.43749,9.549506 -5.19221,0 -9.4211,-4.2678 -9.4211,-9.549506 0,-5.281557 4.22889,-9.549357 9.4211,-9.549357 5.22485,0 9.43749,4.2678 9.43749,9.549357"
|
||||||
|
id="path1767-5"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 247.75624,51.005934 c 0,5.281706 -4.21257,9.549506 -9.43742,9.549506 -5.19221,0 -9.4211,-4.2678 -9.4211,-9.549506 0,-5.281557 4.22889,-9.549357 9.4211,-9.549357 5.22485,0 9.43742,4.2678 9.43742,9.549357 z m 0,0"
|
||||||
|
id="path1769-6"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path6584"
|
||||||
|
style="color:#000000;display:inline;fill:#000000;fill-opacity:1;stroke-width:0.499999;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 233.05657,50.134924 v 0.249598 1.492414 h 10.55646 v -1.742012 z"
|
||||||
|
inkscape:label="Symbol" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1440"
|
||||||
|
inkscape:label="Plus"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="m 350.06585,51.005934 c 0,5.085409 -4.08195,9.222343 -9.11089,9.222343 -5.02887,0 -9.11083,-4.136934 -9.11083,-9.222343 0,-5.085408 4.08196,-9.222343 9.11083,-9.222343 5.02894,0 9.11089,4.136935 9.11089,9.222343"
|
||||||
|
id="path1773-8"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:none;stroke:#211e1e;stroke-width:1.6;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:9.53846;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
d="m 350.06585,51.005934 c 0,5.085409 -4.08195,9.222492 -9.11082,9.222492 -5.02894,0 -9.1109,-4.137083 -9.1109,-9.222492 0,-5.085408 4.08196,-9.222343 9.1109,-9.222343 5.02887,0 9.11082,4.136935 9.11082,9.222343 z m 0,0"
|
||||||
|
id="path1775-8"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path6498"
|
||||||
|
style="color:#000000;display:inline;fill:#000000;fill-opacity:1;stroke-width:0.499999;stroke-miterlimit:9.53846;-inkscape-stroke:none"
|
||||||
|
d="m 340.03506,45.866968 v 4.218863 h -4.1951 v 1.840198 h 4.1951 v 4.202843 h 1.82211 v -4.202843 h 4.19766 v -1.840198 h -4.19766 v -4.218863 z"
|
||||||
|
inkscape:label="Symbol" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1435"
|
||||||
|
inkscape:label="D-PAD"
|
||||||
|
style="display:inline">
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#686b6e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.564261"
|
||||||
|
d="m 215.9498,171.45317 c -0.47339,0 -0.84908,-0.37613 -0.84908,-0.85085 v -19.19701 h -18.95656 c -0.47338,0 -0.849,-0.37613 -0.849,-0.85077 v -19.73672 c 0,-0.47482 0.37557,-0.86621 0.849,-0.86621 h 18.95656 V 110.771 c 0,-0.47482 0.37558,-0.86628 0.84908,-0.86628 h 19.49534 c 0.47345,0 0.8491,0.39322 0.8491,0.86628 v 19.18061 h 18.97279 c 0.45713,0 0.84903,0.39248 0.84903,0.86621 v 19.73672 c 0,0.47482 -0.39182,0.85077 -0.84903,0.85077 h -18.97279 v 19.19701 c 0,0.47482 -0.3755,0.85085 -0.8491,0.85085 z m 0,0"
|
||||||
|
id="path1779-7"
|
||||||
|
inkscape:label="Color" />
|
||||||
|
<path
|
||||||
|
style="display:inline;fill:#211e1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.6;stroke-miterlimit:9.53846;stroke-dasharray:none"
|
||||||
|
d="M 235.44514,109.05477 H 215.9498 c -0.93071,0 -1.69808,0.76895 -1.69808,1.7169 v 18.33036 h -18.10749 c -0.94692,0 -1.69807,0.76887 -1.69807,1.7169 v 19.73665 c 0,0.9481 0.75107,1.71689 1.69807,1.71689 h 18.10749 v 18.33037 c 0,0.9481 0.76745,1.70058 1.69808,1.70058 h 19.49534 c 0.94698,0 1.69805,-0.75226 1.69805,-1.70058 v -18.33037 h 18.12384 c 0.93066,0 1.68173,-0.76887 1.68173,-1.71689 v -19.73665 c 0,-0.9481 -0.75107,-1.7169 -1.68173,-1.7169 h -18.12384 v -18.33036 c 0,-0.9481 -0.75107,-1.7169 -1.69805,-1.7169 m 0,1.7169 v 20.04726 h 19.82189 v 19.73665 h -19.82189 v 20.04726 H 215.9498 v -20.04726 h -19.80557 v -19.73665 h 19.80557 v -20.04726 h 19.49534"
|
||||||
|
id="path1781-2"
|
||||||
|
inkscape:label="Outline" />
|
||||||
|
<path
|
||||||
|
id="path1783-2"
|
||||||
|
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2.13264"
|
||||||
|
d="m 548.21875,463.86523 c -0.77142,0 -1.54272,0.51065 -2.12891,1.53125 l -3.26953,5.68555 c -1.17267,2.03928 -3.0854,5.43727 -4.25781,7.47852 l -3.27148,5.74804 c -1.17238,2.04084 -0.18743,3.70899 2.16015,3.70899 h 21.53711 c 2.3562,0 3.33257,-1.66774 2.16016,-3.70899 l -3.27149,-5.74804 c -1.1721,-2.03955 -3.08544,-5.43727 -4.25781,-7.47852 l -3.27148,-5.68555 c -0.58606,-1.0206 -1.35749,-1.53125 -2.12891,-1.53125 z m -67.85352,77.11524 c -0.48532,0.006 -1.02874,0.16564 -1.60156,0.49609 l -5.61523,3.27539 c -2.03543,1.23364 -5.36999,3.15108 -7.40625,4.32617 l -5.61524,3.33594 c -2.03539,1.17468 -2.03539,3.08881 0,4.26367 l 5.61524,3.33789 c 2.03539,1.17495 5.36999,3.09052 7.40625,4.32813 l 5.61523,3.27344 c 2.03539,1.17494 3.70313,0.16819 3.70313,-2.16211 v -21.81641 c 0,-1.68879 -0.86129,-2.67406 -2.10157,-2.6582 z m 135.65821,0.002 c -1.22227,-0.0113 -2.05078,0.97942 -2.05078,2.6543 v 21.8164 c 0,2.34966 1.60436,3.33507 3.64062,2.16016 l 5.67774,-3.27344 c 2.03625,-1.23647 5.3689,-3.15299 7.34375,-4.32812 l 5.67773,-3.33399 c 2.03626,-1.17498 2.03626,-3.09053 0,-4.26562 l -5.67773,-3.33594 c -1.97458,-1.17468 -5.3075,-3.09048 -7.34375,-4.32812 l -5.67774,-3.27344 c -0.5727,-0.33046 -1.11156,-0.48775 -1.58984,-0.49219 z m -78.57227,80.0918 c -2.35619,0 -3.33256,1.66774 -2.16015,3.70898 l 3.27148,5.74805 c 1.17237,2.03928 3.08544,5.37551 4.25781,7.47852 l 3.26953,5.68554 c 1.17241,2.04125 3.08544,2.04125 4.25782,0 l 3.27148,-5.68554 c 1.17241,-2.10123 3.08544,-5.43727 4.25781,-7.47852 l 3.27149,-5.74805 c 1.17267,-2.04079 0.18742,-3.70898 -2.16016,-3.70898 z"
|
||||||
|
transform="matrix(0.26458333,0,0,0.26458333,80.574244,-5.6765164)"
|
||||||
|
inkscape:label="Symbols" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 44 KiB |
1383
src/switch_pico_bridge/web/assets/controller-xbox.svg
Normal file
1383
src/switch_pico_bridge/web/assets/controller-xbox.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 101 KiB |
421
src/switch_pico_bridge/web/profile_editor.css
Normal file
421
src/switch_pico_bridge/web/profile_editor.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
195
src/switch_pico_bridge/web/profile_editor.html
Normal file
195
src/switch_pico_bridge/web/profile_editor.html
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Switch Pico Profile Studio</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient ambient-one"></div>
|
||||||
|
<div class="ambient ambient-two"></div>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark" aria-hidden="true"><span></span><span></span></div>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Switch Pico</p>
|
||||||
|
<h1>Profile Studio</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="connection" id="connectionState" data-state="loading">
|
||||||
|
<span class="connection-dot"></span>
|
||||||
|
<span id="connectionText">Connecting to adapter…</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Controller</p>
|
||||||
|
<h2>Profile library</h2>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" id="refreshButton" type="button" title="Refresh controllers" aria-label="Refresh controllers">↻</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field-label" for="identitySelect">Profile owner</label>
|
||||||
|
<select id="identitySelect" class="select"></select>
|
||||||
|
<p class="field-help">Choose the global fallback or a paired controller.</p>
|
||||||
|
|
||||||
|
<div class="profile-list" id="profileList" aria-label="Profiles"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-note">
|
||||||
|
<span class="note-icon">i</span>
|
||||||
|
<p>Every controller has four slots. Start from defaults, tune the draft, then save it atomically to the Pico.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="editor-shell">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" id="profileOwner">Global fallback</p>
|
||||||
|
<div class="title-line">
|
||||||
|
<h2 id="profileTitle">Profile 1</h2>
|
||||||
|
<span class="active-badge" id="activeBadge" hidden>Active</span>
|
||||||
|
<span class="dirty-badge" id="dirtyBadge" hidden>Unsaved</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="button button-ghost" id="resetDraftButton" type="button">Start from defaults</button>
|
||||||
|
<button class="button button-secondary" id="activateButton" type="button">Make active</button>
|
||||||
|
<button class="button button-primary" id="saveButton" type="button">Save to Pico</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="section-nav" aria-label="Editor sections">
|
||||||
|
<a href="#mapping">Mapping</a>
|
||||||
|
<a href="#analog">Analog</a>
|
||||||
|
<a href="#feedback">Feedback</a>
|
||||||
|
<a href="#turbo">Turbo</a>
|
||||||
|
<a href="#macro">Macro</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="loading-card" id="loadingCard">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Reading profile from the adapter…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="profileForm" hidden>
|
||||||
|
<section class="panel" id="mapping">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">01 · Controls</p>
|
||||||
|
<h3>Button mapping</h3>
|
||||||
|
</div>
|
||||||
|
<p>Choose what each physical button should produce.</p>
|
||||||
|
</div>
|
||||||
|
<div class="controller-mapper">
|
||||||
|
<div class="controller-canvas" id="controllerCanvas" data-style="generic">
|
||||||
|
<div class="controller-caption">
|
||||||
|
<span class="controller-live"><i></i> Connected layout</span>
|
||||||
|
<strong id="controllerModel">Generic controller</strong>
|
||||||
|
</div>
|
||||||
|
<div class="controller-photo-wrap">
|
||||||
|
<img
|
||||||
|
class="controller-photo"
|
||||||
|
id="controllerImage"
|
||||||
|
src="/assets/controller-xbox.svg"
|
||||||
|
alt="Connected controller"
|
||||||
|
>
|
||||||
|
<div class="controller-hotspots" id="controllerHotspots">
|
||||||
|
<button type="button" data-controller-button="left_shoulder" aria-label="Left Shoulder">L</button>
|
||||||
|
<button type="button" data-controller-button="right_shoulder" aria-label="Right Shoulder">R</button>
|
||||||
|
<button type="button" data-controller-button="left_trigger" aria-label="Left Trigger">LT</button>
|
||||||
|
<button type="button" data-controller-button="right_trigger" aria-label="Right Trigger">RT</button>
|
||||||
|
<button type="button" data-controller-button="dpad_up" aria-label="D-pad Up">▲</button>
|
||||||
|
<button type="button" data-controller-button="dpad_right" aria-label="D-pad Right">▶</button>
|
||||||
|
<button type="button" data-controller-button="dpad_down" aria-label="D-pad Down">▼</button>
|
||||||
|
<button type="button" data-controller-button="dpad_left" aria-label="D-pad Left">◀</button>
|
||||||
|
<button type="button" data-controller-button="north" aria-label="North button">N</button>
|
||||||
|
<button type="button" data-controller-button="west" aria-label="West button">W</button>
|
||||||
|
<button type="button" data-controller-button="east" aria-label="East button">E</button>
|
||||||
|
<button type="button" data-controller-button="south" aria-label="South button">S</button>
|
||||||
|
<button type="button" data-controller-button="left_stick" aria-label="Left Stick">L3</button>
|
||||||
|
<button type="button" data-controller-button="right_stick" aria-label="Right Stick">R3</button>
|
||||||
|
<button type="button" data-controller-button="select" aria-label="Select">−</button>
|
||||||
|
<button type="button" data-controller-button="start" aria-label="Start">+</button>
|
||||||
|
<button type="button" data-controller-button="capture" aria-label="Capture">▣</button>
|
||||||
|
<button type="button" data-controller-button="system" aria-label="System">⌂</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="controller-hint">Select a highlighted control on the controller to remap it.</p>
|
||||||
|
<p class="controller-credit">
|
||||||
|
Image:
|
||||||
|
<a id="controllerCredit" href="https://github.com/AL2009man/Gamepad-Asset-Pack" target="_blank" rel="noreferrer">Controller artwork by Al. Lopez · MIT</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="mapping-inspector">
|
||||||
|
<p class="eyebrow">Selected control</p>
|
||||||
|
<div class="selected-control-mark" id="selectedControlGlyph">S</div>
|
||||||
|
<h4 id="selectedControlName">South button</h4>
|
||||||
|
<p id="selectedControlDescription">Physical South currently produces South.</p>
|
||||||
|
<label class="field-label" for="selectedMapping">Output action</label>
|
||||||
|
<select class="select" id="selectedMapping"></select>
|
||||||
|
<div class="mapping-key">
|
||||||
|
<span><i class="key-dot mapped"></i>Mapped</span>
|
||||||
|
<span><i class="key-dot disabled"></i>Disabled</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="analog">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">02 · Response</p>
|
||||||
|
<h3>Sticks & triggers</h3>
|
||||||
|
</div>
|
||||||
|
<p>Values use the controller's normalized integer range.</p>
|
||||||
|
</div>
|
||||||
|
<div class="analog-grid" id="analogFields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="feedback">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">03 · Identity</p>
|
||||||
|
<h3>Feedback</h3>
|
||||||
|
</div>
|
||||||
|
<p>Scale rumble and choose profile-change confirmation.</p>
|
||||||
|
</div>
|
||||||
|
<div class="subpanel" id="rumbleFields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="turbo">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">04 · Repeat</p>
|
||||||
|
<h3>Turbo behavior</h3>
|
||||||
|
</div>
|
||||||
|
<p>Off, hold-to-repeat, or press-once Auto Burst per button.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-grid compact" id="turboFields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="macro">
|
||||||
|
<div class="panel-heading macro-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">05 · Actions</p>
|
||||||
|
<h3>Bindings & custom macro</h3>
|
||||||
|
</div>
|
||||||
|
<button class="button button-secondary" id="addMacroStepButton" type="button">Add state step</button>
|
||||||
|
</div>
|
||||||
|
<div class="builtin-actions" id="builtinActions"></div>
|
||||||
|
<div class="macro-controls" id="macroControls"></div>
|
||||||
|
<div class="macro-steps" id="macroSteps"></div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
<script src="/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
746
src/switch_pico_bridge/web/profile_editor.js
Normal file
746
src/switch_pico_bridge/web/profile_editor.js
Normal file
|
|
@ -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) => `<option value="${entry.index}">${escapeHtml(entry.label)}</option>`)
|
||||||
|
.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 `
|
||||||
|
<button class="profile-button${selected ? " selected" : ""}" type="button" data-profile-index="${index}">
|
||||||
|
<span class="profile-number"><span>${index + 1}</span>Profile ${index + 1}</span>
|
||||||
|
${active ? '<span class="mini-active">Active</span>' : ""}
|
||||||
|
</button>`;
|
||||||
|
}).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
|
||||||
|
? `<option value=""${selected === null ? " selected" : ""}>None</option>`
|
||||||
|
: "";
|
||||||
|
return none + choices.map((button) => (
|
||||||
|
`<option value="${button}"${selected === button ? " selected" : ""}>${label(button)}</option>`
|
||||||
|
)).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" ? `
|
||||||
|
<div class="toggle-row">
|
||||||
|
${["invert_x", "invert_y"].map((field) => `
|
||||||
|
<label class="checkbox-pill">
|
||||||
|
<input type="checkbox" data-kind="analog-bool" data-group="${group}" data-side="${side}" data-field="${field}"${config[field] ? " checked" : ""}>
|
||||||
|
<span>${label(field)}</span>
|
||||||
|
</label>`).join("")}
|
||||||
|
</div>` : "";
|
||||||
|
return `
|
||||||
|
<div class="subpanel">
|
||||||
|
<div class="subpanel-heading"><h4>${title}</h4><span>${group === "sticks" ? "Signed axes · 32767 full scale" : "Unsigned · 65535 full scale"}</span></div>
|
||||||
|
<div class="number-grid">
|
||||||
|
${fields.map(([field, fieldLabel, min, max]) => `
|
||||||
|
<div class="number-field${field === "digital_threshold" ? " wide" : ""}">
|
||||||
|
<label for="${group}-${side}-${field}">${fieldLabel}</label>
|
||||||
|
<input class="number-input" id="${group}-${side}-${field}" type="number" required min="${min}" max="${max}" step="1" value="${config[field]}" data-kind="analog-number" data-group="${group}" data-side="${side}" data-field="${field}">
|
||||||
|
</div>`).join("")}
|
||||||
|
${toggles}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeedback() {
|
||||||
|
const rumble = state.profile.rumble;
|
||||||
|
elements.rumble.innerHTML = `
|
||||||
|
<div class="subpanel-heading"><h4>Rumble & confirmation</h4><span>0 is silent · 255 is full strength</span></div>
|
||||||
|
${["weak_scale", "strong_scale"].map((field) => `
|
||||||
|
<div class="range-row">
|
||||||
|
<div class="range-meta"><label for="rumble-${field}">${label(field)}</label><output id="rumble-${field}-value">${rumble[field]}</output></div>
|
||||||
|
<input id="rumble-${field}" type="range" min="0" max="255" step="1" value="${rumble[field]}" data-kind="rumble-range" data-field="${field}">
|
||||||
|
</div>`).join("")}
|
||||||
|
<div class="number-field">
|
||||||
|
<label for="confirmation-policy">Profile-change confirmation</label>
|
||||||
|
<select class="select" id="confirmation-policy" data-kind="rumble-policy">
|
||||||
|
${state.schema.rumble_policies.map((policy) => `<option value="${policy}"${rumble.confirmation_policy === policy ? " selected" : ""}>${label(policy)}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTurbo() {
|
||||||
|
elements.turbo.innerHTML = state.schema.buttons.map((button) => `
|
||||||
|
<div class="control-card">
|
||||||
|
<label for="turbo-${button}">${label(button)}</label>
|
||||||
|
<select class="select" id="turbo-${button}" data-kind="turbo" data-name="${button}">
|
||||||
|
${state.schema.turbo_modes.map((mode) => `<option value="${mode}"${state.profile.turbo[button] === mode ? " selected" : ""}>${label(mode)}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function macroNumber(index, field, value, min, max, title, disabled = false) {
|
||||||
|
return `
|
||||||
|
<div class="number-field">
|
||||||
|
<label>${title}</label>
|
||||||
|
<input class="number-input" type="number" required min="${min}" max="${max}" step="1" value="${value}" data-kind="macro-number" data-index="${index}" data-field="${field}"${disabled ? " disabled" : ""}>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="action-card">
|
||||||
|
<div class="action-card-heading">
|
||||||
|
<div><span class="action-kind">${action === "custom_macro" ? "Custom macro" : "Built-in action"}</span><h4>${title}</h4></div>
|
||||||
|
<span>${description}</span>
|
||||||
|
</div>
|
||||||
|
<div class="check-grid controller-choices">
|
||||||
|
${state.schema.controls.map((button) => `
|
||||||
|
<label class="checkbox-pill controller-choice">
|
||||||
|
<input type="checkbox" data-kind="action-chord" data-action="${action}" data-name="${button}"${selected.has(button) ? " checked" : ""}>
|
||||||
|
<span data-button="${button}">
|
||||||
|
<b>${controlGlyph(button, state.identities[state.identityIndex]?.controller?.style || "generic")}</b>
|
||||||
|
<small>${label(button)}</small>
|
||||||
|
</span>
|
||||||
|
</label>`).join("")}
|
||||||
|
</div>
|
||||||
|
<p class="field-help">${defaultText}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="control-card">
|
||||||
|
<label for="macro-cancel">Custom macro cancel control</label>
|
||||||
|
<select class="select" id="macro-cancel" data-kind="macro-selector" data-field="cancel">
|
||||||
|
${buttonOptions(macro.cancel, true, state.schema.controls)}
|
||||||
|
</select>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
elements.macroSteps.innerHTML = macro.steps.map((step, index) => {
|
||||||
|
if (step.type === "end") {
|
||||||
|
return `<div class="macro-step end"><div class="step-header"><span class="step-number">End marker · step ${index + 1}</span></div><p class="field-help">The firmware requires this canonical final step.</p></div>`;
|
||||||
|
}
|
||||||
|
const overrides = new Set(step.overrides);
|
||||||
|
const outputButtons = new Set(step.output_buttons);
|
||||||
|
return `
|
||||||
|
<div class="macro-step">
|
||||||
|
<div class="step-header">
|
||||||
|
<span class="step-number">State step ${index + 1}</span>
|
||||||
|
<button class="remove-step" type="button" data-remove-step="${index}">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div class="step-grid">
|
||||||
|
${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"))}
|
||||||
|
</div>
|
||||||
|
<div class="step-group">
|
||||||
|
<span class="step-group-title">Fields this step overrides</span>
|
||||||
|
<div class="check-grid">
|
||||||
|
${state.schema.macro_overrides.map((name) => `
|
||||||
|
<label class="checkbox-pill">
|
||||||
|
<input type="checkbox" data-kind="macro-override" data-index="${index}" data-name="${name}"${overrides.has(name) ? " checked" : ""}>
|
||||||
|
<span>${label(name)}</span>
|
||||||
|
</label>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-group">
|
||||||
|
<span class="step-group-title">Output buttons</span>
|
||||||
|
<div class="check-grid">
|
||||||
|
${state.schema.buttons.map((button) => `
|
||||||
|
<label class="checkbox-pill">
|
||||||
|
<input type="checkbox" data-kind="macro-output" data-index="${index}" data-name="${button}"${outputButtons.has(button) ? " checked" : ""}${overrides.has("buttons") ? "" : " disabled"}>
|
||||||
|
<span>${label(button)}</span>
|
||||||
|
</label>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).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();
|
||||||
|
|
@ -1970,13 +1970,22 @@ void test_stateful_host_rumble_restore() {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_motion_hotkey() {
|
void test_motion_toggle_action() {
|
||||||
start_pairing_backend();
|
start_pairing_backend();
|
||||||
uni_hid_device_t slot_zero = device(0);
|
uni_hid_device_t slot_zero = device(0);
|
||||||
uni_hid_device_t slot_one = device(1);
|
uni_hid_device_t slot_one = device(1);
|
||||||
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS &&
|
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS &&
|
||||||
platform_on_device_ready(&slot_one) == 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{};
|
uni_controller_t input{};
|
||||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||||
|
|
@ -1985,24 +1994,8 @@ void test_motion_hotkey() {
|
||||||
ControllerState snapshot{};
|
ControllerState snapshot{};
|
||||||
require(read_controller_state(0, &snapshot) &&
|
require(read_controller_state(0, &snapshot) &&
|
||||||
snapshot.motion_sample_count ==
|
snapshot.motion_sample_count ==
|
||||||
(kDefaultMotionEnabled ? 3 : 0),
|
(kDefaultMotionEnabled ? 0 : 3),
|
||||||
"slot 0 did not start with configured motion state");
|
"motion toggle did not change slot motion publication");
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
process_rumble_timer(&g_rumble_timer);
|
process_rumble_timer(&g_rumble_timer);
|
||||||
require(slot_zero.rumble_calls == 1 &&
|
require(slot_zero.rumble_calls == 1 &&
|
||||||
|
|
@ -2020,12 +2013,6 @@ void test_motion_hotkey() {
|
||||||
: kMotionEnabledFeedbackStrongMagnitude),
|
: kMotionEnabledFeedbackStrongMagnitude),
|
||||||
"motion toggle did not send distinct state feedback");
|
"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{};
|
uni_controller_t peer_input{};
|
||||||
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||||
peer_input.gamepad.accel[0] = 8192;
|
peer_input.gamepad.accel[0] = 8192;
|
||||||
|
|
@ -2033,32 +2020,27 @@ void test_motion_hotkey() {
|
||||||
require(read_controller_state(1, &snapshot) &&
|
require(read_controller_state(1, &snapshot) &&
|
||||||
snapshot.motion_sample_count ==
|
snapshot.motion_sample_count ==
|
||||||
(kDefaultMotionEnabled ? 3 : 0),
|
(kDefaultMotionEnabled ? 3 : 0),
|
||||||
"slot 0 motion chord changed slot 1 motion state");
|
"slot 0 motion action changed slot 1 motion state");
|
||||||
|
|
||||||
input.gamepad = {};
|
require(bluepad32_input_backend_toggle_motion(
|
||||||
platform_on_controller_data(&slot_zero, &input);
|
0, backend_snapshot.connection_generation),
|
||||||
input.gamepad.accel[0] = 8192;
|
"second live motion toggle failed");
|
||||||
input.gamepad.dpad = kMotionHotkeyDpadMask;
|
|
||||||
input.gamepad.buttons = kMotionHotkeyButtonMask;
|
|
||||||
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
|
|
||||||
platform_on_controller_data(&slot_zero, &input);
|
platform_on_controller_data(&slot_zero, &input);
|
||||||
require(read_controller_state(0, &snapshot) &&
|
require(read_controller_state(0, &snapshot) &&
|
||||||
snapshot.motion_sample_count ==
|
snapshot.motion_sample_count ==
|
||||||
(kDefaultMotionEnabled ? 3 : 0),
|
(kDefaultMotionEnabled ? 3 : 0),
|
||||||
"released motion chord did not re-arm or restore motion");
|
"second motion toggle did not restore configured state");
|
||||||
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");
|
|
||||||
|
|
||||||
platform_on_device_disconnected(&slot_zero);
|
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);
|
uni_hid_device_t replacement = device(0);
|
||||||
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
|
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
|
||||||
g_slots[0].motion_enabled == kDefaultMotionEnabled &&
|
g_slots[0].motion_enabled == kDefaultMotionEnabled &&
|
||||||
!g_slots[0].motion_hotkey_latched &&
|
|
||||||
g_slots[0].pre_hotkey_button_mask == 0 &&
|
g_slots[0].pre_hotkey_button_mask == 0 &&
|
||||||
!g_slots[0].feedback_pending,
|
!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() {
|
void test_protocol_neutral_analog_state() {
|
||||||
|
|
@ -2392,7 +2374,7 @@ int main(int argc, char** argv) {
|
||||||
} else if (scenario == "profile-feedback") {
|
} else if (scenario == "profile-feedback") {
|
||||||
test_profile_feedback_scheduler();
|
test_profile_feedback_scheduler();
|
||||||
} else if (scenario == "motion-hotkey") {
|
} else if (scenario == "motion-hotkey") {
|
||||||
test_motion_hotkey();
|
test_motion_toggle_action();
|
||||||
} else if (scenario == "analog-state") {
|
} else if (scenario == "analog-state") {
|
||||||
test_protocol_neutral_analog_state();
|
test_protocol_neutral_analog_state();
|
||||||
} else if (scenario == "rumble-mode") {
|
} else if (scenario == "rumble-mode") {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ unsigned active_snapshot_count = 0;
|
||||||
std::array<ActivationAttempt, 32> activation_attempts{};
|
std::array<ActivationAttempt, 32> activation_attempts{};
|
||||||
size_t activation_attempt_count = 0;
|
size_t activation_attempt_count = 0;
|
||||||
unsigned activation_busy_attempts = 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) {
|
void require(bool condition, const char* message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
|
|
@ -54,6 +57,9 @@ void prepare_profiles() {
|
||||||
activation_attempts = {};
|
activation_attempts = {};
|
||||||
activation_attempt_count = 0;
|
activation_attempt_count = 0;
|
||||||
activation_busy_attempts = 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;
|
for (uint8_t slot = 0;
|
||||||
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
|
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
|
||||||
FakeProfileRow& row = rows[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() {
|
void test_default_switching_retry_commit_and_feedback() {
|
||||||
prepare_profiles();
|
prepare_profiles();
|
||||||
ControllerProfile& initial_profile = rows[0].profiles[0];
|
ControllerProfile& initial_profile = rows[0].profiles[0];
|
||||||
initial_profile.macro_trigger = static_cast<uint8_t>(
|
initial_profile.macro_trigger_mask =
|
||||||
ControllerProfileLogicalButton::kLeftShoulder);
|
logical_button_bit(
|
||||||
|
ControllerProfileLogicalButton::kLeftShoulder);
|
||||||
initial_profile.macro_step_count = 2;
|
initial_profile.macro_step_count = 2;
|
||||||
initial_profile.macro_steps[0].type =
|
initial_profile.macro_steps[0].type =
|
||||||
ControllerProfileMacroStepType::kState;
|
ControllerProfileMacroStepType::kState;
|
||||||
|
|
@ -526,8 +533,9 @@ void test_identity_promotion_preserves_held_switching() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void configure_motion_suppression_probe(ControllerProfile* profile) {
|
void configure_motion_suppression_probe(ControllerProfile* profile) {
|
||||||
profile->macro_trigger = static_cast<uint8_t>(
|
profile->macro_trigger_mask =
|
||||||
ControllerProfileLogicalButton::kDpadUp);
|
logical_button_bit(
|
||||||
|
ControllerProfileLogicalButton::kDpadUp);
|
||||||
profile->macro_step_count = 2;
|
profile->macro_step_count = 2;
|
||||||
profile->macro_steps[0].type =
|
profile->macro_steps[0].type =
|
||||||
ControllerProfileMacroStepType::kState;
|
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) {
|
void configure_synthetic_profile(uint8_t slot) {
|
||||||
ControllerProfile& profile = rows[slot].profiles[0];
|
ControllerProfile& profile = rows[slot].profiles[0];
|
||||||
profile = controller_profile_default(rows[slot].identity, 0);
|
profile = controller_profile_default(rows[slot].identity, 0);
|
||||||
profile.macro_trigger =
|
profile.macro_trigger_mask =
|
||||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
|
logical_button_bit(ControllerProfileLogicalButton::kSouth);
|
||||||
profile.macro_cancel =
|
profile.macro_cancel =
|
||||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
|
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
|
||||||
profile.macro_step_count = 2;
|
profile.macro_step_count = 2;
|
||||||
|
|
@ -837,6 +884,14 @@ void test_runtime_slot_synthetic_isolation() {
|
||||||
|
|
||||||
} // namespace
|
} // 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() {
|
uint32_t profile_service_database_generation() {
|
||||||
return database_generation;
|
return database_generation;
|
||||||
}
|
}
|
||||||
|
|
@ -889,6 +944,7 @@ int main() {
|
||||||
test_identity_promotion_preserves_held_switching();
|
test_identity_promotion_preserves_held_switching();
|
||||||
test_switching_uses_pre_hotkey_buttons_only();
|
test_switching_uses_pre_hotkey_buttons_only();
|
||||||
test_custom_switching_chord_and_wrap();
|
test_custom_switching_chord_and_wrap();
|
||||||
|
test_profile_motion_toggle_supports_trigger_chords();
|
||||||
test_switching_slot_isolation();
|
test_switching_slot_isolation();
|
||||||
test_all_runtime_cancellation_causes();
|
test_all_runtime_cancellation_causes();
|
||||||
test_runtime_slot_synthetic_isolation();
|
test_runtime_slot_synthetic_isolation();
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,9 @@ void test_profile_wire_schema() {
|
||||||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||||
"default profile did not encode");
|
"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,
|
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;
|
for (uint8_t index = 0;
|
||||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||||
require(encoded[4 + index] == index,
|
require(encoded[4 + index] == index,
|
||||||
|
|
@ -64,9 +64,15 @@ void test_profile_wire_schema() {
|
||||||
static_cast<uint8_t>(
|
static_cast<uint8_t>(
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
||||||
"default trigger encoding changed");
|
"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 &&
|
require(encoded[72] == 0xff && encoded[73] == 0xff &&
|
||||||
encoded[74] == 3 && encoded[78] == 0xff &&
|
encoded[74] == 3 && encoded[78] == 0 &&
|
||||||
encoded[79] == 0xff && encoded[80] == 1,
|
encoded[79] == 0 && encoded[80] == 1 &&
|
||||||
|
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON,
|
||||||
"default rumble or macro encoding changed");
|
"default rumble or macro encoding changed");
|
||||||
for (uint8_t index = 0;
|
for (uint8_t index = 0;
|
||||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||||
|
|
@ -77,12 +83,40 @@ void test_profile_wire_schema() {
|
||||||
ControllerProfile decoded{};
|
ControllerProfile decoded{};
|
||||||
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||||
"default profile did not decode");
|
"default profile did not decode");
|
||||||
|
ControllerProfile action_profile = profile;
|
||||||
|
action_profile.switching_chord =
|
||||||
|
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
|
||||||
|
(1u << static_cast<uint8_t>(
|
||||||
|
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;
|
encoded[252] = 1;
|
||||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||||
"nonzero reserved profile byte was accepted");
|
"nonzero reserved profile byte was accepted");
|
||||||
|
|
||||||
ControllerProfile invalid = profile;
|
ControllerProfile invalid = profile;
|
||||||
invalid.button_map[0] = 16;
|
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||||
require(!controller_profile_validate(invalid),
|
require(!controller_profile_validate(invalid),
|
||||||
"invalid direct output was accepted");
|
"invalid direct output was accepted");
|
||||||
invalid = profile;
|
invalid = profile;
|
||||||
|
|
@ -156,11 +190,14 @@ void test_legacy_profile_migration() {
|
||||||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||||
"migrated default profile did not encode");
|
"migrated default profile did not encode");
|
||||||
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
||||||
const bool schema_byte = index == 0;
|
const bool migration_byte =
|
||||||
const bool threshold_byte =
|
index == 0 ||
|
||||||
(index >= 58 && index < 60) ||
|
(index >= 58 && index < 60) ||
|
||||||
(index >= 68 && index < 70);
|
index == 60 ||
|
||||||
if (!schema_byte && !threshold_byte) {
|
(index >= 68 && index < 70) ||
|
||||||
|
index == 70 ||
|
||||||
|
index == 78 || index == 79 || index == 81;
|
||||||
|
if (!migration_byte) {
|
||||||
require(encoded[index] == kLegacyDefaultProfile[index],
|
require(encoded[index] == kLegacyDefaultProfile[index],
|
||||||
"legacy default profile changed an unrelated byte");
|
"legacy default profile changed an unrelated byte");
|
||||||
}
|
}
|
||||||
|
|
@ -177,8 +214,12 @@ void test_legacy_profile_migration() {
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
|
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
|
||||||
encoded[69] ==
|
encoded[69] ==
|
||||||
static_cast<uint8_t>(
|
static_cast<uint8_t>(
|
||||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
|
||||||
"migrated default profile did not encode as v2");
|
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(
|
require(controller_profile_decode(
|
||||||
kLegacyNarrowRawRangeProfile,
|
kLegacyNarrowRawRangeProfile,
|
||||||
|
|
@ -196,11 +237,14 @@ void test_legacy_profile_migration() {
|
||||||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||||
"migrated narrow-raw-range profile did not encode");
|
"migrated narrow-raw-range profile did not encode");
|
||||||
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
||||||
const bool schema_byte = index == 0;
|
const bool migration_byte =
|
||||||
const bool threshold_byte =
|
index == 0 ||
|
||||||
(index >= 58 && index < 60) ||
|
(index >= 58 && index < 60) ||
|
||||||
(index >= 68 && index < 70);
|
index == 60 ||
|
||||||
if (!schema_byte && !threshold_byte) {
|
(index >= 68 && index < 70) ||
|
||||||
|
index == 70 ||
|
||||||
|
index == 78 || index == 79 || index == 81;
|
||||||
|
if (!migration_byte) {
|
||||||
require(
|
require(
|
||||||
encoded[index] == kLegacyNarrowRawRangeProfile[index],
|
encoded[index] == kLegacyNarrowRawRangeProfile[index],
|
||||||
"narrow-raw-range migration changed unrelated profile data");
|
"narrow-raw-range migration changed unrelated profile data");
|
||||||
|
|
@ -214,13 +258,68 @@ void test_legacy_profile_migration() {
|
||||||
require(migrated.triggers[0].digital_threshold == 0x1234 &&
|
require(migrated.triggers[0].digital_threshold == 0x1234 &&
|
||||||
migrated.triggers[1].digital_threshold == 0xabcd,
|
migrated.triggers[1].digital_threshold == 0xabcd,
|
||||||
"legacy custom thresholds were not preserved");
|
"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)),
|
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||||
"legacy custom-threshold profile did not re-encode");
|
"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<uint16_t>(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) {
|
for (size_t index = 1; index < sizeof(encoded); ++index) {
|
||||||
require(encoded[index] == kLegacyCustomThresholdProfile[index],
|
const bool migration_byte =
|
||||||
"legacy custom-threshold profile changed data");
|
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<uint8_t>(
|
||||||
|
CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION);
|
||||||
|
previous_encoded[60] = 0;
|
||||||
|
previous_encoded[70] = 0;
|
||||||
|
previous_encoded[78] = static_cast<uint8_t>(
|
||||||
|
ControllerProfileLogicalButton::kSouth);
|
||||||
|
previous_encoded[79] = static_cast<uint8_t>(
|
||||||
|
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<uint16_t>(
|
||||||
|
1u << static_cast<uint8_t>(
|
||||||
|
ControllerProfileLogicalButton::kSouth)) &&
|
||||||
|
migrated.macro_cancel ==
|
||||||
|
static_cast<uint8_t>(
|
||||||
|
ControllerProfileLogicalButton::kCapture) &&
|
||||||
|
migrated.motion_toggle_chord == 0,
|
||||||
|
"v2 profile controls did not migrate to v4");
|
||||||
|
|
||||||
ControllerProfile current =
|
ControllerProfile current =
|
||||||
controller_profile_default(controller_identity_global(), 0);
|
controller_profile_default(controller_identity_global(), 0);
|
||||||
current.triggers[0].digital_threshold = 0x8000;
|
current.triggers[0].digital_threshold = 0x8000;
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ uint16_t transform_left_trigger(
|
||||||
uint16_t value) {
|
uint16_t value) {
|
||||||
ControllerProfile profile = default_profile();
|
ControllerProfile profile = default_profile();
|
||||||
profile.triggers[0] = configuration;
|
profile.triggers[0] = configuration;
|
||||||
|
profile.triggers[0].output =
|
||||||
|
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||||
ControllerState state{};
|
ControllerState state{};
|
||||||
state.left_trigger = value;
|
state.left_trigger = value;
|
||||||
return controller_profile_transform(state, profile).state.left_trigger;
|
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");
|
"disabled button mapping still produced output");
|
||||||
|
|
||||||
ControllerProfile invalid = default_profile();
|
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),
|
require(!controller_profile_validate(invalid),
|
||||||
"logical output 16 was accepted");
|
"logical output 18 was accepted");
|
||||||
invalid.button_map[0] = 0xfe;
|
invalid.button_map[0] = 0xfe;
|
||||||
require(!controller_profile_validate(invalid),
|
require(!controller_profile_validate(invalid),
|
||||||
"logical output 0xfe was accepted");
|
"logical output 0xfe was accepted");
|
||||||
|
|
@ -271,6 +273,8 @@ void test_trigger_boundaries_curves_and_thresholds() {
|
||||||
|
|
||||||
ControllerProfile profile = default_profile();
|
ControllerProfile profile = default_profile();
|
||||||
profile.triggers[0] = slow;
|
profile.triggers[0] = slow;
|
||||||
|
profile.triggers[0].output =
|
||||||
|
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||||
profile.triggers[0].digital_threshold = 12345;
|
profile.triggers[0].digital_threshold = 12345;
|
||||||
profile.triggers[1].digital_threshold = 54321;
|
profile.triggers[1].digital_threshold = 54321;
|
||||||
ControllerState state{};
|
ControllerState state{};
|
||||||
|
|
@ -282,6 +286,41 @@ void test_trigger_boundaries_curves_and_thresholds() {
|
||||||
"profile-owned digital thresholds were not returned");
|
"profile-owned digital thresholds were not returned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_trigger_and_button_cross_mapping() {
|
||||||
|
ControllerProfile profile = default_profile();
|
||||||
|
profile.triggers[0].output =
|
||||||
|
static_cast<uint8_t>(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<uint8_t>(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() {
|
void test_default_whole_state_equivalence() {
|
||||||
ControllerState input{};
|
ControllerState input{};
|
||||||
input.dpad_up = true;
|
input.dpad_up = true;
|
||||||
|
|
@ -378,6 +417,7 @@ int main() {
|
||||||
test_stick_center_boundaries_and_inversion();
|
test_stick_center_boundaries_and_inversion();
|
||||||
test_stick_curves_and_monotonicity();
|
test_stick_curves_and_monotonicity();
|
||||||
test_trigger_boundaries_curves_and_thresholds();
|
test_trigger_boundaries_curves_and_thresholds();
|
||||||
|
test_trigger_and_button_cross_mapping();
|
||||||
test_default_whole_state_equivalence();
|
test_default_whole_state_equivalence();
|
||||||
test_rumble_scaling_and_confirmation_policy();
|
test_rumble_scaling_and_confirmation_policy();
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ ControllerProfile profile_with_macro(
|
||||||
ControllerProfileLogicalButton::kCapture) {
|
ControllerProfileLogicalButton::kCapture) {
|
||||||
ControllerProfile profile =
|
ControllerProfile profile =
|
||||||
controller_profile_default(controller_identity_global(), 0);
|
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);
|
profile.macro_cancel = button_index(cancel);
|
||||||
return profile;
|
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");
|
"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() {
|
void test_optional_field_overrides_and_motion_preservation() {
|
||||||
ControllerProfile profile =
|
ControllerProfile profile =
|
||||||
profile_with_macro(ControllerProfileLogicalButton::kSelect);
|
profile_with_macro(ControllerProfileLogicalButton::kSelect);
|
||||||
|
|
@ -454,6 +502,7 @@ void test_four_contexts_are_isolated() {
|
||||||
|
|
||||||
int main() {
|
int main() {
|
||||||
test_immediate_press_release_dpad_and_explicit_end();
|
test_immediate_press_release_dpad_and_explicit_end();
|
||||||
|
test_macro_trigger_chord_requires_every_button();
|
||||||
test_optional_field_overrides_and_motion_preservation();
|
test_optional_field_overrides_and_motion_preservation();
|
||||||
test_zero_max_wait_and_scheduled_catch_up();
|
test_zero_max_wait_and_scheduled_catch_up();
|
||||||
test_consumption_cancel_precedence_and_duplicate_contributors();
|
test_consumption_cancel_precedence_and_duplicate_contributors();
|
||||||
|
|
|
||||||
|
|
@ -483,7 +483,7 @@ class FakeDevice:
|
||||||
def custom_profile() -> config_manager.ControllerProfile:
|
def custom_profile() -> config_manager.ControllerProfile:
|
||||||
return config_manager.ControllerProfile(
|
return config_manager.ControllerProfile(
|
||||||
button_map=(
|
button_map=(
|
||||||
1,
|
config_manager.LOGICAL_CONTROLS.index("left_trigger"),
|
||||||
0,
|
0,
|
||||||
2,
|
2,
|
||||||
3,
|
3,
|
||||||
|
|
@ -506,14 +506,21 @@ def custom_profile() -> config_manager.ControllerProfile:
|
||||||
right_stick=config_manager.StickConfig(
|
right_stick=config_manager.StickConfig(
|
||||||
789, -321, 500, 31000, 192, False, True
|
789, -321, 500, 31000, 192, False, True
|
||||||
),
|
),
|
||||||
left_trigger=config_manager.TriggerConfig(100, 65000, 320, 32000),
|
left_trigger=config_manager.TriggerConfig(
|
||||||
right_trigger=config_manager.TriggerConfig(200, 64000, 224, 33000),
|
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,
|
weak_rumble_scale=77,
|
||||||
strong_rumble_scale=201,
|
strong_rumble_scale=201,
|
||||||
confirmation_policy=2,
|
confirmation_policy=2,
|
||||||
switching_chord=(1 << 6) | (1 << 7),
|
switching_chord=(1 << 6) | (1 << 16),
|
||||||
macro_trigger=0,
|
motion_toggle_chord=(1 << 5) | (1 << 17),
|
||||||
macro_cancel=1,
|
macro_trigger_mask=(1 << 0) | (1 << 16) | (1 << 17),
|
||||||
|
macro_cancel=config_manager.LOGICAL_CONTROLS.index("right_trigger"),
|
||||||
macro_steps=(
|
macro_steps=(
|
||||||
config_manager.MacroStep(
|
config_manager.MacroStep(
|
||||||
0,
|
0,
|
||||||
|
|
@ -1113,13 +1120,18 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
||||||
config_manager.PROFILE_SCHEMA_VERSION,
|
config_manager.PROFILE_SCHEMA_VERSION,
|
||||||
config_manager.PROFILE_SIZE,
|
config_manager.PROFILE_SIZE,
|
||||||
)
|
)
|
||||||
assert encoded[75] == encoded[81] == 0
|
assert encoded[75] == 0x2D
|
||||||
assert encoded[98:100] == b"\x00\x00"
|
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("<H", encoded, 98)[0] == (
|
||||||
|
profile.motion_toggle_chord & 0xFFFF
|
||||||
|
)
|
||||||
assert encoded[252:] == bytes(4)
|
assert encoded[252:] == bytes(4)
|
||||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||||
|
|
||||||
serialized = profile.to_json()
|
serialized = profile.to_json()
|
||||||
assert serialized.startswith('{\n "schema_version": 2,\n "size": 256,')
|
assert serialized.startswith('{\n "schema_version": 4,\n "size": 256,')
|
||||||
decoded = config_manager.ControllerProfile.from_json(serialized)
|
decoded = config_manager.ControllerProfile.from_json(serialized)
|
||||||
assert decoded == profile
|
assert decoded == profile
|
||||||
assert decoded.to_json() == serialized
|
assert decoded.to_json() == serialized
|
||||||
|
|
@ -1131,6 +1143,12 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
||||||
0,
|
0,
|
||||||
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
|
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(
|
struct.pack_into(
|
||||||
"<HHHH",
|
"<HHHH",
|
||||||
legacy_default_wire,
|
legacy_default_wire,
|
||||||
|
|
@ -1180,22 +1198,37 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
||||||
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
|
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
|
||||||
)
|
)
|
||||||
|
|
||||||
legacy_custom_wire = bytearray(encoded)
|
previous_wire = bytearray(default_wire)
|
||||||
struct.pack_into(
|
struct.pack_into(
|
||||||
"<H",
|
"<H",
|
||||||
legacy_custom_wire,
|
previous_wire,
|
||||||
0,
|
0,
|
||||||
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
|
config_manager.PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
|
||||||
)
|
)
|
||||||
assert (
|
previous_wire[60:62] = b"\x00\x00"
|
||||||
config_manager.ControllerProfile.from_bytes(legacy_custom_wire)
|
previous_wire[70:72] = b"\x00\x00"
|
||||||
== profile
|
previous_wire[78] = 0
|
||||||
|
previous_wire[79] = 1
|
||||||
|
previous_wire[81] = 0
|
||||||
|
previous_wire[98:100] = b"\x00\x00"
|
||||||
|
migrated_previous = config_manager.ControllerProfile.from_bytes(
|
||||||
|
previous_wire
|
||||||
)
|
)
|
||||||
|
assert migrated_previous.left_trigger.output == 16
|
||||||
|
assert migrated_previous.right_trigger.output == 17
|
||||||
|
assert migrated_previous.macro_trigger_mask == 1
|
||||||
|
assert migrated_previous.macro_cancel == 1
|
||||||
|
assert migrated_previous.motion_toggle_chord == 0
|
||||||
|
assert migrated_previous.to_bytes()[0] == config_manager.PROFILE_SCHEMA_VERSION
|
||||||
|
|
||||||
legacy_json_object = default_profile.to_json_object()
|
legacy_json_object = default_profile.to_json_object()
|
||||||
legacy_json_object["schema_version"] = (
|
legacy_json_object["schema_version"] = (
|
||||||
config_manager.PROFILE_LEGACY_SCHEMA_VERSION
|
config_manager.PROFILE_LEGACY_SCHEMA_VERSION
|
||||||
)
|
)
|
||||||
|
del legacy_json_object["motion_toggle_chord"]
|
||||||
|
del legacy_json_object["triggers"]["left"]["output"]
|
||||||
|
del legacy_json_object["triggers"]["right"]["output"]
|
||||||
|
legacy_json_object["macro"]["trigger"] = "south"
|
||||||
legacy_json_object["triggers"]["left"]["digital_threshold"] = (
|
legacy_json_object["triggers"]["left"]["digital_threshold"] = (
|
||||||
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
|
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
|
||||||
)
|
)
|
||||||
|
|
@ -1212,15 +1245,31 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
||||||
assert migrated_json.right_trigger.digital_threshold == 33000
|
assert migrated_json.right_trigger.digital_threshold == 33000
|
||||||
assert migrated_json.left_trigger.lower_deadzone == 30000
|
assert migrated_json.left_trigger.lower_deadzone == 30000
|
||||||
assert migrated_json.left_trigger.upper_saturation == 40000
|
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:
|
def test_trigger_threshold_uses_transformed_output_domain() -> None:
|
||||||
for threshold in (0, 0xFFFF):
|
for threshold in (0, 0xFFFF):
|
||||||
trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold)
|
trigger = config_manager.TriggerConfig(
|
||||||
assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger
|
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 (
|
assert (
|
||||||
config_manager.TriggerConfig.from_json_object(
|
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
|
== trigger
|
||||||
)
|
)
|
||||||
|
|
@ -1242,7 +1291,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
|
||||||
config_manager.ConfigManagerError,
|
config_manager.ConfigManagerError,
|
||||||
match="trigger digital_threshold",
|
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 (
|
for lower_deadzone, upper_saturation in (
|
||||||
(40000, 40000),
|
(40000, 40000),
|
||||||
|
|
@ -1253,7 +1302,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
|
||||||
match="lower_deadzone must be below upper_saturation",
|
match="lower_deadzone must be below upper_saturation",
|
||||||
):
|
):
|
||||||
config_manager.TriggerConfig(
|
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(
|
def test_profile_cli_json_round_trip_activate_and_reset(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
capsys: pytest.CaptureFixture[str],
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
|
@ -1485,9 +1581,9 @@ def test_malformed_profiles_are_rejected_before_usb(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||||
malformed_binary[75] = 1
|
malformed_binary[75] = 0xC0
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
config_manager.ConfigManagerError, match="reserved fields"
|
config_manager.ConfigManagerError, match="action flags"
|
||||||
):
|
):
|
||||||
config_manager.ControllerProfile.from_bytes(malformed_binary)
|
config_manager.ControllerProfile.from_bytes(malformed_binary)
|
||||||
|
|
||||||
|
|
|
||||||
212
tests/test_profile_web.py
Normal file
212
tests/test_profile_web.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue