feat: select Joy-Con player modes and join pairs with a shortcut
This commit is contained in:
parent
34f8a50c19
commit
06a89b5ae1
22 changed files with 2680 additions and 168 deletions
|
|
@ -663,6 +663,27 @@ solo bank; independent activation was checked and all test settings restored.
|
|||
Studio's selected pair key matched the live firmware owner, and Identify
|
||||
produced two physical dispatches.
|
||||
|
||||
Joy-Con 2 now supports a persisted adapter-wide Paired/Individual default in
|
||||
configuration schema 4, byte 4 of the unchanged 232-byte payload. Studio and
|
||||
`joycon-mode [paired|individual]` apply it live. Individual uses separate
|
||||
sideways players/solo banks; Paired reuses the composite bank. Live split/join
|
||||
preserves Bluetooth links, unrelated players and existing pair membership,
|
||||
while clearing affected input/capture/rumble epochs.
|
||||
|
||||
The physical connection-only override is a two-second hold of left ZL+Minus
|
||||
and right ZR+Plus. It joins the selected solos or splits their current pair;
|
||||
ambiguous solo arming does not choose arbitrarily. Reserved inputs are masked
|
||||
before profile/macro handling, both participants need fresh held reports, and
|
||||
all chord buttons must release before retry. Confirmation is bounded to the
|
||||
participating halves. Disconnect/reuse or a changed saved default clears the
|
||||
override, without writing a per-pair preference.
|
||||
|
||||
All 349 tests and all five builds passed. Real hardware exercised both live
|
||||
mode transitions, Individual boot persistence, physical join/split pulses,
|
||||
unchanged configuration generations during gestures, and automatic default
|
||||
restoration after resetting/reconnecting one half. All 64 profile records and
|
||||
metadata remain unchanged; the final saved default is Individual.
|
||||
|
||||
| Priority | Candidate | Intended scope | Dependency or principal risk |
|
||||
|---|---|---|---|
|
||||
| A1 — Complete | Named and copyable profiles | Profile names and controller aliases use catalog metadata rather than input-profile fields. Copy/export/import preserve the current draft and per-section resets leave other settings unchanged. | Atomic metadata records; no report-path cost. |
|
||||
|
|
@ -799,11 +820,11 @@ without losing the Joy-Con links. A 30-second production mixed-rumble run
|
|||
kept native DualSense streaming, with no send failures or ingress drops,
|
||||
14 Joy-Con output-stage discards and nine PCM skips. It is not lossless.
|
||||
|
||||
Verification is now 289 passing tests and all five firmware builds. All
|
||||
40 pre-existing profiles, metadata and adapter configuration remain unchanged;
|
||||
the current inventory contains 56 profiles across seven identities. Temporary
|
||||
measurement hooks were removed, and the final production image passed another
|
||||
simultaneous-rumble exercise after normal controller reconnect. See the
|
||||
The native-HD checkpoint passed 289 tests and all five firmware builds; its
|
||||
40 pre-existing profiles and metadata survived. Its inventory then contained
|
||||
56 profiles across seven identities. Temporary measurement hooks were removed,
|
||||
and that production image passed another simultaneous-rumble exercise after
|
||||
normal controller reconnect. See the
|
||||
[native HD contract and measurements](README.md#switch-2-native-hd-rumble).
|
||||
|
||||
### Native Switch-family HD rumble — Implemented, qualification incomplete
|
||||
|
|
@ -834,7 +855,7 @@ Current constraints:
|
|||
- `native_output_scheduler.cpp` arbitrates both native writers with urgent-stop
|
||||
precedence, earliest deadlines, rotating ties, periodic credit reservations,
|
||||
and generation-bound grant completion. Radio power policy is unchanged.
|
||||
- Adapter configuration schema 3 persists up to 16 explicit physical approvals.
|
||||
- Adapter configuration schema 4 persists up to 16 explicit physical approvals and the Joy-Con default mode.
|
||||
Profile schema 7/catalog 3 preserves prior settings; bonds and wake identity are unchanged.
|
||||
- Original Switch Joy-Cons remain separate, horizontally mapped controllers.
|
||||
Joy-Con 2 logical pairing is a distinct BLE implementation described above.
|
||||
|
|
|
|||
70
README.md
70
README.md
|
|
@ -209,6 +209,54 @@ The Pico 2 W onboard LED reports the overall Bluetooth state:
|
|||
- **Pairing window expires**: new authentication and full discovery stop while a controller is active; remembered controllers may still initiate reconnects, and a solo Joy-Con 2 resumes low-duty discovery of its remembered opposite half.
|
||||
- **Clear all pairings**: hold BOOTSEL continuously for 10 seconds, through the initial double blink, until the rapid confirmation blink starts. All controllers are disconnected and must be paired again.
|
||||
|
||||
### Joy-Con 2 player arrangement
|
||||
|
||||
In Controller Studio, use **Joy-Con 2 default mode** and **Apply default mode**.
|
||||
This is a real adapter-wide setting, separate from the artwork preview:
|
||||
|
||||
- **Paired:** ready opposite halves form one player and use their L+R bank.
|
||||
- **Individual:** both halves can remain connected as separate sideways players,
|
||||
each using its own solo bank. Select the L or R owner to edit its settings.
|
||||
|
||||
Changes apply live without Bluetooth disconnection or fresh pairing. Affected
|
||||
input, macro/capture and rumble epochs are cleared; unrelated players and all
|
||||
saved banks remain intact. Unmatched halves stay solo. The default persists
|
||||
across reboot:
|
||||
|
||||
```sh
|
||||
uv run switch-pico-config joycon-mode
|
||||
uv run switch-pico-config joycon-mode individual
|
||||
uv run switch-pico-config joycon-mode paired --json
|
||||
```
|
||||
|
||||
#### Join or split a specific pair from the controllers
|
||||
|
||||
- On the **left** Joy-Con 2, hold **ZL + Minus**.
|
||||
- On the **right** Joy-Con 2, hold **ZR + Plus**.
|
||||
- Hold both chords together for **2 seconds**, until the short confirmation
|
||||
pulse, then **release all four buttons**.
|
||||
|
||||
Two solo halves join; an existing pair splits. Other controllers are untouched.
|
||||
The shortcut does not change the saved default: its connection-only override
|
||||
ends when either participating half disconnects, or when the adapter default
|
||||
changes. Existing solo/pair banks and active selections are reused.
|
||||
|
||||
Each half's complete two-button chord is reserved while held and until both
|
||||
buttons on that half release, even if the other half has not joined the
|
||||
attempt. Those inputs do not reach profile macros or the console. Other inputs
|
||||
remain available. Fresh held reports from both halves are required; stale,
|
||||
failed or ambiguous attempts require release before retry. When several solo
|
||||
halves are armed ambiguously, no arbitrary pair is chosen. A chord cannot steal
|
||||
a member from a different already-joined pair.
|
||||
|
||||
For a non-destructive disconnect/reset, Nintendo documents pressing
|
||||
[SYNC once, then a normal button to wake the Joy-Con 2](https://en-americas-support.nintendo.com/app/answers/detail/a_id/68521/).
|
||||
|
||||
Configuration schema 4 keeps the 232-byte object and stores the default mode
|
||||
in byte 4 (`0` Paired, `1` Individual); bytes 5–7 remain reserved. Schemas 1–3
|
||||
migrate to Paired, preserving USB mode, pairing-window duration and existing
|
||||
native approvals. Older firmware cannot save this setting.
|
||||
|
||||
### Managing configuration, profiles, and pairings from a PC
|
||||
|
||||
Connect the Pico 2 W to the PC while the AIO firmware is running normally; do not enter the ROM BOOTSEL drive. `switch-pico-config` uses versioned private vendor requests on USB endpoint 0, so it does not add an interface or depend on Linux `hidraw` nodes.
|
||||
|
|
@ -249,7 +297,7 @@ Controller Studio uses the supplied lightweight SVGs for Switch 2 Pro, Joy-Con 2
|
|||
|
||||
**Auto** uses matching-owner live metadata to distinguish a Joy-Con pair from a solo half. **Preview** changes only the editor's diagram and source labels; it does not pair controllers or change saved mappings, and physical highlighting is disabled. Source choices reflect the layout while stored unavailable mappings are retained. Wii orientation is not reported, so horizontal/vertical views require an explicit preview.
|
||||
|
||||
**Joy-Con 2 pair profiles:** the first successful connection of an L + R combination creates a separate **Nintendo Joy-Con 2 (L + R)** owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Disconnecting a half restores the survivor's solo bank; reconnecting the same members restores their existing pair bank without copying again. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings.
|
||||
**Joy-Con 2 pair profiles:** the first successful join of an L + R combination creates a separate **Nintendo Joy-Con 2 (L + R)** owner with eight profiles. It initially copies the left bank's profiles, names, and active selection; its alias starts empty. Both solo banks stay unchanged. Pair edits, names, and active selections are independent thereafter. Splitting or losing a half restores solo banks; joining the same members again restores their existing pair bank without copying. Different member combinations have different banks. Select the L + R owner—not either solo owner—to edit paired settings.
|
||||
|
||||
Pair keys contain both complete Bluetooth addresses and address types, in canonical L/R order. They retain the 14-byte identity size: transport byte 1 is 3; byte 0 contains the stable bit plus the left/right static-random flags in bits 1/2; bytes 2–7 and 8–13 contain the left and right addresses. Pair keys are profile identities, not Bluetooth peers or native-output approvals. Update host tools with firmware when using this identity kind.
|
||||
|
||||
|
|
@ -347,17 +395,19 @@ Motion-producing Bluepad32 parsers normalize to 1024 units per degree/second and
|
|||
The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069` (Pro), `057E:2067` (left Joy-Con 2), and `057E:2066` (right Joy-Con 2). This is controller **input** support, distinct from the existing Switch 2 console-wake feature and from emulating a native Switch 2 USB controller.
|
||||
|
||||
- **Pairing:** fresh SYNC pairing requires the existing bounded pairing window. A directed reconnect must target this adapter's Bluetooth address and match its persistent application-level authorization. These links are unencrypted and are **not authenticated SMP bonds**. No global Bluetooth security downgrade is made; automatic SMP requests for these devices fail closed while other controllers retain their existing policy. Public/static addresses can own profiles; transient private addresses are not promoted to persistent identities.
|
||||
- **Joy-Con ownership:** an opposite ready half joins a solo half after its independent pair bank is ready. Either connection order works; the first-ready player slot is retained. The pair identity includes both typed member addresses, and the right half supplies motion. Losing a half clears its inputs and pending effects; the survivor returns to sideways solo operation and its own bank. Failed bank initialization leaves the existing solo intact rather than sharing its settings with the pair. A lone half has rotated controls and SL/SR shoulders. Two pairs exhaust the four physical Bluetooth connections. Pairing does not reopen discovery outside the existing connection policy.
|
||||
- **Joy-Con ownership:** the saved player-mode default and connection-only shortcut determine whether ready opposite halves join. Enrollment must finish before a pair is published; a failed join leaves the solos intact. Pair identity includes both typed member addresses, and the right half supplies paired motion. Splitting clears stale input/effects and restores sideways solo banks. Changing the default does not disconnect Bluetooth; a participant disconnect clears its temporary grouping override. Two pairs exhaust the four physical Bluetooth connections. New authentication remains restricted to the normal pairing window.
|
||||
- **Protocol:** service, characteristic and CCCD UUIDs are discovered rather than trusting fixed ATT handles. Setup requires matching acknowledgements, reads user/factory stick calibration and gyro bias, and rejects malformed/failed transactions. Motion is normalized to the existing SDL-oriented units; sensor clock/range classification and physical axis accuracy still need wider model qualification.
|
||||
- **Rumble:** Switch HD commands now retain independent left/right frequency/amplitude fields and up to three ordered subframes through the native Switch 2 encoder and bounded queues described below. XInput and local feedback retain their conventional fixed-carrier behavior. This is separate from the original Switch-native opt-in backend.
|
||||
- **Not implemented:** Joy-Con mouse output, native GameChat signaling, NFC/IR and Switch 2 NSO GameCube support. C and back/rail inputs can instead be remapped to controls the selected USB mode supports.
|
||||
|
||||
This is a scoped reimplementation informed by [Bluepad32 PR #219](https://github.com/ricardoquesada/bluepad32/pull/219), reviewed at `9c95e43a87d3bd8a68565da0836d8a758bd8d8af`, not a wholesale fork import. Protocol references: [ndeadly's research](https://github.com/ndeadly/switch2_controller_research), [Nadeflore](https://github.com/Nadeflore/switch2-controllers), [Switch2Connect](https://github.com/TommyWabg/Switch2Connect), and [SDL's Switch 2 sensor implementation](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c).
|
||||
|
||||
**Verification:** 321 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use the SDK's real BTstack types/accessors and cover discovery, acknowledgement ordering, calibration, persistence, output deadlines and teardown. Lifecycle/storage tests cover both Joy-Con connection orders, multiple pairs, detach/replacement, independent pair banks, interrupted initialization and pairing-policy isolation. The editor's layouts, remapping and source preservation were exercised in Chromium.
|
||||
**Verification:** 349 tests passed; AIO, XInput/feasibility, HD-rumble, haptics and UART firmware variants built. Native protocol tests use real BTstack types/accessors. Lifecycle/storage tests cover player-mode transitions, both connection orders, two-pair membership, bank isolation and interrupted initialization. Gesture cases cover timing/freshness/wrap, input masking, real macro/capture cancellation, ambiguity, failures, and disconnect/default restoration. Studio controls, draft preservation, CLI refresh and desktop/mobile layouts were exercised in Chromium.
|
||||
|
||||
On hardware, the new L + R owner copied all eight left-bank profiles and names, bringing the inventory to eight owners / 64 profiles without changing the original 56. A temporary edit and name on inactive pair profile 8 survived reboot while both solo banks stayed unchanged; pair activation was also independent. Test edits, names and active selections were restored. Studio selected the real composite owner and its Identify action produced two physical rumble dispatches.
|
||||
|
||||
Hardware mode changes split the pair into two live USB slots and recombined it without fresh pairing; Individual remained selected after reboot. The user felt join and split confirmation pulses from the physical shortcut. Saved configuration generation stayed unchanged during each gesture. A temporary split under a Paired default ended after resetting/reconnecting the right half, restoring the original pair. The final saved default is Individual; all 64 profiles, names, aliases and active selections were preserved.
|
||||
|
||||
At the initial Switch 2 input checkpoint, a real Pro (`3C:A9:AB:65:73:12`) completed setup, appeared in persistent pairing/profile inventories, and delivered live sticks, accelerometer, gyro and independent C/GL/GR presses. A 100-report USB rumble exercise retained its connection while 3,033 controller reports arrived. Schema-7 extra mappings were written/read and restored; all 32 then-existing profiles, metadata and active selections were preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Subsequent native-rumble and pair measurements are documented here and below; wider sensor-axis, perceptual-equivalence and long-duration transport qualification remains open. Use schema-7-capable firmware after saving expanded profiles.
|
||||
|
||||
### Switch 2 native HD rumble
|
||||
|
|
@ -485,9 +535,9 @@ uv run switch-pico-config config native-rumble revoke --identity N
|
|||
Use the physical controller's row from `profiles list`, not the global fallback.
|
||||
`config native-rumble list` also supplies approval indices; `revoke --approval N`
|
||||
can remove an approval after its profile-catalog entry has been forgotten.
|
||||
Approvals persist in adapter configuration schema 3 (232 bytes). Schema 1/2
|
||||
migration preserves existing settings and starts with no approvals; profile
|
||||
schema 6 and the profile catalog are unchanged.
|
||||
Approvals persist in adapter configuration schema 4 (232 bytes), alongside the
|
||||
Joy-Con default mode. Schema 1/2 migration starts with no approvals; schema 3
|
||||
migration preserves its approval list. Profile schema 7/catalog 3 are separate.
|
||||
|
||||
The native encoder preserves safe unity bytes when synchronized, otherwise
|
||||
encodes independent actuator/band/substep state with documented quantization.
|
||||
|
|
@ -915,15 +965,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
|
|||
|
||||
| Resource | Used or reserved | Device capacity |
|
||||
|---|---:|---:|
|
||||
| Executable flash image | 825,232 bytes | 4 MiB |
|
||||
| Executable flash image | 833,320 bytes | 4 MiB |
|
||||
| Indexed profile arenas | 256 KiB | 4 MiB flash |
|
||||
| Adapter configuration | 8 KiB | 4 MiB flash |
|
||||
| BTstack bonds and Switch 2 application authorizations | 8 KiB | 4 MiB flash |
|
||||
| RP2350 terminal sector | 4 KiB | 4 MiB flash |
|
||||
| Allocated/reserved SRAM, including heap and stacks | 151,652 bytes | 520 KiB |
|
||||
| Allocated/reserved SRAM, including heap and stacks | 151,804 bytes | 520 KiB |
|
||||
|
||||
The executable plus persistent reservations consume 1,107,856 bytes of flash,
|
||||
leaving 3,086,448 bytes. Allocated SRAM sections leave 380,828 bytes of link-time
|
||||
The executable plus persistent reservations consume 1,115,944 bytes of flash,
|
||||
leaving 3,078,360 bytes. Allocated SRAM sections leave 380,676 bytes of link-time
|
||||
headroom; this is not a runtime heap high-water measurement. Core 0 has a
|
||||
4 KiB stack, and Core 1 uses a dedicated 16 KiB stack in main SRAM for nested
|
||||
catalog migration/compaction rather than overflowing its 4 KiB scratch bank.
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -8,6 +8,10 @@ bool pairing_window_valid(uint16_t pairing_window_seconds) {
|
|||
pairing_window_seconds <= ADAPTER_PAIRING_WINDOW_SECONDS_MAX;
|
||||
}
|
||||
|
||||
bool joycon_mode_valid(JoyConMode mode) {
|
||||
return mode == JoyConMode::kPaired || mode == JoyConMode::kIndividual;
|
||||
}
|
||||
|
||||
bool native_switch_identity_eligible(const ControllerIdentity& identity) {
|
||||
return identity.stable &&
|
||||
identity.transport == ControllerTransport::kClassic &&
|
||||
|
|
@ -77,6 +81,7 @@ bool adapter_configuration_encode(const AdapterConfiguration& configuration,
|
|||
output_size != ADAPTER_CONFIGURATION_ENCODED_SIZE ||
|
||||
!pairing_window_valid(configuration.pairing_window_seconds) ||
|
||||
!adapter_requested_mode_valid(configuration.requested_mode) ||
|
||||
!joycon_mode_valid(configuration.joycon_mode) ||
|
||||
configuration.native_switch_controller_count >
|
||||
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) {
|
||||
return false;
|
||||
|
|
@ -88,6 +93,7 @@ bool adapter_configuration_encode(const AdapterConfiguration& configuration,
|
|||
static_cast<uint8_t>(configuration.pairing_window_seconds >> 8);
|
||||
output[2] = static_cast<uint8_t>(configuration.requested_mode);
|
||||
output[3] = configuration.native_switch_controller_count;
|
||||
output[4] = static_cast<uint8_t>(configuration.joycon_mode);
|
||||
for (size_t index = 0;
|
||||
index < configuration.native_switch_controller_count; ++index) {
|
||||
const ControllerIdentity& identity =
|
||||
|
|
@ -136,15 +142,19 @@ bool adapter_configuration_decode(uint16_t schema_version,
|
|||
}
|
||||
decoded.requested_mode = AdapterRequestedMode::kAuto;
|
||||
} else if (schema_version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION ||
|
||||
schema_version == ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION ||
|
||||
schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION) {
|
||||
const bool current =
|
||||
const bool has_approvals =
|
||||
schema_version >= ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION;
|
||||
const bool has_joycon_mode =
|
||||
schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION;
|
||||
const size_t expected_size =
|
||||
current ? ADAPTER_CONFIGURATION_ENCODED_SIZE
|
||||
: ADAPTER_CONFIGURATION_V2_ENCODED_SIZE;
|
||||
if (payload_size != expected_size || payload[4] != 0 ||
|
||||
has_approvals ? ADAPTER_CONFIGURATION_ENCODED_SIZE
|
||||
: ADAPTER_CONFIGURATION_V2_ENCODED_SIZE;
|
||||
if (payload_size != expected_size ||
|
||||
(!has_joycon_mode && payload[4] != 0) ||
|
||||
payload[5] != 0 || payload[6] != 0 || payload[7] != 0 ||
|
||||
(!current && payload[3] != 0) ||
|
||||
(!has_approvals && payload[3] != 0) ||
|
||||
payload[3] >
|
||||
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY) {
|
||||
return false;
|
||||
|
|
@ -154,7 +164,13 @@ bool adapter_configuration_decode(uint16_t schema_version,
|
|||
if (!adapter_requested_mode_valid(decoded.requested_mode)) {
|
||||
return false;
|
||||
}
|
||||
if (current) {
|
||||
if (has_joycon_mode) {
|
||||
decoded.joycon_mode = static_cast<JoyConMode>(payload[4]);
|
||||
if (!joycon_mode_valid(decoded.joycon_mode)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (has_approvals) {
|
||||
decoded.native_switch_controller_count = payload[3];
|
||||
size_t offset = ADAPTER_CONFIGURATION_HEADER_SIZE;
|
||||
for (size_t index = 0;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
|
||||
constexpr uint16_t ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION = 1;
|
||||
constexpr uint16_t ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION = 2;
|
||||
constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 3;
|
||||
constexpr uint16_t ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION = 3;
|
||||
constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 4;
|
||||
constexpr size_t ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE = 4;
|
||||
constexpr size_t ADAPTER_CONFIGURATION_V2_ENCODED_SIZE = 8;
|
||||
constexpr size_t ADAPTER_CONFIGURATION_HEADER_SIZE = 8;
|
||||
|
|
@ -21,6 +22,11 @@ constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MIN = 10;
|
|||
constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MAX = 300;
|
||||
constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT = 60;
|
||||
|
||||
enum class JoyConMode : uint8_t {
|
||||
kPaired = 0,
|
||||
kIndividual = 1,
|
||||
};
|
||||
|
||||
struct AdapterConfiguration {
|
||||
uint16_t pairing_window_seconds =
|
||||
ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT;
|
||||
|
|
@ -28,6 +34,7 @@ struct AdapterConfiguration {
|
|||
uint8_t native_switch_controller_count = 0;
|
||||
ControllerIdentity native_switch_controllers[
|
||||
ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY]{};
|
||||
JoyConMode joycon_mode = JoyConMode::kPaired;
|
||||
};
|
||||
|
||||
struct AdapterModeAvailability {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ constexpr uint8_t kAllBlePairingMethods =
|
|||
constexpr uint16_t kProfileFeedbackPhaseDurationMs = 75;
|
||||
constexpr uint8_t kProfileFeedbackWeakMagnitude = UINT8_MAX;
|
||||
constexpr uint8_t kProfileFeedbackStrongMagnitude = UINT8_MAX;
|
||||
constexpr uint32_t kJoyConGestureHoldMs = 2000;
|
||||
constexpr uint32_t kJoyConGestureFreshMs = 250;
|
||||
constexpr uint16_t kJoyConGestureFeedbackMs = 75;
|
||||
// One initial indication can be followed by one committed switch before the
|
||||
// Core 1 timer drains the queue. Profile commits are rate-limited well beyond
|
||||
// the longest feedback sequence.
|
||||
|
|
@ -240,6 +243,41 @@ struct Switch2IntervalRequest {
|
|||
uint32_t requested_ms = 0;
|
||||
};
|
||||
Switch2IntervalRequest g_switch2_interval_requests[kSlotCount]{};
|
||||
JoyConMode g_joycon_mode = JoyConMode::kPaired;
|
||||
bool g_joycon_reconcile_requested = false;
|
||||
// Live-link hints only: splitting two pairs must not exchange their members
|
||||
// when the next Paired preference is applied.
|
||||
struct JoyConPairHint {
|
||||
uni_hid_device_t* mate = nullptr;
|
||||
uint8_t owner_slot = 0;
|
||||
};
|
||||
JoyConPairHint g_joycon_pair_hints[kSlotCount]{};
|
||||
|
||||
enum class JoyConGroupingOverride : uint8_t {
|
||||
Default,
|
||||
Individual,
|
||||
Paired,
|
||||
};
|
||||
struct JoyConConnectionOverride {
|
||||
JoyConGroupingOverride mode = JoyConGroupingOverride::Default;
|
||||
uni_hid_device_t* mate = nullptr;
|
||||
};
|
||||
JoyConConnectionOverride g_joycon_overrides[kSlotCount]{};
|
||||
|
||||
// Core 1 physical-link state survives logical slot moves. Raw reports stay in
|
||||
// BackendSlot; only the derived logical view consumes the reserved buttons.
|
||||
struct JoyConGesture {
|
||||
uni_hid_device_t* device = nullptr;
|
||||
uint32_t last_report_ms = 0;
|
||||
uint32_t started_ms = 0;
|
||||
uint8_t participants = 0;
|
||||
bool held = false;
|
||||
bool released = true;
|
||||
bool masked = false;
|
||||
bool blocked = false;
|
||||
bool joining = false;
|
||||
};
|
||||
JoyConGesture g_joycon_gestures[kSlotCount]{};
|
||||
uint32_t g_pairing_window_deadline_ms = 0;
|
||||
uint32_t g_pairing_window_duration_ms =
|
||||
kDefaultPairingWindowDurationMs;
|
||||
|
|
@ -370,23 +408,180 @@ int joycon_side(const uni_hid_device_t* device) {
|
|||
return device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : 0;
|
||||
}
|
||||
|
||||
bool joycon_default_pairing_allowed(const uni_hid_device_t* device) {
|
||||
const int index = physical_index_for_device(device);
|
||||
return g_joycon_mode == JoyConMode::kPaired && index >= 0 &&
|
||||
g_joycon_overrides[index].mode == JoyConGroupingOverride::Default;
|
||||
}
|
||||
|
||||
// Include the entire old attempt when another held solo makes selection
|
||||
// ambiguous. None of its members may retry until all have released.
|
||||
void block_joycon_gesture(uint8_t participants) {
|
||||
for (uint8_t pass = 0; pass < kSlotCount; ++pass) {
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
if (participants & (1u << index)) {
|
||||
participants |= g_joycon_gestures[index].participants;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
if (!(participants & (1u << index))) continue;
|
||||
g_joycon_gestures[index].participants = participants;
|
||||
g_joycon_gestures[index].blocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool joycon_gesture_live(uint8_t index) {
|
||||
const JoyConGesture& gesture = g_joycon_gestures[index];
|
||||
const int slot = slot_for_device(gesture.device);
|
||||
return gesture.device != nullptr && slot >= 0 && g_slots[slot].active &&
|
||||
physical_index_for_device(gesture.device) == index &&
|
||||
joycon_side(gesture.device) != 0;
|
||||
}
|
||||
|
||||
void arm_joycon_gesture(uint8_t left, uint8_t right, uint32_t now_ms,
|
||||
bool joining) {
|
||||
JoyConGesture& l = g_joycon_gestures[left];
|
||||
JoyConGesture& r = g_joycon_gestures[right];
|
||||
if (!l.held || !r.held || l.blocked || r.blocked ||
|
||||
l.participants != (1u << left) ||
|
||||
r.participants != (1u << right)) return;
|
||||
l.participants = r.participants = (1u << left) | (1u << right);
|
||||
l.started_ms = r.started_ms = now_ms;
|
||||
l.joining = r.joining = joining;
|
||||
}
|
||||
|
||||
// Caller holds the state lock. Reports perform admission; the timer also
|
||||
// expires attempts, but elapsed cached input alone can never complete a hold.
|
||||
void refresh_joycon_gestures(uint32_t now_ms) {
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
JoyConGesture& gesture = g_joycon_gestures[index];
|
||||
if (gesture.participants == 0 || gesture.blocked) continue;
|
||||
if (!joycon_gesture_live(index) || !gesture.held ||
|
||||
now_ms - gesture.last_report_ms > kJoyConGestureFreshMs) {
|
||||
block_joycon_gesture(gesture.participants);
|
||||
}
|
||||
}
|
||||
for (JoyConGesture& gesture : g_joycon_gestures) {
|
||||
if (gesture.participants == 0) continue;
|
||||
bool released = true;
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
if ((gesture.participants & (1u << index)) &&
|
||||
!g_joycon_gestures[index].released) released = false;
|
||||
}
|
||||
if (!released) continue;
|
||||
const uint8_t participants = gesture.participants;
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
if (!(participants & (1u << index))) continue;
|
||||
g_joycon_gestures[index].participants = 0;
|
||||
g_joycon_gestures[index].blocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
int left = -1;
|
||||
int right = -1;
|
||||
uint8_t solos = 0;
|
||||
bool ambiguous = false;
|
||||
for (const BackendSlot& slot : g_slots) {
|
||||
if (!slot.active || joycon_side(slot.device) == 0) continue;
|
||||
const int index = physical_index_for_device(slot.device);
|
||||
if (index < 0) continue;
|
||||
if (slot.companion != nullptr) {
|
||||
const int mate = physical_index_for_device(slot.companion);
|
||||
if (mate >= 0) arm_joycon_gesture(index, mate, now_ms, false);
|
||||
continue;
|
||||
}
|
||||
const JoyConGesture& gesture = g_joycon_gestures[index];
|
||||
if (gesture.device != slot.device || !gesture.held || gesture.blocked) continue;
|
||||
solos |= 1u << index;
|
||||
int& side = joycon_side(slot.device) < 0 ? left : right;
|
||||
ambiguous = ambiguous || side >= 0;
|
||||
side = index;
|
||||
}
|
||||
if (ambiguous) {
|
||||
block_joycon_gesture(solos);
|
||||
} else if (left >= 0 && right >= 0) {
|
||||
arm_joycon_gesture(left, right, now_ms, true);
|
||||
}
|
||||
}
|
||||
|
||||
void observe_joycon_gesture(uni_hid_device_t* device,
|
||||
const uni_gamepad_t& raw, uint32_t now_ms) {
|
||||
const int side = joycon_side(device);
|
||||
const int index = physical_index_for_device(device);
|
||||
if (side == 0 || index < 0) return;
|
||||
// Expire before updating the timestamp: a returning stale report must not
|
||||
// hide a gap, even when no timer ran during it.
|
||||
refresh_joycon_gestures(now_ms);
|
||||
JoyConGesture& gesture = g_joycon_gestures[index];
|
||||
gesture.device = device;
|
||||
const bool trigger = (raw.buttons &
|
||||
(side < 0 ? BUTTON_TRIGGER_L : BUTTON_TRIGGER_R)) != 0;
|
||||
const bool menu = (raw.misc_buttons &
|
||||
(side < 0 ? MISC_BUTTON_SELECT : MISC_BUTTON_START)) != 0;
|
||||
gesture.last_report_ms = now_ms;
|
||||
gesture.held = trigger && menu;
|
||||
gesture.released = !trigger && !menu;
|
||||
if (gesture.held) {
|
||||
gesture.masked = true;
|
||||
if (gesture.participants == 0) gesture.participants = 1u << index;
|
||||
} else if (gesture.released) {
|
||||
gesture.masked = false;
|
||||
}
|
||||
refresh_joycon_gestures(now_ms);
|
||||
}
|
||||
|
||||
bool joycon_gesture_masked(const uni_hid_device_t* device) {
|
||||
const int index = physical_index_for_device(device);
|
||||
return index >= 0 && g_joycon_gestures[index].device == device &&
|
||||
g_joycon_gestures[index].masked;
|
||||
}
|
||||
|
||||
void mask_joycon_gesture(uni_gamepad_t& gamepad,
|
||||
const uni_hid_device_t* device) {
|
||||
if (!joycon_gesture_masked(device)) return;
|
||||
if (joycon_side(device) < 0) {
|
||||
gamepad.buttons &= ~BUTTON_TRIGGER_L;
|
||||
gamepad.misc_buttons &= ~MISC_BUTTON_SELECT;
|
||||
gamepad.brake = 0;
|
||||
} else {
|
||||
gamepad.buttons &= ~BUTTON_TRIGGER_R;
|
||||
gamepad.misc_buttons &= ~MISC_BUTTON_START;
|
||||
gamepad.throttle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool waiting_for_joycon_mate(int side = 0) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
unsigned physical_count = 0;
|
||||
bool pending = false;
|
||||
bool solo_mate = false;
|
||||
unsigned left_count = 0;
|
||||
unsigned right_count = 0;
|
||||
for (const BackendSlot& slot : g_slots) {
|
||||
physical_count += slot.device != nullptr;
|
||||
physical_count += slot.companion != nullptr;
|
||||
pending = pending || (slot.device != nullptr && !slot.active);
|
||||
if (slot.active && slot.companion == nullptr) {
|
||||
// An explicit solo choice is complete, not a request for another
|
||||
// default-paired mate. Individual defaults retain balanced reconnects.
|
||||
if (slot.active && slot.companion == nullptr &&
|
||||
(g_joycon_mode == JoyConMode::kIndividual ||
|
||||
joycon_default_pairing_allowed(slot.device))) {
|
||||
const int candidate_side = joycon_side(slot.device);
|
||||
solo_mate = solo_mate ||
|
||||
(candidate_side != 0 && (side == 0 || candidate_side == -side));
|
||||
left_count += candidate_side < 0;
|
||||
right_count += candidate_side > 0;
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
return physical_count < kSlotCount && !pending && solo_mate;
|
||||
// Individual players still reconnect their remembered opposite half, but
|
||||
// a balanced set is complete even though no logical pair was created.
|
||||
const bool missing_left = g_joycon_mode == JoyConMode::kIndividual
|
||||
? right_count > left_count
|
||||
: right_count != 0;
|
||||
const bool missing_right = g_joycon_mode == JoyConMode::kIndividual
|
||||
? left_count > right_count
|
||||
: left_count != 0;
|
||||
return physical_count < kSlotCount && !pending &&
|
||||
((side <= 0 && missing_left) || (side >= 0 && missing_right));
|
||||
}
|
||||
|
||||
void stop_background_scan() {
|
||||
|
|
@ -696,6 +891,7 @@ ControllerIdentity identity_for_device(const uni_hid_device_t* device) {
|
|||
void publish_ble_identity(const BleIdentityMapping& mapping) {
|
||||
ControllerIdentity observed_identity{};
|
||||
bool observe_identity = false;
|
||||
bool joycon_identity_changed = false;
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
for (BackendSlot& slot : g_slots) {
|
||||
if (slot.device != nullptr && slot.companion == nullptr &&
|
||||
|
|
@ -704,7 +900,10 @@ void publish_ble_identity(const BleIdentityMapping& mapping) {
|
|||
slot.device->conn.handle == mapping.connection_handle &&
|
||||
addresses_equal(slot.device->conn.btaddr,
|
||||
mapping.connection_address)) {
|
||||
slot.identity = make_ble_identity(mapping, slot.device);
|
||||
const ControllerIdentity identity = make_ble_identity(mapping, slot.device);
|
||||
joycon_identity_changed = slot.active && joycon_side(slot.device) != 0 &&
|
||||
!controller_identity_equal(slot.identity, identity);
|
||||
slot.identity = identity;
|
||||
if (slot.active) {
|
||||
observed_identity = slot.identity;
|
||||
observe_identity = true;
|
||||
|
|
@ -715,6 +914,9 @@ void publish_ble_identity(const BleIdentityMapping& mapping) {
|
|||
if (observe_identity) {
|
||||
profile_service_observe_identity_on_storage_core(
|
||||
observed_identity);
|
||||
if (joycon_identity_changed && g_joycon_mode == JoyConMode::kPaired) {
|
||||
g_joycon_reconcile_requested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -911,6 +1113,11 @@ void publish_all_neutral() {
|
|||
++slot.state_generation;
|
||||
++slot.connection_generation;
|
||||
}
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
g_joycon_gestures[index] = {};
|
||||
g_joycon_overrides[index] = {};
|
||||
g_joycon_pair_hints[index] = {};
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
for (BleIdentityMapping& mapping : g_ble_identity_mappings) {
|
||||
mapping = {};
|
||||
|
|
@ -1235,14 +1442,18 @@ void rotate_solo_joycon(uni_gamepad_t& gamepad, int side,
|
|||
}
|
||||
uni_gamepad_t logical_gamepad(const BackendSlot& slot) {
|
||||
uni_gamepad_t gamepad = slot.gamepad;
|
||||
mask_joycon_gesture(gamepad, slot.device);
|
||||
if (slot.companion != nullptr) {
|
||||
const uni_gamepad_t& right = slot.companion_gamepad;
|
||||
gamepad.dpad |= right.dpad;
|
||||
gamepad.buttons |= right.buttons;
|
||||
gamepad.misc_buttons |= right.misc_buttons;
|
||||
const bool masked = joycon_gesture_masked(slot.companion);
|
||||
gamepad.buttons |=
|
||||
right.buttons & ~(masked ? uint32_t{BUTTON_TRIGGER_R} : 0u);
|
||||
gamepad.misc_buttons |=
|
||||
right.misc_buttons & ~(masked ? uint32_t{MISC_BUTTON_START} : 0u);
|
||||
gamepad.axis_rx = right.axis_rx;
|
||||
gamepad.axis_ry = right.axis_ry;
|
||||
gamepad.throttle = right.throttle;
|
||||
gamepad.throttle = masked ? 0 : right.throttle;
|
||||
// The right half is the sole aim source. A left report must not
|
||||
// republish an already consumed right-hand motion sample.
|
||||
memcpy(gamepad.accel, right.accel, sizeof(gamepad.accel));
|
||||
|
|
@ -1591,6 +1802,9 @@ void process_clear_pairings(uint32_t now_ms) {
|
|||
devices[device_count++] = slot.companion;
|
||||
}
|
||||
release_slot(slot);
|
||||
g_joycon_gestures[slot_index] = {};
|
||||
g_joycon_overrides[slot_index] = {};
|
||||
g_joycon_pair_hints[slot_index] = {};
|
||||
}
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
|
|
@ -1768,6 +1982,9 @@ void update_status_led() {
|
|||
}
|
||||
}
|
||||
|
||||
void apply_joycon_configuration(const ConfigurationServiceSnapshot& configuration);
|
||||
void process_joycon_gestures(uint32_t now_ms);
|
||||
|
||||
void process_configuration_timer(btstack_timer_source_t* timer) {
|
||||
__atomic_add_fetch(
|
||||
&g_configuration_timer_ticks, 1, __ATOMIC_RELAXED);
|
||||
|
|
@ -1777,9 +1994,10 @@ void process_configuration_timer(btstack_timer_source_t* timer) {
|
|||
apply_radio_connection_policy();
|
||||
configuration_service_task_on_storage_core(now_ms);
|
||||
profile_service_task_on_storage_core(now_ms);
|
||||
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
|
||||
ConfigurationServiceSnapshot configuration{};
|
||||
configuration_service_snapshot(&configuration);
|
||||
apply_joycon_configuration(configuration);
|
||||
#ifdef SWITCH_PICO_NATIVE_SWITCH_RUMBLE
|
||||
if (configuration.state == ConfigurationServiceState::kReady) {
|
||||
uint8_t previously_owned = 0;
|
||||
for (uint8_t i = 0; i < kSlotCount; ++i)
|
||||
|
|
@ -1893,10 +2111,12 @@ void seed_native_host_rumble() {
|
|||
|
||||
void process_rumble_timer(btstack_timer_source_t* timer) {
|
||||
__atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED);
|
||||
const uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
uint32_t now_ms = btstack_run_loop_get_time_ms();
|
||||
|
||||
process_clear_pairings(now_ms);
|
||||
process_pairing_snapshot_request();
|
||||
process_joycon_gestures(now_ms);
|
||||
now_ms = btstack_run_loop_get_time_ms();
|
||||
const bool wake_identity_ready =
|
||||
switch2_wake_ready_for_connections();
|
||||
if (g_connection_policy_state ==
|
||||
|
|
@ -2148,6 +2368,361 @@ void recompute_connection_status() {
|
|||
apply_connection_policy();
|
||||
}
|
||||
|
||||
void forget_joycon_pair_hint(uni_hid_device_t* device) {
|
||||
const int physical_index = physical_index_for_device(device);
|
||||
if (physical_index < 0) return;
|
||||
for (JoyConPairHint& hint : g_joycon_pair_hints) {
|
||||
if (hint.mate == device) hint = {};
|
||||
}
|
||||
g_joycon_pair_hints[physical_index] = {};
|
||||
}
|
||||
|
||||
// Replacing an explicit association does not revoke its former partner's solo
|
||||
// choice. Only the new current association participates in disconnect reset.
|
||||
void set_joycon_override(uni_hid_device_t* left, uni_hid_device_t* right,
|
||||
JoyConGroupingOverride mode) {
|
||||
uni_hid_device_t* devices[] = {left, right};
|
||||
for (uni_hid_device_t* device : devices) {
|
||||
for (JoyConConnectionOverride& current : g_joycon_overrides) {
|
||||
if (current.mate == device) current.mate = nullptr;
|
||||
}
|
||||
}
|
||||
g_joycon_overrides[physical_index_for_device(left)] = {mode, right};
|
||||
g_joycon_overrides[physical_index_for_device(right)] = {mode, left};
|
||||
}
|
||||
|
||||
// Caller holds the state lock; used for disconnect and physical index reuse.
|
||||
void reset_joycon_connection(uni_hid_device_t* device) {
|
||||
const int index = physical_index_for_device(device);
|
||||
if (index < 0) return;
|
||||
bool changed = g_joycon_overrides[index].mode !=
|
||||
JoyConGroupingOverride::Default;
|
||||
for (JoyConConnectionOverride& current : g_joycon_overrides) {
|
||||
if (current.mate == device) {
|
||||
current = {};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
g_joycon_overrides[index] = {};
|
||||
if (changed) g_joycon_reconcile_requested = true;
|
||||
block_joycon_gesture(g_joycon_gestures[index].participants);
|
||||
g_joycon_gestures[index] = {};
|
||||
for (JoyConGesture& gesture : g_joycon_gestures) {
|
||||
gesture.participants &= ~(1u << index);
|
||||
}
|
||||
forget_joycon_pair_hint(device);
|
||||
}
|
||||
|
||||
bool joycon_gesture_mature(uni_hid_device_t* first,
|
||||
uni_hid_device_t* second, bool joining,
|
||||
uint32_t now_ms) {
|
||||
const int first_index = physical_index_for_device(first);
|
||||
const int second_index = physical_index_for_device(second);
|
||||
if (first_index < 0 || second_index < 0 || first_index == second_index ||
|
||||
joycon_side(first) == 0 || joycon_side(first) != -joycon_side(second)) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t participants = (1u << first_index) | (1u << second_index);
|
||||
const JoyConGesture& a = g_joycon_gestures[first_index];
|
||||
const JoyConGesture& b = g_joycon_gestures[second_index];
|
||||
if (a.device != first || b.device != second ||
|
||||
a.started_ms != b.started_ms) return false;
|
||||
const int indices[] = {first_index, second_index};
|
||||
for (int index : indices) {
|
||||
const JoyConGesture& gesture = g_joycon_gestures[index];
|
||||
if (!joycon_gesture_live(index) ||
|
||||
gesture.participants != participants ||
|
||||
gesture.joining != joining || !gesture.held ||
|
||||
now_ms - gesture.last_report_ms > kJoyConGestureFreshMs ||
|
||||
static_cast<int32_t>(gesture.last_report_ms - gesture.started_ms) <
|
||||
static_cast<int32_t>(kJoyConGestureHoldMs)) return false;
|
||||
}
|
||||
const int a_slot = slot_for_device(first);
|
||||
const int b_slot = slot_for_device(second);
|
||||
return joining
|
||||
? a_slot != b_slot && g_slots[a_slot].companion == nullptr &&
|
||||
g_slots[b_slot].companion == nullptr
|
||||
: a_slot == b_slot && g_slots[a_slot].companion != nullptr;
|
||||
}
|
||||
|
||||
// Caller holds the state lock. Prefer the last live pair's exact members,
|
||||
// otherwise preserve the existing first-ready / lowest-slot admission order.
|
||||
int joycon_partner_slot(uni_hid_device_t* device, int slot_index) {
|
||||
const int side = joycon_side(device);
|
||||
if (side == 0 || !joycon_default_pairing_allowed(device)) return -1;
|
||||
const auto& hint = g_joycon_pair_hints[physical_index_for_device(device)];
|
||||
int first = -1;
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
const BackendSlot& candidate = g_slots[index];
|
||||
if (index == slot_index || !candidate.active ||
|
||||
candidate.companion != nullptr ||
|
||||
!joycon_default_pairing_allowed(candidate.device) ||
|
||||
joycon_side(candidate.device) != -side) continue;
|
||||
if (candidate.device == hint.mate) return index;
|
||||
const auto& candidate_hint =
|
||||
g_joycon_pair_hints[physical_index_for_device(candidate.device)];
|
||||
if (candidate_hint.mate != nullptr && candidate_hint.mate != device) continue;
|
||||
if (first < 0) first = index;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
void stop_joycon_output(uni_hid_device_t* device) {
|
||||
if (device->report_parser.play_dual_rumble != nullptr) {
|
||||
dispatch_rumble(device, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Core 1 only; shared by ready admission, saved defaults and explicit gestures.
|
||||
// Pair enrollment is atomic and idempotent, and always precedes topology
|
||||
// changes with no cross-core input lock held during storage I/O.
|
||||
bool merge_joycon_slots(int owner_index, int joining_index,
|
||||
uni_hid_device_t* joining_device,
|
||||
bool gesture = false) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& owner = g_slots[owner_index];
|
||||
BackendSlot& joining = g_slots[joining_index];
|
||||
uni_hid_device_t* const owner_device = owner.device;
|
||||
const uint32_t owner_generation = owner.connection_generation;
|
||||
const uint32_t joining_generation = joining.connection_generation;
|
||||
const bool joining_active = joining.active;
|
||||
const int side = joycon_side(joining_device);
|
||||
const bool admission = gesture
|
||||
? joining.active && joycon_gesture_mature(
|
||||
owner_device, joining_device, true, btstack_run_loop_get_time_ms())
|
||||
: joycon_default_pairing_allowed(owner_device) &&
|
||||
joycon_default_pairing_allowed(joining_device);
|
||||
const bool eligible = admission &&
|
||||
owner.active && owner.companion == nullptr &&
|
||||
joining.companion == nullptr && side != 0 &&
|
||||
joycon_side(owner_device) == -side &&
|
||||
reserve_device_slot(joining_device) == joining_index;
|
||||
const ControllerIdentity owner_identity = identity_for_device(owner_device);
|
||||
const ControllerIdentity joining_identity = identity_for_device(joining_device);
|
||||
critical_section_exit(&g_state_lock);
|
||||
ControllerIdentity pair_identity{};
|
||||
if (!eligible ||
|
||||
!controller_identity_make_joycon_pair(
|
||||
side < 0 ? joining_identity : owner_identity,
|
||||
side < 0 ? owner_identity : joining_identity, &pair_identity) ||
|
||||
!profile_service_observe_joycon_pair_on_storage_core(pair_identity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const bool still_admitted = gesture
|
||||
? joycon_gesture_mature(
|
||||
owner_device, joining_device, true, btstack_run_loop_get_time_ms())
|
||||
: joycon_default_pairing_allowed(owner_device) &&
|
||||
joycon_default_pairing_allowed(joining_device);
|
||||
if (!still_admitted ||
|
||||
!owner.active || owner.device != owner_device ||
|
||||
owner.companion != nullptr ||
|
||||
owner.connection_generation != owner_generation ||
|
||||
joining.active != joining_active || joining.companion != nullptr ||
|
||||
joining.connection_generation != joining_generation ||
|
||||
reserve_device_slot(joining_device) != joining_index) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return false;
|
||||
}
|
||||
invalidate_slot(owner);
|
||||
invalidate_slot(joining);
|
||||
// Clear both parser epochs and local motor feedback before publishing
|
||||
// either the new pair or its neutral retired output.
|
||||
stop_joycon_output(owner_device);
|
||||
stop_joycon_output(joining_device);
|
||||
if (side < 0) {
|
||||
owner.companion = owner.device;
|
||||
owner.companion_gamepad = owner.gamepad;
|
||||
owner.companion_extra_buttons = owner.extra_buttons;
|
||||
owner.device = joining_device;
|
||||
owner.gamepad = joining.gamepad;
|
||||
owner.extra_buttons = joining.extra_buttons;
|
||||
} else {
|
||||
owner.companion = joining_device;
|
||||
owner.companion_gamepad = joining.gamepad;
|
||||
owner.companion_extra_buttons = joining.extra_buttons;
|
||||
}
|
||||
owner.identity = pair_identity;
|
||||
refresh_topology_input(owner);
|
||||
joining.identity = controller_identity_global();
|
||||
joining.device = nullptr;
|
||||
joining.gamepad = {};
|
||||
joining.extra_buttons = 0;
|
||||
joining.active = false;
|
||||
forget_joycon_pair_hint(owner.device);
|
||||
forget_joycon_pair_hint(owner.companion);
|
||||
if (gesture) {
|
||||
set_joycon_override(owner.device, owner.companion,
|
||||
JoyConGroupingOverride::Paired);
|
||||
queue_local_feedback(owner, kJoyConGestureFeedbackMs,
|
||||
kProfileFeedbackWeakMagnitude,
|
||||
kProfileFeedbackStrongMagnitude);
|
||||
}
|
||||
g_joycon_pair_hints[physical_index_for_device(owner.device)] =
|
||||
{owner.companion, static_cast<uint8_t>(owner_index)};
|
||||
g_joycon_pair_hints[physical_index_for_device(owner.companion)] =
|
||||
{owner.device, static_cast<uint8_t>(owner_index)};
|
||||
critical_section_exit(&g_state_lock);
|
||||
apply_slot_lighting(static_cast<uint8_t>(owner_index), owner.device);
|
||||
apply_slot_lighting(static_cast<uint8_t>(owner_index), owner.companion);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool split_joycon_slot(uint8_t owner_index, bool gesture = false) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& owner = g_slots[owner_index];
|
||||
const int physical_index = physical_index_for_device(owner.device);
|
||||
if (!owner.active || owner.companion == nullptr || physical_index < 0 ||
|
||||
(!gesture && g_joycon_overrides[physical_index].mode ==
|
||||
JoyConGroupingOverride::Paired) ||
|
||||
(gesture && !joycon_gesture_mature(
|
||||
owner.device, owner.companion, false,
|
||||
btstack_run_loop_get_time_ms()))) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return false;
|
||||
}
|
||||
// Keep the pair's left member at its existing player index. Prefer the
|
||||
// right member's physical index, falling back to the lowest free output.
|
||||
int right_index = physical_index_for_device(owner.companion);
|
||||
if (right_index < 0 || g_slots[right_index].device != nullptr) {
|
||||
right_index = -1;
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
if (g_slots[index].device == nullptr) {
|
||||
right_index = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (right_index < 0) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return false;
|
||||
}
|
||||
BackendSlot& right = g_slots[right_index];
|
||||
ControllerIdentity left_identity{};
|
||||
ControllerIdentity right_identity{};
|
||||
// The enrolled owner is authoritative even if a later identity-resolution
|
||||
// event has temporarily cleared a member's transport mapping.
|
||||
if (!controller_identity_joycon_pair_members(
|
||||
owner.identity, &left_identity, &right_identity)) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return false;
|
||||
}
|
||||
invalidate_slot(owner);
|
||||
invalidate_slot(right);
|
||||
stop_joycon_output(owner.device);
|
||||
stop_joycon_output(owner.companion);
|
||||
right.device = owner.companion;
|
||||
right.identity = right_identity;
|
||||
right.gamepad = owner.companion_gamepad;
|
||||
right.extra_buttons = owner.companion_extra_buttons;
|
||||
right.active = true;
|
||||
owner.identity = left_identity;
|
||||
owner.companion = nullptr;
|
||||
owner.companion_gamepad = {};
|
||||
owner.companion_extra_buttons = 0;
|
||||
refresh_topology_input(owner);
|
||||
refresh_topology_input(right);
|
||||
if (gesture) {
|
||||
set_joycon_override(owner.device, right.device,
|
||||
JoyConGroupingOverride::Individual);
|
||||
queue_local_feedback(owner, kJoyConGestureFeedbackMs,
|
||||
kProfileFeedbackWeakMagnitude,
|
||||
kProfileFeedbackStrongMagnitude);
|
||||
queue_local_feedback(right, kJoyConGestureFeedbackMs,
|
||||
kProfileFeedbackWeakMagnitude,
|
||||
kProfileFeedbackStrongMagnitude);
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
apply_slot_lighting(owner_index, owner.device);
|
||||
apply_slot_lighting(static_cast<uint8_t>(right_index), right.device);
|
||||
return true;
|
||||
}
|
||||
|
||||
void process_joycon_gestures(uint32_t now_ms) {
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
now_ms = btstack_run_loop_get_time_ms();
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
refresh_joycon_gestures(now_ms);
|
||||
const JoyConGesture& gesture = g_joycon_gestures[index];
|
||||
if (gesture.blocked || joycon_side(gesture.device) >= 0) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
continue;
|
||||
}
|
||||
uni_hid_device_t* right = nullptr;
|
||||
for (uint8_t mate = 0; mate < kSlotCount; ++mate) {
|
||||
if (mate != index && (gesture.participants & (1u << mate))) {
|
||||
right = g_joycon_gestures[mate].device;
|
||||
}
|
||||
}
|
||||
const bool joining = gesture.joining;
|
||||
const int owner_slot = slot_for_device(gesture.device);
|
||||
const int right_slot = slot_for_device(right);
|
||||
const bool mature = joycon_gesture_mature(
|
||||
gesture.device, right, joining, now_ms);
|
||||
if (mature) block_joycon_gesture(gesture.participants);
|
||||
critical_section_exit(&g_state_lock);
|
||||
if (!mature) continue;
|
||||
// Latch success AND failure before any enrollment I/O. A failed seed
|
||||
// must not retry at the timer cadence or undo either participant.
|
||||
const bool changed = joining
|
||||
? merge_joycon_slots(owner_slot, right_slot, right, true)
|
||||
: split_joycon_slot(static_cast<uint8_t>(owner_slot), true);
|
||||
if (changed) recompute_connection_status();
|
||||
}
|
||||
}
|
||||
|
||||
void apply_joycon_configuration(const ConfigurationServiceSnapshot& configuration) {
|
||||
if (configuration.state != ConfigurationServiceState::kReady) return;
|
||||
const JoyConMode requested = configuration.configuration.joycon_mode;
|
||||
if (requested != g_joycon_mode) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
g_joycon_overrides[index] = {};
|
||||
block_joycon_gesture(g_joycon_gestures[index].participants);
|
||||
}
|
||||
g_joycon_mode = requested;
|
||||
g_joycon_reconcile_requested = true;
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
if (!g_joycon_reconcile_requested) return;
|
||||
g_joycon_reconcile_requested = false;
|
||||
if (g_joycon_mode == JoyConMode::kIndividual) {
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
split_joycon_slot(index);
|
||||
}
|
||||
} else {
|
||||
uint8_t attempted = 0;
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
const BackendSlot& slot = g_slots[index];
|
||||
int partner = slot.active && slot.companion == nullptr &&
|
||||
!(attempted & (1u << index))
|
||||
? joycon_partner_slot(slot.device, index) : -1;
|
||||
if (partner < 0 || (attempted & (1u << partner))) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
continue;
|
||||
}
|
||||
int owner = index;
|
||||
int joining = partner;
|
||||
const auto& hint =
|
||||
g_joycon_pair_hints[physical_index_for_device(slot.device)];
|
||||
if (hint.mate == g_slots[partner].device && hint.owner_slot == partner) {
|
||||
owner = partner;
|
||||
joining = index;
|
||||
}
|
||||
uni_hid_device_t* joining_device = g_slots[joining].device;
|
||||
attempted |= (1u << index) | (1u << partner);
|
||||
critical_section_exit(&g_state_lock);
|
||||
// Failed seeds/invalid identities leave both live solos intact.
|
||||
// Retry only on a mode change or fresh identity/ready event, never
|
||||
// at the 50 ms poll cadence or for unrelated configuration edits.
|
||||
merge_joycon_slots(owner, joining, joining_device);
|
||||
}
|
||||
}
|
||||
recompute_connection_status();
|
||||
}
|
||||
|
||||
void platform_init(int argc, const char** argv) {
|
||||
(void)argc;
|
||||
(void)argv;
|
||||
|
|
@ -2173,6 +2748,9 @@ void platform_on_init_complete() {
|
|||
&g_configuration_timer, kConfigurationPollIntervalMs);
|
||||
btstack_run_loop_add_timer(&g_configuration_timer);
|
||||
__atomic_store_n(&g_initialization_stage, 6, __ATOMIC_RELEASE);
|
||||
ConfigurationServiceSnapshot configuration{};
|
||||
configuration_service_snapshot(&configuration);
|
||||
apply_joycon_configuration(configuration);
|
||||
if (switch2_wake_ready_for_connections()) {
|
||||
recompute_connection_status();
|
||||
}
|
||||
|
|
@ -2219,6 +2797,7 @@ void platform_on_device_connected(uni_hid_device_t* device) {
|
|||
if (physical_index >= 0) {
|
||||
g_retired_devices[physical_index] = nullptr;
|
||||
g_switch2_interval_requests[physical_index] = {};
|
||||
if (slot_for_device(device) < 0) reset_joycon_connection(device);
|
||||
}
|
||||
const int slot_index = reserve_device_slot(device);
|
||||
if (slot_index >= 0) {
|
||||
|
|
@ -2254,6 +2833,7 @@ void platform_on_device_disconnected(uni_hid_device_t* device) {
|
|||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
g_retired_devices[physical_index_for_device(device)] = device;
|
||||
reset_joycon_connection(device);
|
||||
if (slot.companion != nullptr) {
|
||||
invalidate_slot(slot);
|
||||
if (slot.device == device) {
|
||||
|
|
@ -2311,68 +2891,13 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
|
|||
}
|
||||
BackendSlot& pending = g_slots[slot_index];
|
||||
if (!pending.active) {
|
||||
const int side = joycon_side(device);
|
||||
int partner_index = -1;
|
||||
if (side != 0) {
|
||||
for (uint8_t index = 0; index < kSlotCount; ++index) {
|
||||
const BackendSlot& candidate = g_slots[index];
|
||||
if (index != slot_index && candidate.active &&
|
||||
candidate.companion == nullptr &&
|
||||
joycon_side(candidate.device) == -side) {
|
||||
partner_index = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const int partner_index = joycon_partner_slot(device, slot_index);
|
||||
if (partner_index >= 0) {
|
||||
BackendSlot& partner = g_slots[partner_index];
|
||||
const uint32_t partner_generation = partner.connection_generation;
|
||||
const uint32_t pending_generation = pending.connection_generation;
|
||||
uni_hid_device_t* const partner_device = partner.device;
|
||||
const ControllerIdentity partner_identity =
|
||||
identity_for_device(partner_device);
|
||||
critical_section_exit(&g_state_lock);
|
||||
|
||||
ControllerIdentity pair_identity{};
|
||||
if (!controller_identity_make_joycon_pair(
|
||||
side < 0 ? connection_identity : partner_identity,
|
||||
side < 0 ? partner_identity : connection_identity,
|
||||
&pair_identity) ||
|
||||
!profile_service_observe_joycon_pair_on_storage_core(
|
||||
pair_identity)) {
|
||||
if (!merge_joycon_slots(partner_index, slot_index, device)) {
|
||||
return UNI_ERROR_INIT_FAILED;
|
||||
}
|
||||
|
||||
// Storage can publish the complete bank without blocking Core 0.
|
||||
// Only then replace the first-ready solo's topology and generation.
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
if (!partner.active || partner.device != partner_device ||
|
||||
partner.companion != nullptr ||
|
||||
partner.connection_generation != partner_generation ||
|
||||
pending.active ||
|
||||
pending.connection_generation != pending_generation ||
|
||||
reserve_device_slot(device) != slot_index) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return UNI_ERROR_INIT_FAILED;
|
||||
}
|
||||
invalidate_slot(partner);
|
||||
if (side < 0) {
|
||||
partner.companion = partner.device;
|
||||
partner.companion_gamepad = partner.gamepad;
|
||||
partner.companion_extra_buttons = partner.extra_buttons;
|
||||
partner.device = device;
|
||||
partner.gamepad = {};
|
||||
partner.extra_buttons = 0;
|
||||
} else {
|
||||
partner.companion = device;
|
||||
partner.companion_gamepad = {};
|
||||
partner.companion_extra_buttons = 0;
|
||||
}
|
||||
// Preserve the existing player's output index regardless of
|
||||
// physical connection order, with a separately seeded pair owner.
|
||||
partner.identity = pair_identity;
|
||||
refresh_topology_input(partner);
|
||||
release_slot(pending);
|
||||
slot_index = partner_index;
|
||||
paired = true;
|
||||
} else {
|
||||
|
|
@ -2413,15 +2938,7 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
|
|||
}
|
||||
#endif
|
||||
#endif
|
||||
if (paired) {
|
||||
uni_hid_device_t* halves[] = {owner, companion};
|
||||
for (uni_hid_device_t* half : halves) {
|
||||
if (half->report_parser.play_dual_rumble != nullptr) {
|
||||
dispatch_rumble(half, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lighting_target_is_current(
|
||||
if (!paired && lighting_target_is_current(
|
||||
static_cast<uint8_t>(slot_index), lighting_generation, owner)) {
|
||||
apply_slot_lighting(static_cast<uint8_t>(slot_index), owner);
|
||||
if (companion != nullptr) {
|
||||
|
|
@ -2432,6 +2949,10 @@ uni_error_t platform_on_device_ready(uni_hid_device_t* device) {
|
|||
profile_service_observe_identity_on_storage_core(
|
||||
connection_identity);
|
||||
}
|
||||
if (!paired && joycon_side(device) != 0 &&
|
||||
g_joycon_mode == JoyConMode::kPaired) {
|
||||
g_joycon_reconcile_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2462,6 +2983,8 @@ void platform_on_controller_data(uni_hid_device_t* device,
|
|||
slot.gamepad = controller->gamepad;
|
||||
slot.extra_buttons = extras;
|
||||
}
|
||||
observe_joycon_gesture(
|
||||
device, controller->gamepad, btstack_run_loop_get_time_ms());
|
||||
const uni_gamepad_t gamepad = logical_gamepad(slot);
|
||||
uni_hid_device_t* owner = slot.device;
|
||||
const bool fresh_motion =
|
||||
|
|
@ -2526,6 +3049,12 @@ uni_platform* get_platform() {
|
|||
__atomic_store_n(&g_initialization_stage, 2, __ATOMIC_RELEASE);
|
||||
configuration_service_initialize_on_storage_core();
|
||||
profile_service_initialize_on_storage_core();
|
||||
ConfigurationServiceSnapshot configuration{};
|
||||
configuration_service_snapshot(&configuration);
|
||||
if (configuration.state == ConfigurationServiceState::kReady) {
|
||||
// Load before uni_init can deliver even the first ready callback.
|
||||
g_joycon_mode = configuration.configuration.joycon_mode;
|
||||
}
|
||||
__atomic_store_n(&g_initialization_stage, 3, __ATOMIC_RELEASE);
|
||||
if (cyw43_arch_init() != 0) {
|
||||
halt_wireless_backend();
|
||||
|
|
@ -2656,7 +3185,12 @@ void bluepad32_input_backend_init() {
|
|||
g_consumed_generation[slot_index] = 0;
|
||||
g_last_snapshot_generation[slot_index] = 0;
|
||||
g_ble_identity_mappings[slot_index] = {};
|
||||
g_joycon_pair_hints[slot_index] = {};
|
||||
g_joycon_gestures[slot_index] = {};
|
||||
g_joycon_overrides[slot_index] = {};
|
||||
}
|
||||
g_joycon_mode = JoyConMode::kPaired;
|
||||
g_joycon_reconcile_requested = false;
|
||||
g_pairing_window_requested = false;
|
||||
g_pairing_snapshot_requested = false;
|
||||
g_pairing_snapshot = {};
|
||||
|
|
|
|||
|
|
@ -99,7 +99,10 @@ STATUS_NAMES = {
|
|||
8: "storage failure",
|
||||
}
|
||||
|
||||
CONFIGURATION_SCHEMA_VERSION = 3
|
||||
CONFIGURATION_SCHEMA_VERSION = 4
|
||||
JOYCON_MODE_PAIRED = 0
|
||||
JOYCON_MODE_INDIVIDUAL = 1
|
||||
JOYCON_MODE_NAMES = ("paired", "individual")
|
||||
CONFIGURATION_SIZE = 232
|
||||
NATIVE_SWITCH_CONTROLLER_CAPACITY = 16
|
||||
NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE = (
|
||||
|
|
@ -604,6 +607,7 @@ class AdapterConfiguration:
|
|||
requested_mode: int = REQUESTED_MODE_AUTO
|
||||
native_switch_controllers: tuple[ControllerIdentity, ...] = ()
|
||||
schema_version: int = CONFIGURATION_SCHEMA_VERSION
|
||||
joycon_mode: int = JOYCON_MODE_PAIRED
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -3344,6 +3348,7 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration:
|
|||
_raise_status(envelope)
|
||||
payload = envelope.payload
|
||||
identities: tuple[ControllerIdentity, ...] = ()
|
||||
joycon_mode = JOYCON_MODE_PAIRED
|
||||
if envelope.schema_version == 1:
|
||||
if len(payload) != 4 or payload[2:] != bytes(2):
|
||||
raise ConfigManagerError("unsupported configuration object")
|
||||
|
|
@ -3352,14 +3357,15 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration:
|
|||
if len(payload) != 8 or payload[3:] != bytes(5):
|
||||
raise ConfigManagerError("unsupported configuration object")
|
||||
requested_mode = payload[2]
|
||||
elif envelope.schema_version == CONFIGURATION_SCHEMA_VERSION:
|
||||
elif envelope.schema_version in (3, CONFIGURATION_SCHEMA_VERSION):
|
||||
if len(payload) != CONFIGURATION_SIZE:
|
||||
raise ConfigManagerError("unsupported configuration object")
|
||||
count = payload[3]
|
||||
end = 8 + count * CONTROLLER_IDENTITY_SIZE
|
||||
if (
|
||||
count > NATIVE_SWITCH_CONTROLLER_CAPACITY
|
||||
or payload[4:8] != bytes(4)
|
||||
or payload[5:8] != bytes(3)
|
||||
or (envelope.schema_version == 3 and payload[4] != 0)
|
||||
or payload[end:] != bytes(CONFIGURATION_SIZE - end)
|
||||
):
|
||||
raise ConfigManagerError("invalid native rumble approval encoding")
|
||||
|
|
@ -3372,6 +3378,8 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration:
|
|||
if identities != _canonical_native_switch_controllers(identities):
|
||||
raise ConfigManagerError("noncanonical native rumble approval order")
|
||||
requested_mode = payload[2]
|
||||
if envelope.schema_version == CONFIGURATION_SCHEMA_VERSION:
|
||||
joycon_mode = payload[4]
|
||||
else:
|
||||
raise ConfigManagerError("unsupported configuration object")
|
||||
pairing_window_seconds = struct.unpack_from("<H", payload)[0]
|
||||
|
|
@ -3383,6 +3391,8 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration:
|
|||
raise ConfigManagerError("invalid stored pairing-window duration")
|
||||
if requested_mode >= len(REQUESTED_MODE_NAMES):
|
||||
raise ConfigManagerError(f"invalid stored requested USB mode {requested_mode}")
|
||||
if joycon_mode >= len(JOYCON_MODE_NAMES):
|
||||
raise ConfigManagerError(f"invalid stored Joy-Con2 mode {joycon_mode}")
|
||||
return AdapterConfiguration(
|
||||
pairing_window_seconds=pairing_window_seconds,
|
||||
generation=envelope.generation,
|
||||
|
|
@ -3390,6 +3400,7 @@ def read_configuration(device: UsbDevice) -> AdapterConfiguration:
|
|||
requested_mode=requested_mode,
|
||||
native_switch_controllers=identities,
|
||||
schema_version=envelope.schema_version,
|
||||
joycon_mode=joycon_mode,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3429,16 +3440,23 @@ def write_configuration(
|
|||
configuration.requested_mode
|
||||
) is not int or not 0 <= configuration.requested_mode < len(REQUESTED_MODE_NAMES):
|
||||
raise ConfigManagerError("invalid requested USB mode")
|
||||
_require_int(configuration.joycon_mode, "Joy-Con2 mode", 0, 1)
|
||||
if (
|
||||
configuration.schema_version < CONFIGURATION_SCHEMA_VERSION
|
||||
and configuration.joycon_mode != JOYCON_MODE_PAIRED
|
||||
):
|
||||
raise ConfigManagerError("Joy-Con2 player mode requires schema 4 firmware")
|
||||
identities = _canonical_native_switch_controllers(
|
||||
configuration.native_switch_controllers
|
||||
)
|
||||
if configuration.schema_version == CONFIGURATION_SCHEMA_VERSION:
|
||||
if configuration.schema_version in (3, CONFIGURATION_SCHEMA_VERSION):
|
||||
payload = (
|
||||
struct.pack(
|
||||
"<HBB4x",
|
||||
"<HBBB3x",
|
||||
configuration.pairing_window_seconds,
|
||||
configuration.requested_mode,
|
||||
len(identities),
|
||||
configuration.joycon_mode,
|
||||
)
|
||||
+ b"".join(identity.to_bytes() for identity in identities)
|
||||
+ bytes(
|
||||
|
|
@ -3488,6 +3506,17 @@ def write_configuration(
|
|||
return _wait_for_transaction(device, transaction_id, timeout)
|
||||
|
||||
|
||||
def set_joycon_mode(
|
||||
device: UsbDevice, mode: int, timeout: float
|
||||
) -> TransactionStatus:
|
||||
"""Persist the adapter-wide player mode without rebooting or changing profiles."""
|
||||
_require_int(mode, "Joy-Con2 mode", 0, 1)
|
||||
before = read_configuration(device)
|
||||
if before.schema_version < CONFIGURATION_SCHEMA_VERSION:
|
||||
raise ConfigManagerError("Joy-Con2 player mode requires schema 4 firmware")
|
||||
return write_configuration(device, replace(before, joycon_mode=mode), timeout)
|
||||
|
||||
|
||||
def set_native_switch_rumble_approval(
|
||||
device: UsbDevice,
|
||||
identity: ControllerIdentity,
|
||||
|
|
@ -3498,7 +3527,7 @@ def set_native_switch_rumble_approval(
|
|||
_require_bool(approved, "native rumble approval")
|
||||
_canonical_native_switch_controllers((identity,))
|
||||
before = read_configuration(device)
|
||||
if before.schema_version != CONFIGURATION_SCHEMA_VERSION:
|
||||
if before.schema_version < 3:
|
||||
raise ConfigManagerError("native rumble approval requires schema 3 firmware")
|
||||
identities = tuple(
|
||||
item for item in before.native_switch_controllers if item != identity
|
||||
|
|
@ -4414,6 +4443,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
reboot.add_argument("target", choices=("bootsel",))
|
||||
mode = commands.add_parser("mode", help="select the persistent USB mode")
|
||||
mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES)
|
||||
joycon_mode = commands.add_parser(
|
||||
"joycon-mode",
|
||||
help="read or apply the persistent Joy-Con2 player mode without reconnecting",
|
||||
)
|
||||
joycon_mode.add_argument("mode", nargs="?", choices=JOYCON_MODE_NAMES)
|
||||
joycon_mode.add_argument(
|
||||
"--json", action="store_true", help="emit the committed mode as JSON"
|
||||
)
|
||||
|
||||
config = commands.add_parser("config", help="read or change configuration")
|
||||
config_commands = config.add_subparsers(dest="config_command", required=True)
|
||||
|
|
@ -4594,6 +4631,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
print(f"Configuration generation: {configuration.generation}")
|
||||
print(f"Configuration CRC: {configuration.crc:08x}")
|
||||
print(f"Pairing window: {configuration.pairing_window_seconds} seconds")
|
||||
print(f"Joy-Con2 player mode: {JOYCON_MODE_NAMES[configuration.joycon_mode]}")
|
||||
elif args.command == "diagnostics":
|
||||
diagnostics = read_runtime_diagnostics(device)
|
||||
print(f"Initialization stage: {diagnostics.initialization_stage}")
|
||||
|
|
@ -4622,6 +4660,27 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
print(f"USB mode changed to {args.mode}.")
|
||||
else:
|
||||
print(f"USB mode is already {args.mode}.")
|
||||
elif args.command == "joycon-mode":
|
||||
if args.mode is not None:
|
||||
set_joycon_mode(device, JOYCON_MODE_NAMES.index(args.mode), args.timeout)
|
||||
configuration = read_configuration(device)
|
||||
mode_name = JOYCON_MODE_NAMES[configuration.joycon_mode]
|
||||
supported = configuration.schema_version >= CONFIGURATION_SCHEMA_VERSION
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"mode": mode_name,
|
||||
"generation": configuration.generation,
|
||||
"supported": supported,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"Joy-Con2 player mode: {mode_name}")
|
||||
print(f"Configuration generation: {configuration.generation}")
|
||||
if not supported:
|
||||
print("Changing Joy-Con2 player mode requires schema 4 firmware.")
|
||||
elif args.command == "config":
|
||||
if args.config_command == "show":
|
||||
configuration = read_configuration(device)
|
||||
|
|
@ -4630,6 +4689,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
"requested_mode="
|
||||
f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}"
|
||||
)
|
||||
print(f"joycon_mode={JOYCON_MODE_NAMES[configuration.joycon_mode]}")
|
||||
print(f"generation={configuration.generation}")
|
||||
print(f"crc={configuration.crc:08x}")
|
||||
for identity in configuration.native_switch_controllers:
|
||||
|
|
@ -4663,7 +4723,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
elif args.native_rumble_command == "list":
|
||||
configuration = read_configuration(device)
|
||||
print(NATIVE_SWITCH_RUMBLE_APPROVAL_NOTE)
|
||||
if configuration.schema_version != CONFIGURATION_SCHEMA_VERSION:
|
||||
if configuration.schema_version < 3:
|
||||
print("Native rumble approval requires schema 3 firmware.")
|
||||
_print_profiles(entries, physical_only=True)
|
||||
for index, entry in enumerate(entries):
|
||||
|
|
|
|||
|
|
@ -357,6 +357,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
|
|||
if path == "/api/profiles":
|
||||
self._api_call(self._list_profiles)
|
||||
return
|
||||
if path == "/api/joycon-mode":
|
||||
self._api_call(self._read_joycon_mode)
|
||||
return
|
||||
capture = self._parse_capture_path(path)
|
||||
if capture is not None:
|
||||
identity_index, profile_index, action = capture
|
||||
|
|
@ -385,6 +388,9 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
|
|||
if not self._allow_mutation():
|
||||
return
|
||||
path = urlsplit(self.path).path
|
||||
if path == "/api/joycon-mode":
|
||||
self._api_call(self._set_joycon_mode)
|
||||
return
|
||||
identity_selection = self._parse_identity_path(path)
|
||||
if identity_selection is not None:
|
||||
identity_index, action = identity_selection
|
||||
|
|
@ -869,6 +875,40 @@ class ProfileEditorHandler(BaseHTTPRequestHandler):
|
|||
)
|
||||
return value
|
||||
|
||||
def _read_joycon_mode(self) -> dict[str, Any]:
|
||||
configuration = config_manager.read_configuration(
|
||||
self.profile_server.find_device()
|
||||
)
|
||||
supported = configuration.schema_version >= 4
|
||||
mode = (
|
||||
configuration.joycon_mode
|
||||
if supported
|
||||
else config_manager.JOYCON_MODE_PAIRED
|
||||
)
|
||||
return {
|
||||
"mode": config_manager.JOYCON_MODE_NAMES[mode],
|
||||
"generation": configuration.generation,
|
||||
"supported": supported,
|
||||
}
|
||||
|
||||
def _set_joycon_mode(self) -> dict[str, Any]:
|
||||
body = self._read_json_object()
|
||||
mode = body.get("mode")
|
||||
if (
|
||||
set(body) != {"mode"}
|
||||
or not isinstance(mode, str)
|
||||
or mode not in config_manager.JOYCON_MODE_NAMES
|
||||
):
|
||||
raise config_manager.ConfigManagerError(
|
||||
"request must contain only mode: paired or individual"
|
||||
)
|
||||
config_manager.set_joycon_mode(
|
||||
self.profile_server.find_device(),
|
||||
config_manager.JOYCON_MODE_NAMES.index(mode),
|
||||
self.profile_server.operation_timeout,
|
||||
)
|
||||
return self._read_joycon_mode()
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -186,6 +186,19 @@ h4 { font-size: 1rem; }
|
|||
.select:focus, .number-input:focus { border-color: var(--sky); box-shadow: 0 0 0 4px rgba(89, 199, 255, 0.1); }
|
||||
.select:disabled, .number-input:disabled { cursor: not-allowed; opacity: 0.45; border-color: var(--line-soft); box-shadow: none; }
|
||||
.select option:disabled { color: var(--subtle); }
|
||||
.joycon-mode { margin-top: 22px; padding: 16px 0; border-block: 1px solid var(--line); }
|
||||
.joycon-mode h3 { font-size: 1rem; }
|
||||
.joycon-mode .field-label { margin-top: 14px; }
|
||||
.joycon-mode-help { margin: 12px 0; color: var(--muted); font-size: 0.73rem; line-height: 1.5; }
|
||||
.joycon-mode-help summary { color: var(--blue); cursor: pointer; }
|
||||
.joycon-mode-help summary:focus-visible { outline: 3px solid var(--sky); outline-offset: 3px; }
|
||||
.joycon-mode-help > div { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.joycon-mode-help strong { color: var(--ink); }
|
||||
.joycon-mode .button { width: 100%; }
|
||||
.joycon-mode-status { margin-top: 10px; color: var(--mint); font-size: 0.74rem; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.joycon-mode-status[data-state="loading"] { color: var(--muted); }
|
||||
.joycon-mode-status[data-state="pending"], .joycon-mode-status[data-state="unsupported"] { color: var(--amber); }
|
||||
.joycon-mode-status[data-state="error"] { color: var(--rose); }
|
||||
.profile-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 22px; }
|
||||
.profile-button {
|
||||
--slot-color: var(--blue);
|
||||
|
|
|
|||
|
|
@ -35,10 +35,34 @@
|
|||
</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">The default applies when a controller has no dedicated layout.</p>
|
||||
|
||||
<section class="joycon-mode" aria-labelledby="joyconModeTitle">
|
||||
<p class="eyebrow">Adapter-wide setting</p>
|
||||
<h3 id="joyconModeTitle">Joy-Con 2 default mode</h3>
|
||||
<label class="field-label" for="joyconModeSelect">Default arrangement</label>
|
||||
<select id="joyconModeSelect" class="select" aria-describedby="joyconModeHelp joyconModeStatus" disabled>
|
||||
<option value="paired">Paired · L + R together</option>
|
||||
<option value="individual">Individual · separate players</option>
|
||||
</select>
|
||||
<p class="field-help">Saved on the adapter; changing it applies live and clears temporary joins/splits. Not an artwork preview.</p>
|
||||
<details class="joycon-mode-help">
|
||||
<summary>Controller shortcut & profile banks</summary>
|
||||
<div id="joyconModeHelp">
|
||||
<p><strong>Paired:</strong> L + R share one player and a separate L+R profile bank. Unmatched halves stay separate.</p>
|
||||
<p><strong>Individual:</strong> each half is a sideways player using its own solo bank. Choose the L or R profile owner to edit it.</p>
|
||||
<p><strong>Join / split this pair:</strong> hold <strong>ZL + Minus</strong> on the left and <strong>ZR + Plus</strong> on the right for <strong>2 seconds</strong>. A short pulse confirms success. Release all four buttons before using it again.</p>
|
||||
<p>The shortcut affects only those halves and overrides this default until either disconnects. Other Joy-Cons are unchanged. Each half's complete two-button chord is reserved until both buttons release, even while waiting for the other half. Ambiguous solo selections do not join.</p>
|
||||
<p>All saved banks and unsaved profile drafts are kept. Your chosen owner does not change automatically.</p>
|
||||
<p>Saved preference is not a live pairing report. A pair waits for its L+R bank to be ready; the diagram and live playtest report the actual arrangement.</p>
|
||||
</div>
|
||||
</details>
|
||||
<button class="button button-secondary" id="applyJoyconModeButton" type="button" disabled>Apply default mode</button>
|
||||
<p class="joycon-mode-status" id="joyconModeStatus" data-state="loading" role="status" aria-live="polite">Reading saved preference…</p>
|
||||
</section>
|
||||
|
||||
<div class="identity-tools">
|
||||
<label class="field-label" for="controllerAlias">Controller alias</label>
|
||||
<input class="number-input" id="controllerAlias" maxlength="31" placeholder="Optional nickname">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,19 @@ const macroCapture = {
|
|||
requestActive: false,
|
||||
timer: 0,
|
||||
};
|
||||
const joyconMode = {
|
||||
saved: null,
|
||||
selected: null,
|
||||
pending: false,
|
||||
supported: false,
|
||||
generation: 0,
|
||||
loading: true,
|
||||
applying: false,
|
||||
requestActive: false,
|
||||
revision: 0,
|
||||
readError: "",
|
||||
applyError: "",
|
||||
};
|
||||
const state = {
|
||||
schema: null,
|
||||
identities: [],
|
||||
|
|
@ -57,6 +70,9 @@ const elements = {
|
|||
connection: document.querySelector("#connectionState"),
|
||||
connectionText: document.querySelector("#connectionText"),
|
||||
identity: document.querySelector("#identitySelect"),
|
||||
joyconMode: document.querySelector("#joyconModeSelect"),
|
||||
applyJoyconMode: document.querySelector("#applyJoyconModeButton"),
|
||||
joyconModeStatus: document.querySelector("#joyconModeStatus"),
|
||||
profileList: document.querySelector("#profileList"),
|
||||
profileOwner: document.querySelector("#profileOwner"),
|
||||
controllerAlias: document.querySelector("#controllerAlias"),
|
||||
|
|
@ -541,6 +557,83 @@ async function api(path, options = {}) {
|
|||
}
|
||||
return payload;
|
||||
}
|
||||
function joyconModeChanged() {
|
||||
return joyconMode.selected !== null && joyconMode.selected !== joyconMode.saved;
|
||||
}
|
||||
|
||||
function renderJoyconMode() {
|
||||
const locked = state.busy || captureBlocking() || macroCapture.requestActive ||
|
||||
joyconMode.loading || joyconMode.applying;
|
||||
elements.joyconMode.disabled = locked || !joyconMode.supported || !state.adapterConnected;
|
||||
elements.applyJoyconMode.disabled = elements.joyconMode.disabled ||
|
||||
Boolean(joyconMode.readError) || !joyconModeChanged();
|
||||
if (joyconMode.selected !== null && elements.joyconMode.value !== joyconMode.selected) {
|
||||
elements.joyconMode.value = joyconMode.selected;
|
||||
}
|
||||
let status;
|
||||
let mode;
|
||||
const saved = joyconMode.saved === null ? "" :
|
||||
`Saved default: ${label(joyconMode.saved)} · generation ${joyconMode.generation}.`;
|
||||
const selection = joyconModeChanged()
|
||||
? ` Selection: ${label(joyconMode.selected)} — not applied.` : "";
|
||||
if (joyconMode.applying) {
|
||||
mode = "loading";
|
||||
status = `Saving ${label(joyconMode.selected)} default… Your profile draft is unchanged.`;
|
||||
} else if (joyconMode.loading) {
|
||||
mode = "loading";
|
||||
status = `Reading saved preference… ${saved}${selection}`;
|
||||
} else if (joyconMode.applyError || joyconMode.readError) {
|
||||
mode = "error";
|
||||
status = `${joyconMode.applyError || joyconMode.readError} ${saved ? `Last confirmed ${saved.toLowerCase()}` : "Saved preference is unknown."}${selection}`;
|
||||
} else if (joyconMode.saved === null) {
|
||||
mode = "error";
|
||||
status = "Saved preference is unknown. Refresh to read it from the adapter.";
|
||||
} else if (!joyconMode.supported) {
|
||||
mode = "unsupported";
|
||||
status = `Paired is the legacy default. This firmware cannot save player mode; update the adapter firmware (configuration schema 4 required).${selection}`;
|
||||
} else if (!state.adapterConnected) {
|
||||
mode = "error";
|
||||
status = `Adapter unavailable. Last confirmed ${saved.toLowerCase()}${selection}`;
|
||||
} else {
|
||||
mode = joyconModeChanged() ? "pending" : "saved";
|
||||
status = `${saved}${selection} Shortcuts can override these connections. Use live playtest to check the current arrangement.`;
|
||||
}
|
||||
elements.joyconModeStatus.dataset.state = mode;
|
||||
if (elements.joyconModeStatus.textContent !== status) {
|
||||
elements.joyconModeStatus.textContent = status;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptJoyconMode(payload, committed = false) {
|
||||
joyconMode.saved = payload.mode;
|
||||
joyconMode.supported = payload.supported;
|
||||
joyconMode.generation = payload.generation;
|
||||
if (committed || !joyconMode.pending) joyconMode.selected = payload.mode;
|
||||
joyconMode.pending = joyconModeChanged();
|
||||
joyconMode.readError = "";
|
||||
if (joyconMode.selected === payload.mode) joyconMode.applyError = "";
|
||||
}
|
||||
|
||||
async function refreshJoyconMode(showLoading = false) {
|
||||
if (joyconMode.requestActive || joyconMode.applying) return;
|
||||
const revision = joyconMode.revision;
|
||||
joyconMode.requestActive = true;
|
||||
joyconMode.loading = showLoading || joyconMode.saved === null;
|
||||
renderJoyconMode();
|
||||
try {
|
||||
const payload = await api("/api/joycon-mode");
|
||||
if (revision === joyconMode.revision) acceptJoyconMode(payload);
|
||||
} catch (error) {
|
||||
if (revision === joyconMode.revision) {
|
||||
joyconMode.readError = `Could not read player mode: ${error.message}`;
|
||||
}
|
||||
} finally {
|
||||
joyconMode.requestActive = false;
|
||||
joyconMode.loading = false;
|
||||
renderJoyconMode();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setBusy(busy) {
|
||||
state.busy = busy;
|
||||
|
|
@ -664,6 +757,7 @@ async function pollLibraryMetadata() {
|
|||
}
|
||||
state.libraryRequestActive = true;
|
||||
try {
|
||||
await refreshJoyconMode();
|
||||
const payload = await api("/api/profiles");
|
||||
syncLibraryMetadata(payload.identities);
|
||||
setConnection("ready", "Adapter connected");
|
||||
|
|
@ -1415,6 +1509,7 @@ function renderCapture() {
|
|||
...elements.profileList.querySelectorAll("button"),
|
||||
].forEach((control) => { control.disabled = true; });
|
||||
}
|
||||
renderJoyconMode();
|
||||
}
|
||||
|
||||
function capturePath(session, action) {
|
||||
|
|
@ -2029,6 +2124,12 @@ async function loadProfile() {
|
|||
if (owner) owner.alias = payload.alias;
|
||||
state.active = payload.active;
|
||||
setConnection("ready", "Adapter connected");
|
||||
syncControllerPresentation();
|
||||
if (state.presentation.sources.length &&
|
||||
!sourceAvailable(state.selectedButton) &&
|
||||
getControlMapping(state.selectedButton) == null) {
|
||||
state.selectedButton = state.presentation.sources[0];
|
||||
}
|
||||
renderEditor();
|
||||
} catch (error) {
|
||||
state.profile = null;
|
||||
|
|
@ -2043,6 +2144,7 @@ async function loadProfile() {
|
|||
async function loadLibrary(preserveDraft = false) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await refreshJoyconMode(true);
|
||||
const payload = await api("/api/profiles");
|
||||
syncLibraryMetadata(payload.identities, !preserveDraft);
|
||||
if (!preserveDraft || !state.profile) {
|
||||
|
|
@ -2259,6 +2361,40 @@ elements.refresh.addEventListener("click", async () => {
|
|||
await loadLibrary(true);
|
||||
});
|
||||
|
||||
elements.joyconMode.addEventListener("change", () => {
|
||||
if (elements.joyconMode.disabled) return;
|
||||
joyconMode.selected = elements.joyconMode.value;
|
||||
joyconMode.pending = joyconModeChanged();
|
||||
joyconMode.applyError = "";
|
||||
renderJoyconMode();
|
||||
});
|
||||
|
||||
elements.applyJoyconMode.addEventListener("click", async () => {
|
||||
if (elements.applyJoyconMode.disabled || state.busy || captureBlocking() ||
|
||||
macroCapture.requestActive) return;
|
||||
joyconMode.applying = true;
|
||||
joyconMode.applyError = "";
|
||||
joyconMode.revision += 1;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await api("/api/joycon-mode", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: joyconMode.selected }),
|
||||
});
|
||||
acceptJoyconMode(payload, true);
|
||||
toast(`${label(payload.mode)} default saved. Your profile draft is unchanged; live playtest reports the actual arrangement.`);
|
||||
} catch (error) {
|
||||
joyconMode.applyError = `Could not confirm player mode: ${error.message}`;
|
||||
toast(joyconMode.applyError, true);
|
||||
} finally {
|
||||
joyconMode.applying = false;
|
||||
setBusy(false);
|
||||
window.clearTimeout(state.libraryTimer);
|
||||
state.libraryTimer = window.setTimeout(pollLibraryMetadata, 0);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
@ -2643,7 +2779,7 @@ elements.activate.addEventListener("click", async () => {
|
|||
window.addEventListener("beforeunload", (event) => {
|
||||
stopMacroPreview("Preview stopped: leaving the editor.");
|
||||
stopCapture("Recording stopped because you left the editor.");
|
||||
if (!isDirty() && !macroCapture.session) return;
|
||||
if (!isDirty() && !macroCapture.session && !joyconModeChanged()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -133,6 +133,7 @@ void test_service_lifecycle_and_mutations(bool v2) {
|
|||
require(snapshot.state == ConfigurationServiceState::kReady &&
|
||||
snapshot.configuration.pairing_window_seconds == 90 &&
|
||||
snapshot.configuration.requested_mode == original_mode &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kPaired &&
|
||||
snapshot.configuration.native_switch_controller_count == 0,
|
||||
"Core 0 did not preserve legacy settings without approval");
|
||||
require(g_flash.program_count == programs_after_seed &&
|
||||
|
|
@ -160,6 +161,7 @@ void test_service_lifecycle_and_mutations(bool v2) {
|
|||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.state == ConfigurationServiceState::kReady &&
|
||||
snapshot.configuration.requested_mode == original_mode &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kPaired &&
|
||||
snapshot.configuration.native_switch_controller_count == 0 &&
|
||||
snapshot.transaction.status ==
|
||||
ConfigurationTransactionStatus::kIdle,
|
||||
|
|
@ -172,7 +174,7 @@ void test_service_lifecycle_and_mutations(bool v2) {
|
|||
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
|
||||
after_migration.snapshot().payload_size ==
|
||||
ADAPTER_CONFIGURATION_ENCODED_SIZE,
|
||||
"power cycle did not observe the migrated v3 record");
|
||||
"power cycle did not observe the migrated v4 record");
|
||||
AdapterConfiguration migrated{};
|
||||
require(adapter_configuration_decode(
|
||||
after_migration.snapshot().schema_version,
|
||||
|
|
@ -180,8 +182,9 @@ void test_service_lifecycle_and_mutations(bool v2) {
|
|||
after_migration.snapshot().payload_size, &migrated) &&
|
||||
migrated.pairing_window_seconds == 90 &&
|
||||
migrated.requested_mode == original_mode &&
|
||||
migrated.joycon_mode == JoyConMode::kPaired &&
|
||||
migrated.native_switch_controller_count == 0,
|
||||
"migrated v3 bytes did not preserve legacy configuration");
|
||||
"migrated v4 bytes did not preserve legacy configuration");
|
||||
|
||||
require(configuration_service_set_mode(
|
||||
10, AdapterRequestedMode::kSwitch, implemented) ==
|
||||
|
|
@ -476,6 +479,17 @@ void test_service_lifecycle_and_mutations(bool v2) {
|
|||
}
|
||||
|
||||
void test_abandoned_host_receive_does_not_block_recovery() {
|
||||
AdapterConfiguration original{};
|
||||
original.pairing_window_seconds = 90;
|
||||
original.requested_mode = AdapterRequestedMode::kSwitch;
|
||||
original.joycon_mode = JoyConMode::kIndividual;
|
||||
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
|
||||
ConfigurationStorage seed;
|
||||
require(adapter_configuration_encode(original, payload, sizeof(payload)) &&
|
||||
seed.initialize(fake_io()) &&
|
||||
seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
|
||||
sizeof(payload)) == ConfigurationStorageResult::kOk,
|
||||
"recovery configuration fixture did not persist");
|
||||
configuration_service_prepare();
|
||||
configuration_service_initialize_pre_usb();
|
||||
configuration_service_initialize_on_storage_core();
|
||||
|
|
@ -484,7 +498,7 @@ void test_abandoned_host_receive_does_not_block_recovery() {
|
|||
configuration_service_snapshot(&snapshot);
|
||||
AdapterConfiguration abandoned = snapshot.configuration;
|
||||
abandoned.pairing_window_seconds = 180;
|
||||
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
|
||||
abandoned.joycon_mode = JoyConMode::kPaired;
|
||||
require(adapter_configuration_encode(abandoned, payload,
|
||||
sizeof(payload)),
|
||||
"abandoned recovery-race configuration did not encode");
|
||||
|
|
@ -513,6 +527,11 @@ void test_abandoned_host_receive_does_not_block_recovery() {
|
|||
require_mode_status(kRecoveryAuto,
|
||||
ConfigurationTransactionStatus::kCommitted,
|
||||
"recovery Auto did not commit after abandoned receive");
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kAuto &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
|
||||
snapshot.configuration.pairing_window_seconds == 90,
|
||||
"recovery Auto replaced durable settings with defaults or abandoned data");
|
||||
require(configuration_service_mode_transaction_reboot_ready(
|
||||
kRecoveryAuto),
|
||||
"recovery Auto did not become latest reboot authority");
|
||||
|
|
@ -535,7 +554,7 @@ void queue_configuration(uint32_t transaction_id,
|
|||
"approval configuration did not reach pending commit");
|
||||
}
|
||||
|
||||
void test_native_switch_approval_preservation_and_revocation() {
|
||||
void test_native_switch_approval_preservation_and_revocation(bool migrate_v3) {
|
||||
ControllerIdentity pro{};
|
||||
pro.stable = true;
|
||||
pro.transport = ControllerTransport::kClassic;
|
||||
|
|
@ -548,6 +567,8 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
left.product_id = 0x2006;
|
||||
AdapterConfiguration configuration{};
|
||||
configuration.pairing_window_seconds = 90;
|
||||
configuration.joycon_mode =
|
||||
migrate_v3 ? JoyConMode::kPaired : JoyConMode::kIndividual;
|
||||
configuration.native_switch_controller_count = 2;
|
||||
configuration.native_switch_controllers[0] = pro;
|
||||
configuration.native_switch_controllers[1] = left;
|
||||
|
|
@ -555,28 +576,59 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
ConfigurationStorage seed;
|
||||
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
|
||||
seed.initialize(fake_io()) &&
|
||||
seed.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
|
||||
sizeof(payload)) == ConfigurationStorageResult::kOk,
|
||||
seed.commit(migrate_v3 ? ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION
|
||||
: ADAPTER_CONFIGURATION_SCHEMA_VERSION,
|
||||
payload, sizeof(payload)) ==
|
||||
ConfigurationStorageResult::kOk,
|
||||
"approved controller configuration did not persist");
|
||||
configuration_service_prepare();
|
||||
configuration_service_initialize_pre_usb();
|
||||
ConfigurationServiceSnapshot snapshot{};
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro) &&
|
||||
require(snapshot.configuration.joycon_mode == configuration.joycon_mode &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro) &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, left),
|
||||
"pre-USB snapshot did not expose persisted approvals");
|
||||
configuration_service_initialize_on_storage_core();
|
||||
if (migrate_v3) {
|
||||
configuration_service_task_on_storage_core(0);
|
||||
ConfigurationStorage migrated;
|
||||
AdapterConfiguration decoded{};
|
||||
require(migrated.initialize(fake_io()) &&
|
||||
migrated.snapshot().schema_version ==
|
||||
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
|
||||
adapter_configuration_decode(
|
||||
migrated.snapshot().schema_version,
|
||||
migrated.snapshot().payload,
|
||||
migrated.snapshot().payload_size, &decoded) &&
|
||||
decoded.joycon_mode == JoyConMode::kPaired &&
|
||||
decoded.pairing_window_seconds == 90 &&
|
||||
adapter_configuration_native_switch_approved(decoded, pro) &&
|
||||
adapter_configuration_native_switch_approved(decoded, left),
|
||||
"v3 service migration changed settings or approvals");
|
||||
configuration.joycon_mode = JoyConMode::kIndividual;
|
||||
queue_configuration(100, configuration);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.configuration.joycon_mode == JoyConMode::kPaired,
|
||||
"player mode changed before durable commit");
|
||||
configuration_service_task_on_storage_core(1000);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.configuration.joycon_mode == JoyConMode::kIndividual,
|
||||
"committed Individual mode was not published");
|
||||
}
|
||||
const uint32_t start_ms = migrate_v3 ? 2000 : 0;
|
||||
|
||||
const AdapterModeAvailability implemented{true, true, true, true};
|
||||
require(configuration_service_set_mode(
|
||||
1, AdapterRequestedMode::kSwitch, implemented) ==
|
||||
ConfigurationTransactionStatus::kPending,
|
||||
"approved configuration blocked host mode selection");
|
||||
configuration_service_task_on_storage_core(0);
|
||||
configuration_service_task_on_storage_core(start_ms);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kSwitch &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro) &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
|
|
@ -586,9 +638,10 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
0x80000002u, AdapterRequestedMode::kXInput, implemented) ==
|
||||
ConfigurationTransactionStatus::kPending,
|
||||
"approved configuration blocked internal mode selection");
|
||||
configuration_service_task_on_storage_core(1000);
|
||||
configuration_service_task_on_storage_core(start_ms + 1000);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro) &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
|
|
@ -598,10 +651,11 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
configuration = snapshot.configuration;
|
||||
configuration.pairing_window_seconds = 120;
|
||||
queue_configuration(3, configuration);
|
||||
configuration_service_task_on_storage_core(2000);
|
||||
configuration_service_task_on_storage_core(start_ms + 2000);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.configuration.pairing_window_seconds == 120 &&
|
||||
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro) &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
|
|
@ -619,13 +673,14 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
snapshot.configuration, pro),
|
||||
"approval was revoked before durable commit");
|
||||
g_flash.fail_program = true;
|
||||
configuration_service_task_on_storage_core(3000);
|
||||
configuration_service_task_on_storage_core(start_ms + 3000);
|
||||
g_flash.fail_program = false;
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.transaction.status ==
|
||||
ConfigurationTransactionStatus::kStorageError &&
|
||||
snapshot.generation == approved_generation &&
|
||||
snapshot.payload_crc == approved_crc &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro),
|
||||
"failed revocation changed the published durable configuration");
|
||||
|
|
@ -636,17 +691,19 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
after_failure.snapshot().schema_version,
|
||||
after_failure.snapshot().payload,
|
||||
after_failure.snapshot().payload_size, &recovered) &&
|
||||
recovered.joycon_mode == JoyConMode::kIndividual &&
|
||||
adapter_configuration_native_switch_approved(recovered, pro) &&
|
||||
adapter_configuration_native_switch_approved(recovered, left),
|
||||
"interrupted revocation destroyed persisted approvals");
|
||||
|
||||
queue_configuration(5, configuration);
|
||||
configuration_service_task_on_storage_core(3000);
|
||||
configuration_service_task_on_storage_core(start_ms + 3000);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.transaction.status ==
|
||||
ConfigurationTransactionStatus::kCommitted &&
|
||||
snapshot.configuration.pairing_window_seconds == 120 &&
|
||||
snapshot.configuration.requested_mode == AdapterRequestedMode::kXInput &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kIndividual &&
|
||||
!adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, pro) &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
|
|
@ -658,6 +715,7 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
after_revocation.snapshot().schema_version,
|
||||
after_revocation.snapshot().payload,
|
||||
after_revocation.snapshot().payload_size, &recovered) &&
|
||||
recovered.joycon_mode == JoyConMode::kIndividual &&
|
||||
!adapter_configuration_native_switch_approved(recovered, pro) &&
|
||||
adapter_configuration_native_switch_approved(recovered, left),
|
||||
"controller-specific revocation did not survive power cycle");
|
||||
|
|
@ -669,9 +727,10 @@ void test_native_switch_approval_preservation_and_revocation() {
|
|||
require(adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, left),
|
||||
"reset erased approval before commit");
|
||||
configuration_service_task_on_storage_core(4000);
|
||||
configuration_service_task_on_storage_core(start_ms + 4000);
|
||||
configuration_service_snapshot(&snapshot);
|
||||
require(snapshot.transaction.status == ConfigurationTransactionStatus::kCommitted &&
|
||||
snapshot.configuration.joycon_mode == JoyConMode::kPaired &&
|
||||
!adapter_configuration_native_switch_approved(
|
||||
snapshot.configuration, left),
|
||||
"configuration reset did not revoke persisted approval");
|
||||
|
|
@ -685,7 +744,9 @@ int main(int argc, char** argv) {
|
|||
} else if (strcmp(argv[1], "v2-migration") == 0) {
|
||||
test_service_lifecycle_and_mutations(true);
|
||||
} else if (strcmp(argv[1], "native-approvals") == 0) {
|
||||
test_native_switch_approval_preservation_and_revocation();
|
||||
test_native_switch_approval_preservation_and_revocation(false);
|
||||
} else if (strcmp(argv[1], "v3-migration") == 0) {
|
||||
test_native_switch_approval_preservation_and_revocation(true);
|
||||
} else {
|
||||
require(strcmp(argv[1], "abandoned-receive") == 0,
|
||||
"unknown service test scenario");
|
||||
|
|
|
|||
|
|
@ -90,22 +90,23 @@ void test_schema_encoding() {
|
|||
AdapterConfiguration configuration{};
|
||||
configuration.pairing_window_seconds = 90;
|
||||
configuration.requested_mode = AdapterRequestedMode::kXInput;
|
||||
configuration.joycon_mode = JoyConMode::kIndividual;
|
||||
uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
|
||||
require(adapter_configuration_encode(configuration, payload,
|
||||
sizeof(payload)),
|
||||
"valid v3 configuration did not encode");
|
||||
"valid v4 configuration did not encode");
|
||||
const uint8_t expected[ADAPTER_CONFIGURATION_ENCODED_SIZE] = {
|
||||
90,
|
||||
0,
|
||||
static_cast<uint8_t>(AdapterRequestedMode::kXInput),
|
||||
0,
|
||||
0,
|
||||
static_cast<uint8_t>(JoyConMode::kIndividual),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
};
|
||||
require(memcmp(payload, expected, sizeof(expected)) == 0,
|
||||
"v3 configuration bytes are not canonical");
|
||||
"v4 configuration bytes are not canonical");
|
||||
|
||||
AdapterConfiguration decoded{};
|
||||
require(adapter_configuration_decode(
|
||||
|
|
@ -113,8 +114,9 @@ void test_schema_encoding() {
|
|||
sizeof(payload), &decoded) &&
|
||||
decoded.pairing_window_seconds == 90 &&
|
||||
decoded.requested_mode == AdapterRequestedMode::kXInput &&
|
||||
decoded.joycon_mode == JoyConMode::kIndividual &&
|
||||
decoded.native_switch_controller_count == 0,
|
||||
"v3 configuration did not round trip");
|
||||
"v4 configuration did not round trip");
|
||||
|
||||
const AdapterRequestedMode valid_modes[] = {
|
||||
AdapterRequestedMode::kAuto,
|
||||
|
|
@ -130,7 +132,8 @@ void test_schema_encoding() {
|
|||
adapter_configuration_decode(
|
||||
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
|
||||
sizeof(payload), &decoded) &&
|
||||
decoded.requested_mode == mode,
|
||||
decoded.requested_mode == mode &&
|
||||
decoded.joycon_mode == JoyConMode::kIndividual,
|
||||
"valid requested mode did not round trip");
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +159,7 @@ void test_schema_encoding() {
|
|||
sizeof(legacy), &decoded) &&
|
||||
decoded.pairing_window_seconds == 120 &&
|
||||
decoded.requested_mode == AdapterRequestedMode::kAuto &&
|
||||
decoded.joycon_mode == JoyConMode::kPaired &&
|
||||
decoded.native_switch_controller_count == 0,
|
||||
"v1 configuration did not migrate without approvals");
|
||||
uint8_t malformed_legacy[sizeof(legacy)];
|
||||
|
|
@ -178,6 +182,7 @@ void test_schema_encoding() {
|
|||
&decoded) &&
|
||||
decoded.pairing_window_seconds == 120 &&
|
||||
decoded.requested_mode == AdapterRequestedMode::kMac &&
|
||||
decoded.joycon_mode == JoyConMode::kPaired &&
|
||||
decoded.native_switch_controller_count == 0,
|
||||
"v2 migration changed settings or granted native rumble");
|
||||
for (size_t index = 3; index < sizeof(v2); ++index) {
|
||||
|
|
@ -195,13 +200,16 @@ void test_schema_encoding() {
|
|||
"v2 record with wrong size was accepted");
|
||||
|
||||
for (size_t index = 3; index < sizeof(payload); ++index) {
|
||||
if (index == 4) {
|
||||
continue;
|
||||
}
|
||||
uint8_t malformed[sizeof(payload)];
|
||||
memcpy(malformed, payload, sizeof(payload));
|
||||
malformed[index] = 1;
|
||||
require(!adapter_configuration_decode(
|
||||
ADAPTER_CONFIGURATION_SCHEMA_VERSION, malformed,
|
||||
sizeof(malformed), &decoded),
|
||||
"v3 nonzero reserved or unused byte was accepted");
|
||||
"v4 nonzero reserved or unused byte was accepted");
|
||||
}
|
||||
uint8_t invalid_mode[sizeof(payload)];
|
||||
memcpy(invalid_mode, payload, sizeof(payload));
|
||||
|
|
@ -210,16 +218,33 @@ void test_schema_encoding() {
|
|||
ADAPTER_CONFIGURATION_SCHEMA_VERSION, invalid_mode,
|
||||
sizeof(invalid_mode), &decoded),
|
||||
"out-of-range requested mode was accepted");
|
||||
const uint8_t invalid_joycon_modes[] = {2, 0xff};
|
||||
for (uint8_t mode : invalid_joycon_modes) {
|
||||
memcpy(invalid_mode, payload, sizeof(payload));
|
||||
invalid_mode[4] = mode;
|
||||
require(!adapter_configuration_decode(invalid_mode, sizeof(invalid_mode),
|
||||
&decoded),
|
||||
"out-of-range Joy-Con2 mode was accepted");
|
||||
configuration.joycon_mode = static_cast<JoyConMode>(mode);
|
||||
require(!adapter_configuration_encode(configuration, payload,
|
||||
sizeof(payload)),
|
||||
"out-of-range Joy-Con2 mode encoded");
|
||||
}
|
||||
configuration.joycon_mode = JoyConMode::kPaired;
|
||||
require(adapter_configuration_encode(configuration, payload, sizeof(payload)) &&
|
||||
adapter_configuration_decode(payload, sizeof(payload), &decoded) &&
|
||||
decoded.joycon_mode == JoyConMode::kPaired,
|
||||
"Paired mode did not round trip");
|
||||
require(!adapter_configuration_decode(
|
||||
ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
|
||||
sizeof(payload) - 1, &decoded),
|
||||
"short v3 record was accepted");
|
||||
"short v4 record was accepted");
|
||||
uint8_t oversized[ADAPTER_CONFIGURATION_ENCODED_SIZE + 1]{};
|
||||
memcpy(oversized, payload, sizeof(payload));
|
||||
require(!adapter_configuration_decode(
|
||||
ADAPTER_CONFIGURATION_SCHEMA_VERSION, oversized,
|
||||
sizeof(oversized), &decoded),
|
||||
"oversized v3 record was accepted");
|
||||
"oversized v4 record was accepted");
|
||||
|
||||
configuration.pairing_window_seconds = 9;
|
||||
require(!adapter_configuration_encode(configuration, payload,
|
||||
|
|
@ -234,7 +259,7 @@ void test_schema_encoding() {
|
|||
configuration.requested_mode = AdapterRequestedMode::kAuto;
|
||||
require(!adapter_configuration_encode(configuration, oversized,
|
||||
sizeof(oversized)),
|
||||
"v3 encoder accepted a noncanonical output size");
|
||||
"v4 encoder accepted a noncanonical output size");
|
||||
}
|
||||
|
||||
void test_native_switch_approval_identity_and_canonical_encoding() {
|
||||
|
|
@ -262,6 +287,25 @@ void test_native_switch_approval_identity_and_canonical_encoding() {
|
|||
decoded, nintendo_identity(3, 0x2006)),
|
||||
"approval did not remain specific to the complete controller identity");
|
||||
|
||||
decoded.joycon_mode = JoyConMode::kIndividual;
|
||||
require(adapter_configuration_decode(
|
||||
ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, payload,
|
||||
sizeof(payload), &decoded) &&
|
||||
decoded.joycon_mode == JoyConMode::kPaired &&
|
||||
adapter_configuration_native_switch_approved(decoded, pro) &&
|
||||
adapter_configuration_native_switch_approved(decoded, left) &&
|
||||
adapter_configuration_native_switch_approved(decoded, right),
|
||||
"v3 migration erased approvals or retained a stale player mode");
|
||||
for (size_t index = 4; index < ADAPTER_CONFIGURATION_HEADER_SIZE; ++index) {
|
||||
uint8_t malformed[sizeof(payload)];
|
||||
memcpy(malformed, payload, sizeof(payload));
|
||||
malformed[index] = 1;
|
||||
require(!adapter_configuration_decode(
|
||||
ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION, malformed,
|
||||
sizeof(malformed), &decoded),
|
||||
"v3 reserved header byte was accepted");
|
||||
}
|
||||
|
||||
configuration.native_switch_controllers[0] = right;
|
||||
configuration.native_switch_controllers[1] = pro;
|
||||
configuration.native_switch_controllers[2] = left;
|
||||
|
|
@ -368,16 +412,27 @@ void test_legacy_migration_power_loss() {
|
|||
const uint16_t versions[] = {
|
||||
ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION,
|
||||
ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
|
||||
ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION,
|
||||
};
|
||||
for (uint16_t version : versions) {
|
||||
const bool v2 = version == ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION;
|
||||
const uint8_t legacy[] = {
|
||||
90, 0, static_cast<uint8_t>(v2 ? AdapterRequestedMode::kXInput
|
||||
: AdapterRequestedMode::kAuto),
|
||||
0, 0, 0, 0, 0,
|
||||
};
|
||||
const size_t legacy_size = v2 ? ADAPTER_CONFIGURATION_V2_ENCODED_SIZE
|
||||
: ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE;
|
||||
const bool v1 = version == ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION;
|
||||
const bool v3 = version == ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION;
|
||||
AdapterConfiguration source{};
|
||||
source.pairing_window_seconds = 90;
|
||||
source.requested_mode = v1 ? AdapterRequestedMode::kAuto
|
||||
: AdapterRequestedMode::kXInput;
|
||||
if (v3) {
|
||||
source.native_switch_controller_count = 2;
|
||||
source.native_switch_controllers[0] = nintendo_identity(1);
|
||||
source.native_switch_controllers[1] = nintendo_identity(2, 0x2006);
|
||||
}
|
||||
uint8_t legacy[ADAPTER_CONFIGURATION_ENCODED_SIZE]{};
|
||||
require(adapter_configuration_encode(source, legacy, sizeof(legacy)),
|
||||
"legacy migration fixture did not encode");
|
||||
const size_t legacy_size =
|
||||
v3 ? ADAPTER_CONFIGURATION_ENCODED_SIZE
|
||||
: (v1 ? ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE
|
||||
: ADAPTER_CONFIGURATION_V2_ENCODED_SIZE);
|
||||
FakeFlash flash;
|
||||
ConfigurationStorage store;
|
||||
require(store.initialize(fake_io(&flash)) &&
|
||||
|
|
@ -390,7 +445,7 @@ void test_legacy_migration_power_loss() {
|
|||
&migrated) &&
|
||||
adapter_configuration_encode(migrated, payload,
|
||||
sizeof(payload)),
|
||||
"legacy settings did not convert to schema3");
|
||||
"legacy settings did not convert to schema4");
|
||||
flash.fail_after_programs = flash.successful_programs;
|
||||
require(store.commit(ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload,
|
||||
sizeof(payload)) ==
|
||||
|
|
@ -418,8 +473,15 @@ void test_legacy_migration_power_loss() {
|
|||
rebooted.snapshot().payload_size, &decoded) &&
|
||||
decoded.pairing_window_seconds == 90 &&
|
||||
decoded.requested_mode == migrated.requested_mode &&
|
||||
decoded.native_switch_controller_count == 0,
|
||||
"migration retry changed settings or granted rumble approval");
|
||||
decoded.joycon_mode == JoyConMode::kPaired &&
|
||||
decoded.native_switch_controller_count ==
|
||||
source.native_switch_controller_count &&
|
||||
(!v3 ||
|
||||
(adapter_configuration_native_switch_approved(
|
||||
decoded, source.native_switch_controllers[0]) &&
|
||||
adapter_configuration_native_switch_approved(
|
||||
decoded, source.native_switch_controllers[1]))),
|
||||
"migration retry changed settings or controller approvals");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -553,18 +615,33 @@ void test_transaction_validation() {
|
|||
transaction.begin(
|
||||
14, ADAPTER_CONFIGURATION_V2_SCHEMA_VERSION,
|
||||
ADAPTER_CONFIGURATION_V2_ENCODED_SIZE, 0) ==
|
||||
ConfigurationTransactionStatus::kUnsupportedSchema &&
|
||||
transaction.begin(
|
||||
15, ADAPTER_CONFIGURATION_V3_SCHEMA_VERSION,
|
||||
ADAPTER_CONFIGURATION_ENCODED_SIZE, 0) ==
|
||||
ConfigurationTransactionStatus::kUnsupportedSchema,
|
||||
"legacy host writes could silently erase stored approvals");
|
||||
"legacy host writes could silently erase stored settings");
|
||||
payload[3] = ADAPTER_CONFIGURATION_NATIVE_SWITCH_CONTROLLER_CAPACITY + 1;
|
||||
require(transaction.begin(
|
||||
15, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
|
||||
16, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
|
||||
configuration_crc32(payload, sizeof(payload))) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
transaction.append(15, 0, payload, sizeof(payload)) ==
|
||||
transaction.append(16, 0, payload, sizeof(payload)) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
transaction.finish(15) ==
|
||||
transaction.finish(16) ==
|
||||
ConfigurationTransactionStatus::kMalformed,
|
||||
"valid CRC allowed a malformed approval list to reach storage");
|
||||
payload[3] = 0;
|
||||
payload[4] = 2;
|
||||
require(transaction.begin(
|
||||
17, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload),
|
||||
configuration_crc32(payload, sizeof(payload))) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
transaction.append(17, 0, payload, sizeof(payload)) ==
|
||||
ConfigurationTransactionStatus::kReceiving &&
|
||||
transaction.finish(17) ==
|
||||
ConfigurationTransactionStatus::kMalformed,
|
||||
"valid CRC allowed an invalid Joy-Con2 mode to reach storage");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
|
|||
|
|
@ -84,6 +84,9 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile_transform.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_synthetic_input.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile_runtime.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "profile_storage.cpp"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(
|
||||
|
|
@ -96,6 +99,23 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
subprocess.run(command, check=True, cwd=root)
|
||||
subprocess.run([str(executable), "xbox-rumble"], check=True, cwd=root)
|
||||
for scenario in (
|
||||
"switch2-individual-core-start",
|
||||
"switch2-individual-forward",
|
||||
"switch2-individual-reverse",
|
||||
"switch2-mode-forward",
|
||||
"switch2-mode-reverse",
|
||||
"switch2-mode-two-pairs",
|
||||
"switch2-mode-seed-failure",
|
||||
"switch2-mode-identity-failure",
|
||||
"switch2-gesture-timing",
|
||||
"switch2-gesture-stale",
|
||||
"switch2-gesture-clock-wrap",
|
||||
"switch2-gesture-masking-epochs",
|
||||
"switch2-gesture-override-lifetime",
|
||||
"switch2-gesture-two-pairs",
|
||||
"switch2-gesture-ambiguous",
|
||||
"switch2-gesture-seed-failure",
|
||||
"switch2-gesture-device-scope",
|
||||
"switch2-forward",
|
||||
"switch2-reverse",
|
||||
"switch2-multiple-pairs",
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ class FakeDevice:
|
|||
self.transaction_expected_size = 0
|
||||
self.transaction_expected_crc = 0
|
||||
self.transaction_status = config_manager.STATUS_OK
|
||||
self.fail_configuration_commit_status: int | None = None
|
||||
self.pending_requested_mode: int | None = None
|
||||
self.mode_pending_reads = 0
|
||||
self.fail_mode_status: int | None = None
|
||||
|
|
@ -450,16 +451,19 @@ class FakeDevice:
|
|||
assert (
|
||||
zlib.crc32(self.transaction_payload) & 0xFFFFFFFF
|
||||
) == self.transaction_expected_crc
|
||||
self.configuration = bytes(self.transaction_payload)
|
||||
self.configuration_generation += 1
|
||||
self.transaction_status = config_manager.STATUS_OK
|
||||
if self.fail_configuration_commit_status is not None:
|
||||
self.transaction_status = self.fail_configuration_commit_status
|
||||
else:
|
||||
self.configuration = bytes(self.transaction_payload)
|
||||
self.configuration_generation += 1
|
||||
self.transaction_status = config_manager.STATUS_OK
|
||||
elif request == config_manager.OP_CONFIGURATION_RESET:
|
||||
self.transaction_id = struct.unpack("<I", payload)[0]
|
||||
assert 0 < self.transaction_id <= (config_manager.HOST_TRANSACTION_ID_MASK)
|
||||
self.configuration = struct.pack(
|
||||
"<HB5x", 60, config_manager.REQUESTED_MODE_AUTO
|
||||
)
|
||||
if self.configuration_schema == config_manager.CONFIGURATION_SCHEMA_VERSION:
|
||||
if self.configuration_schema >= 3:
|
||||
self.configuration += bytes(config_manager.CONFIGURATION_SIZE - 8)
|
||||
self.configuration_generation += 1
|
||||
self.transaction_payload = bytearray(self.configuration)
|
||||
|
|
@ -710,9 +714,12 @@ def native_rumble_identity(
|
|||
|
||||
def native_rumble_configuration(
|
||||
identities: tuple[config_manager.ControllerIdentity, ...] = (),
|
||||
joycon_mode: int = config_manager.JOYCON_MODE_PAIRED,
|
||||
) -> bytes:
|
||||
return (
|
||||
struct.pack("<HBB4x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities))
|
||||
struct.pack(
|
||||
"<HBBB3x", 90, config_manager.REQUESTED_MODE_XINPUT, len(identities), joycon_mode
|
||||
)
|
||||
+ b"".join(identity.to_bytes() for identity in identities)
|
||||
+ bytes((16 - len(identities)) * 14)
|
||||
)
|
||||
|
|
@ -734,6 +741,7 @@ def test_legacy_configuration_has_no_native_rumble_approval(
|
|||
configuration = config_manager.read_configuration(device)
|
||||
assert configuration.pairing_window_seconds == 75
|
||||
assert configuration.requested_mode == mode
|
||||
assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
|
||||
assert configuration.native_switch_controllers == ()
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.set_native_switch_rumble_approval(
|
||||
|
|
@ -750,7 +758,8 @@ def test_legacy_configuration_has_no_native_rumble_approval(
|
|||
assert not device.out_requests
|
||||
|
||||
|
||||
def test_native_rumble_configuration_canonical_wire_round_trip() -> None:
|
||||
@pytest.mark.parametrize("schema", (3, 4))
|
||||
def test_native_rumble_configuration_canonical_wire_round_trip(schema: int) -> None:
|
||||
device = FakeDevice()
|
||||
identities = tuple(
|
||||
native_rumble_identity(
|
||||
|
|
@ -761,17 +770,19 @@ def test_native_rumble_configuration_canonical_wire_round_trip() -> None:
|
|||
config_manager.write_configuration(
|
||||
device,
|
||||
config_manager.AdapterConfiguration(
|
||||
90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities))
|
||||
90, 0, 0, config_manager.REQUESTED_MODE_XINPUT, tuple(reversed(identities)),
|
||||
schema_version=schema,
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
assert device.configuration_schema == 3
|
||||
assert device.configuration_schema == schema
|
||||
assert device.configuration == native_rumble_configuration(identities)
|
||||
stored = config_manager.read_configuration(device)
|
||||
assert stored.native_switch_controllers == identities
|
||||
assert stored.crc == zlib.crc32(device.configuration) & 0xFFFFFFFF
|
||||
assert stored.pairing_window_seconds == 90
|
||||
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
|
||||
assert stored.joycon_mode == config_manager.JOYCON_MODE_PAIRED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -852,12 +863,17 @@ def test_native_rumble_write_rejects_invalid_approvals_before_transaction(
|
|||
assert not device.out_requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "joycon_mode"),
|
||||
((3, config_manager.JOYCON_MODE_PAIRED), (4, config_manager.JOYCON_MODE_INDIVIDUAL)),
|
||||
)
|
||||
def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
schema: int, joycon_mode: int,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = 3
|
||||
device.configuration = native_rumble_configuration()
|
||||
device.configuration_schema = schema
|
||||
device.configuration = native_rumble_configuration(joycon_mode=joycon_mode)
|
||||
first = native_rumble_identity()
|
||||
second = native_rumble_identity(bytes.fromhex("A1A2A3A4A5A6"))
|
||||
for identity in (first, second):
|
||||
|
|
@ -888,13 +904,10 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
|
|||
assert approved.native_switch_controllers == (first,)
|
||||
assert approved.pairing_window_seconds == 90
|
||||
assert approved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
|
||||
assert approved.joycon_mode == joycon_mode
|
||||
assert device.profiles == previous_profiles
|
||||
assert device.active_profiles == previous_active
|
||||
assert device.records == previous_pairings
|
||||
assert config_manager.main(["config", "native-rumble", "list"]) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert f"Approved identity 2: {first.address_text}" in output
|
||||
assert f"Approved identity 3: {second.address_text}" not in output
|
||||
assert (
|
||||
config_manager.main(["config", "set", "--pairing-window-seconds", "120"]) == 0
|
||||
)
|
||||
|
|
@ -902,10 +915,12 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
|
|||
assert preserved.pairing_window_seconds == 120
|
||||
assert preserved.requested_mode == config_manager.REQUESTED_MODE_XINPUT
|
||||
assert preserved.native_switch_controllers == (first,)
|
||||
assert preserved.joycon_mode == joycon_mode
|
||||
config_manager.set_mode(device, config_manager.REQUESTED_MODE_DINPUT, 1.0)
|
||||
preserved = config_manager.read_configuration(device)
|
||||
assert preserved.requested_mode == config_manager.REQUESTED_MODE_DINPUT
|
||||
assert preserved.native_switch_controllers == (first,)
|
||||
assert preserved.joycon_mode == joycon_mode
|
||||
assert (
|
||||
config_manager.main(["config", "native-rumble", "revoke", "--identity", "2"])
|
||||
== 0
|
||||
|
|
@ -914,6 +929,7 @@ def test_native_rumble_cli_approval_is_physical_and_preserves_other_settings(
|
|||
assert revoked.native_switch_controllers == ()
|
||||
assert revoked.requested_mode == config_manager.REQUESTED_MODE_DINPUT
|
||||
assert revoked.pairing_window_seconds == 120
|
||||
assert revoked.joycon_mode == joycon_mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identity_index", ("0", "1", "99"))
|
||||
|
|
@ -995,6 +1011,7 @@ def test_configuration_transaction_and_reset() -> None:
|
|||
before.generation,
|
||||
before.crc,
|
||||
config_manager.REQUESTED_MODE_XINPUT,
|
||||
joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL,
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
|
|
@ -1002,12 +1019,139 @@ def test_configuration_transaction_and_reset() -> None:
|
|||
stored = config_manager.read_configuration(device)
|
||||
assert stored.pairing_window_seconds == 90
|
||||
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
|
||||
assert stored.joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL
|
||||
reset = config_manager.reset_configuration(device, 1.0)
|
||||
assert reset.stored_generation == 5
|
||||
reset_configuration = config_manager.read_configuration(device)
|
||||
assert reset_configuration.pairing_window_seconds == 60
|
||||
assert reset_configuration.requested_mode == config_manager.REQUESTED_MODE_AUTO
|
||||
assert reset_configuration.native_switch_controllers == ()
|
||||
assert reset_configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("schema", "payload"),
|
||||
(
|
||||
(1, struct.pack("<H2x", 75)),
|
||||
(2, struct.pack("<HB5x", 75, config_manager.REQUESTED_MODE_DINPUT)),
|
||||
(3, native_rumble_configuration((native_rumble_identity(),))),
|
||||
),
|
||||
)
|
||||
def test_joycon_mode_legacy_read_and_write_refusal(
|
||||
schema: int, payload: bytes,
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = schema
|
||||
device.configuration = payload
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
|
||||
assert config_manager.main(["joycon-mode", "--json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out) == {
|
||||
"mode": "paired", "generation": 3, "supported": False,
|
||||
}
|
||||
configuration = config_manager.read_configuration(device)
|
||||
assert configuration.joycon_mode == config_manager.JOYCON_MODE_PAIRED
|
||||
if schema == 3:
|
||||
assert configuration.native_switch_controllers == (native_rumble_identity(),)
|
||||
for mode in config_manager.JOYCON_MODE_NAMES:
|
||||
assert config_manager.main(["joycon-mode", mode, "--json"]) == 1
|
||||
assert capsys.readouterr().out == ""
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.write_configuration(
|
||||
device, replace(configuration, joycon_mode=config_manager.JOYCON_MODE_INDIVIDUAL),
|
||||
1.0,
|
||||
)
|
||||
assert device.configuration == payload
|
||||
assert not device.out_requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("offset", "value"), ((4, 2), (4, 255), (5, 1), (6, 1), (7, 1))
|
||||
)
|
||||
def test_joycon_mode_rejects_invalid_wire_encoding(offset: int, value: int) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = 4
|
||||
payload = bytearray(native_rumble_configuration((native_rumble_identity(),)))
|
||||
payload[offset] = value
|
||||
device.configuration = bytes(payload)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.read_configuration(device)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", (8, 231, 233))
|
||||
def test_joycon_mode_requires_exact_configuration_size(size: int) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = 4
|
||||
payload = native_rumble_configuration()
|
||||
device.configuration = (payload + b"\x00")[:size]
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.read_configuration(device)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", (-1, 2, True, 1.0, "individual"))
|
||||
def test_joycon_mode_rejects_invalid_values_before_transaction(mode: object) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = 4
|
||||
device.configuration = native_rumble_configuration()
|
||||
before = config_manager.read_configuration(device)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.set_joycon_mode(device, mode, 1.0)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.write_configuration(device, replace(before, joycon_mode=mode), 1.0)
|
||||
assert not device.out_requests
|
||||
|
||||
|
||||
def test_joycon_mode_cli_commits_and_reads_without_reboot_or_profile_changes(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = 4
|
||||
identity = native_rumble_identity()
|
||||
device.configuration = native_rumble_configuration((identity,))
|
||||
previous_profiles = dict(device.profiles)
|
||||
previous_active = dict(device.active_profiles)
|
||||
previous_aliases = dict(device.profile_aliases)
|
||||
previous_names = dict(device.profile_names)
|
||||
previous_pairings = list(device.records)
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
|
||||
for generation, mode in enumerate(("individual", "paired"), start=4):
|
||||
assert config_manager.main(["joycon-mode", mode, "--json"]) == 0
|
||||
expected = {"mode": mode, "generation": generation, "supported": True}
|
||||
assert json.loads(capsys.readouterr().out) == expected
|
||||
stored = config_manager.read_configuration(device)
|
||||
assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode)
|
||||
assert stored.pairing_window_seconds == 90
|
||||
assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT
|
||||
assert stored.native_switch_controllers == (identity,)
|
||||
assert device.configuration == native_rumble_configuration(
|
||||
(identity,), config_manager.JOYCON_MODE_NAMES.index(mode)
|
||||
)
|
||||
writes_before_read = tuple(device.out_requests)
|
||||
assert config_manager.main(["joycon-mode", "--json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out) == expected
|
||||
assert tuple(device.out_requests) == writes_before_read
|
||||
assert device.profiles == previous_profiles
|
||||
assert device.active_profiles == previous_active
|
||||
assert device.profile_aliases == previous_aliases
|
||||
assert device.profile_names == previous_names
|
||||
assert device.records == previous_pairings
|
||||
assert not device.reboot_transaction_ids
|
||||
assert not device.bootsel_reboot_requested
|
||||
|
||||
|
||||
def test_joycon_mode_cli_commit_failure_does_not_claim_new_preference(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration_schema = 4
|
||||
device.configuration = native_rumble_configuration((native_rumble_identity(),))
|
||||
before = config_manager.read_configuration(device)
|
||||
device.fail_configuration_commit_status = 8
|
||||
monkeypatch.setattr(config_manager, "_candidate_devices", lambda: (device,))
|
||||
assert config_manager.main(["joycon-mode", "individual", "--json"]) == 1
|
||||
assert capsys.readouterr().out == ""
|
||||
assert config_manager.read_configuration(device) == before
|
||||
assert not device.reboot_transaction_ids
|
||||
|
||||
|
||||
def test_configuration_transaction_ids_stay_in_host_range(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ def test_configuration_service_native(tmp_path: Path) -> None:
|
|||
for scenario in (
|
||||
"lifecycle",
|
||||
"v2-migration",
|
||||
"v3-migration",
|
||||
"native-approvals",
|
||||
"abandoned-receive",
|
||||
):
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ from dataclasses import replace
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import usb.core
|
||||
|
||||
from switch_pico_bridge import config_manager, profile_web
|
||||
from tests.test_config_manager import FakeDevice, custom_profile
|
||||
from tests.test_config_manager import FakeDevice, custom_profile, native_rumble_identity
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -604,6 +605,184 @@ def test_editor_rejects_invalid_or_unauthorized_mutations(
|
|||
assert device.profiles[(device.global_identity.to_bytes(), 0)] == original
|
||||
|
||||
|
||||
def test_joycon_mode_preserves_configuration_and_all_profile_banks(
|
||||
monkeypatch: pytest.MonkeyPatch, joycon_pair_device: FakeDevice,
|
||||
) -> None:
|
||||
device = joycon_pair_device
|
||||
config_manager.write_configuration(
|
||||
device,
|
||||
config_manager.AdapterConfiguration(
|
||||
135, 0, 0, config_manager.REQUESTED_MODE_XINPUT,
|
||||
(native_rumble_identity(),),
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
for index, identity in enumerate(device.profile_identities):
|
||||
key = identity.to_bytes()
|
||||
device.profiles[(key, index)] = custom_profile().to_bytes()
|
||||
device.profile_names[(key, index)] = f"Layout {index}"
|
||||
device.profile_aliases[key] = f"Controller {index}"
|
||||
before = config_manager.read_configuration(device)
|
||||
profiles = device.profiles.copy()
|
||||
names = device.profile_names.copy()
|
||||
aliases = device.profile_aliases.copy()
|
||||
active = device.active_profiles.copy()
|
||||
pairings = config_manager.read_pairings(device)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, initial = request_json(f"{base_url}/api/joycon-mode")
|
||||
assert status == 200
|
||||
assert initial == {
|
||||
"mode": "paired", "generation": before.generation, "supported": True,
|
||||
}
|
||||
for mode in ("individual", "paired"):
|
||||
status, committed = request_json(
|
||||
f"{base_url}/api/joycon-mode", method="PUT",
|
||||
value={"mode": mode}, token=token,
|
||||
)
|
||||
assert status == 200
|
||||
stored = config_manager.read_configuration(device)
|
||||
assert committed == {
|
||||
"mode": mode, "generation": stored.generation, "supported": True,
|
||||
}
|
||||
assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode)
|
||||
assert stored.generation > before.generation
|
||||
assert replace(
|
||||
stored, joycon_mode=before.joycon_mode,
|
||||
generation=before.generation, crc=before.crc,
|
||||
) == before
|
||||
assert request_json(f"{base_url}/api/joycon-mode") == (200, committed)
|
||||
assert device.profiles == profiles
|
||||
assert device.profile_names == names
|
||||
assert device.profile_aliases == aliases
|
||||
assert device.active_profiles == active
|
||||
assert config_manager.read_pairings(device) == pairings
|
||||
assert device.reboot_transaction_ids == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema", [1, 2, 3])
|
||||
def test_joycon_mode_legacy_reads_default_and_refuses_mutations(
|
||||
monkeypatch: pytest.MonkeyPatch, schema: int,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
config_manager.write_configuration(
|
||||
device,
|
||||
config_manager.AdapterConfiguration(
|
||||
95, 0, 0,
|
||||
native_switch_controllers=(native_rumble_identity(),) if schema == 3 else (),
|
||||
schema_version=schema,
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
before = config_manager.read_configuration(device)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, legacy = request_json(f"{base_url}/api/joycon-mode")
|
||||
assert status == 200
|
||||
assert legacy == {
|
||||
"mode": "paired", "generation": before.generation, "supported": False,
|
||||
}
|
||||
for mode in ("paired", "individual"):
|
||||
status, _ = request_json(
|
||||
f"{base_url}/api/joycon-mode", method="PUT",
|
||||
value={"mode": mode}, token=token,
|
||||
)
|
||||
assert status == 400
|
||||
assert config_manager.read_configuration(device) == before
|
||||
|
||||
|
||||
def test_joycon_mode_rejects_unauthorized_and_malformed_requests(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
config_manager.write_configuration(
|
||||
device, config_manager.AdapterConfiguration(110, 0, 0), 1.0,
|
||||
)
|
||||
before = config_manager.read_configuration(device)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
endpoint = f"{base_url}/api/joycon-mode"
|
||||
for supplied_token in (None, "invalid"):
|
||||
status, _ = request_json(
|
||||
endpoint, method="PUT", value={"mode": "individual"},
|
||||
token=supplied_token,
|
||||
)
|
||||
assert status == 403
|
||||
for body in (
|
||||
{}, [], {"mode": 1}, {"mode": True}, {"mode": None},
|
||||
{"mode": ["individual"]}, {"mode": "Individual"},
|
||||
{"mode": "individual", "pairing_window_seconds": 10},
|
||||
):
|
||||
status, _ = request_json(
|
||||
endpoint, method="PUT", value=body, token=token,
|
||||
)
|
||||
assert status == 400
|
||||
for raw in (b"", b"{", b"\xff", b" " * (profile_web._MAXIMUM_REQUEST_BYTES + 1)):
|
||||
request = urllib.request.Request(
|
||||
endpoint, data=raw, method="PUT",
|
||||
headers={"X-Switch-Pico-Token": token},
|
||||
)
|
||||
with pytest.raises(urllib.error.HTTPError) as rejected:
|
||||
urllib.request.urlopen(request, timeout=2)
|
||||
assert rejected.value.code == 400
|
||||
for method in ("GET", "PUT"):
|
||||
request = urllib.request.Request(
|
||||
endpoint, method=method,
|
||||
data=b'{"mode":"individual"}' if method == "PUT" else None,
|
||||
headers={"Host": "untrusted.example", "X-Switch-Pico-Token": token},
|
||||
)
|
||||
with pytest.raises(urllib.error.HTTPError) as rejected:
|
||||
urllib.request.urlopen(request, timeout=2)
|
||||
assert rejected.value.code == 403
|
||||
assert config_manager.read_configuration(device) == before
|
||||
|
||||
|
||||
def test_joycon_mode_commit_is_not_reported_as_confirmed_when_readback_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
config_manager.write_configuration(
|
||||
device, config_manager.AdapterConfiguration(110, 0, 0), 1.0,
|
||||
)
|
||||
read_configuration = config_manager.read_configuration
|
||||
before = read_configuration(device)
|
||||
|
||||
def disconnect_after_commit(device: config_manager.UsbDevice) -> config_manager.AdapterConfiguration:
|
||||
configuration = read_configuration(device)
|
||||
if configuration.generation != before.generation:
|
||||
raise usb.core.USBError("device disconnected")
|
||||
return configuration
|
||||
|
||||
monkeypatch.setattr(config_manager, "read_configuration", disconnect_after_commit)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, response = request_json(
|
||||
f"{base_url}/api/joycon-mode", method="PUT",
|
||||
value={"mode": "individual"}, token=token,
|
||||
)
|
||||
assert status == 503
|
||||
assert "error" in response
|
||||
assert "mode" not in response
|
||||
assert read_configuration(device).joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "PUT"])
|
||||
def test_joycon_mode_usb_failure_returns_service_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch, method: str,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
before = device.configuration
|
||||
|
||||
def disconnected(*args: Any, **kwargs: Any) -> Any:
|
||||
raise usb.core.USBError("device disconnected")
|
||||
|
||||
monkeypatch.setattr(device, "ctrl_transfer", disconnected)
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, response = request_json(
|
||||
f"{base_url}/api/joycon-mode", method=method,
|
||||
value={"mode": "individual"} if method == "PUT" else None, token=token,
|
||||
)
|
||||
assert status == 503
|
||||
assert "error" in response
|
||||
assert device.configuration == before
|
||||
|
||||
|
||||
def test_recorder_accepts_first_connection_generation_zero(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -302,6 +302,7 @@ void test_mode_vendor_requests() {
|
|||
using namespace UsbConfigurationManagement;
|
||||
current_configuration.configuration.requested_mode =
|
||||
AdapterRequestedMode::kXInput;
|
||||
current_configuration.configuration.joycon_mode = JoyConMode::kIndividual;
|
||||
current_active_mode = AdapterUsbMode::kSwitchProbe;
|
||||
|
||||
tusb_control_request_t request =
|
||||
|
|
@ -339,8 +340,10 @@ void test_mode_vendor_requests() {
|
|||
control_payload[10] ==
|
||||
ADAPTER_CONFIGURATION_SCHEMA_VERSION &&
|
||||
control_payload[kResponseHeaderSize + 2] ==
|
||||
static_cast<uint8_t>(AdapterRequestedMode::kXInput),
|
||||
"configuration response did not keep requested mode separate");
|
||||
static_cast<uint8_t>(AdapterRequestedMode::kXInput) &&
|
||||
control_payload[kResponseHeaderSize + 4] ==
|
||||
static_cast<uint8_t>(JoyConMode::kIndividual),
|
||||
"configuration response did not report the saved USB and player modes");
|
||||
|
||||
std::vector<uint8_t> mode_set(5);
|
||||
write_u32(&mode_set, 0, 0x12345678);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue