feat: preserve native HD rumble on Switch 2 controllers
This commit is contained in:
parent
a87917bf0b
commit
006b573ee8
25 changed files with 1722 additions and 60 deletions
|
|
@ -732,6 +732,23 @@ Set B delivery evidence:
|
|||
qualification remain separate from those software results.
|
||||
- Full operation and limitations: [README.md](README.md#switch-2-controller-input).
|
||||
|
||||
Switch 2 native HD output now preserves decoded stereo frequency/amplitude
|
||||
and up to three subframes, using microphone-verified 10-bit frequency fields
|
||||
and `0x50/0x60/0x70` sample-count headers. A 90-packet timing run measured
|
||||
approximately 5.3 ms per frame; the sender guards 6/11/16 ms for complete
|
||||
batches. Two bounded queue stages retain receipt timestamps; obsolete work
|
||||
is counted and discarded rather than replayed or allowed to starve fresh work.
|
||||
Keepalives/feedback resumption retain only the current final sample.
|
||||
|
||||
The final Pro-only native run sustained 512 changing one-subframe commands at
|
||||
125.14 Hz with zero queue drops. Stereo/sweep/three-subframe patterns also
|
||||
recorded zero drops. Deliberate three-subframe saturation at 125.11 Hz
|
||||
discarded/superseded 69 output-stage commands out of 128 host updates and
|
||||
recovered to a clean stop. No mixed-radio lossless claim is made.
|
||||
Verification is now 289 passing tests and all five firmware builds; all
|
||||
40 stored profiles and metadata survived. See the
|
||||
[native HD contract and measurements](README.md#switch-2-native-hd-rumble).
|
||||
|
||||
### Native Switch-family HD rumble — Implemented, qualification incomplete
|
||||
|
||||
Standalone agent handoff: [SWITCH_FAMILY_HD_RUMBLE_PLAN.md](SWITCH_FAMILY_HD_RUMBLE_PLAN.md).
|
||||
|
|
|
|||
|
|
@ -227,7 +227,8 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32")
|
|||
)
|
||||
target_sources(bluepad32 PRIVATE
|
||||
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_hid_parser_switch2.c
|
||||
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c)
|
||||
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_pairing.c
|
||||
${CMAKE_CURRENT_LIST_DIR}/bluepad32_config/parser/uni_switch2_haptics.c)
|
||||
endif()
|
||||
|
||||
# Firmware sources live under one include root and are grouped by responsibility.
|
||||
|
|
|
|||
77
README.md
77
README.md
|
|
@ -266,6 +266,12 @@ build services one CYW43 packet per poll and explicitly reschedules remaining
|
|||
input. Packet-level ring reads and bounded incoming-credit batching reduce
|
||||
bus work without disabling flow control. `haptics-experiment profile --json`
|
||||
adds transport timings, clock/voltage settings and packet-size diagnostics.
|
||||
Switch 2 native output adds separate ingress and output-stage drop counters.
|
||||
The management response extends from 32 to 40 bytes; the updated host tool
|
||||
still reads older 32-byte responses and treats their missing counters as
|
||||
unreported, not zero. These count firmware queue discards/rejections, not
|
||||
physical actuator-delivery receipts.
|
||||
|
||||
|
||||
### Per-controller profiles
|
||||
|
||||
|
|
@ -333,15 +339,74 @@ The AIO firmware implements the proprietary BLE protocol for Nintendo `057E:2069
|
|||
- **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 automatically joins a solo half. Either connection order works; the first-ready player slot is retained, with the left controller's identity/profile owning the pair. The right half supplies motion. A disconnected half's inputs and pending effects are removed immediately; the surviving half returns to sideways solo operation and its own profile identity. 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.
|
||||
- **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:** strong/weak amplitudes feed fixed low/high carriers, repeated in three-frame HOLD packets on an approximately 13 ms cadence. Keepalives retain active effects instead of silencing them; finite effects expire, XInput held effects persist until replaced/stopped, and teardown cancels output. Pro output mirrors the same two-band effect to both actuators. This does **not** preserve Nintendo HD substeps or independent left/right HD effects and does not use the original Switch-native opt-in backend.
|
||||
- **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:** 287 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 tests cover both Joy-Con connection orders, multiple pairs, detach/replacement and pairing-policy isolation. The editor's extra/Shift mappings were exercised in Chromium.
|
||||
**Verification:** 289 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 tests cover both Joy-Con connection orders, multiple pairs, detach/replacement and pairing-policy isolation. The editor's extra/Shift mappings were exercised in Chromium.
|
||||
|
||||
On the flashed Pico, a real Switch 2 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 on hardware; all 32 pre-existing profiles, metadata and active selections were compared against a pre-flash backup and preserved, with adapter configuration generation 13 / CRC `3af5ee18` unchanged. Physical rumble feel, Joy-Con 2 pair behavior, long-duration reconnect and mixed-controller transport remain hardware qualification items. Use schema-7-capable firmware after saving expanded profiles.
|
||||
|
||||
### Switch 2 native HD rumble
|
||||
|
||||
Switch-mode host commands use decoded HD parameters, not the compatibility
|
||||
strong/weak peak values. Pro output preserves two independent actuators;
|
||||
paired Joy-Con 2 output routes each source side to its physical half.
|
||||
A solo Joy-Con uses the louder source independently for each band, retaining
|
||||
that band's frequency, with left winning ties and shorter sequences holding
|
||||
their final sample. Profiles scale amplitudes before this conversion.
|
||||
|
||||
Physical microphone characterization on the Pro Controller established:
|
||||
|
||||
- Each five-byte sample contains two **10-bit frequency + 10-bit amplitude**
|
||||
fields. The measured frequency model is
|
||||
`Hz ~= 10 * 2^((code - 1) / 96)`.
|
||||
Original low/high indices map to `193 + 3*index` / `289 + 3*index`;
|
||||
index 64 therefore produces codes 385/481 (160/320 Hz).
|
||||
- Block headers `0x50`, `0x60`, `0x70`, plus the four-bit sequence counter,
|
||||
select **one, two or three** valid samples. Unused slots are zeroed.
|
||||
Filling three slots under `0x50` does not play the later slots.
|
||||
- A randomized 90-packet run using the other actuator as an acoustic timing
|
||||
reference measured **5.27 ms/frame, ±0.16 ms statistical 95% interval**.
|
||||
Acoustic/threshold systematic error is not included. The sender uses
|
||||
conservative **6/11/16 ms** submission guards, not a claim of exact onset.
|
||||
|
||||
Linear Q0.15 amplitudes use SDL's conservative `29000/65535` envelope, producing
|
||||
native codes 0–453. This preserves a linear input curve but is not calibrated
|
||||
physical-force equivalence; it can feel different from compatibility rumble.
|
||||
Source frequency indices are bounded to 1–127.
|
||||
|
||||
Each logical slot has a 16-command cross-core ingress FIFO; each physical
|
||||
Switch 2 controller has a 16-command transport FIFO. Commands keep their
|
||||
original receipt time and connection/output generation. Native commands expire
|
||||
after 50 ms; a batch that cannot fit its complete playback guard before that
|
||||
deadline is discarded rather than started halfway stale. Expiring unplayed
|
||||
history does not interrupt current playback or force a useless HOLD ahead of
|
||||
fresh work. Consecutive identical one-sample holds may refresh a pending
|
||||
command; multi-sample sequences are never coalesced.
|
||||
|
||||
Stops flush older host work, including under backpressure. Local feedback owns
|
||||
a separate bounded override while host state advances underneath it; resuming
|
||||
uses the current valid final sample, not a replay of masked history.
|
||||
Keepalives likewise send only the final sample with count 1. Pending ATT
|
||||
write-request buffers remain immutable; late completion cannot resurrect an
|
||||
old epoch after stop, reconnect or Joy-Con topology change.
|
||||
|
||||
Final single-Pro hardware runs:
|
||||
|
||||
| Workload | Result |
|
||||
|---|---|
|
||||
| Stereo, frequency sweep, and three-subframe patterns (195 USB reports) | Zero ingress/output drops; 1,193 input reports continued |
|
||||
| 512 changing one-subframe commands at 125.14 Hz | Zero ingress/output drops; clean stop |
|
||||
| 128 changing three-subframe commands at 125.11 Hz | Zero ingress drops; 69 output-stage commands discarded/superseded; clean stop and empty ingress |
|
||||
|
||||
Three-subframe commands at 125 Hz exceed the native playback budget. These
|
||||
results do **not** establish lossless arbitrary workloads or mixed-controller
|
||||
radio performance. Joy-Con hardware and perceptual equivalence remain separate
|
||||
qualification items. All 40 profiles, names, active selections and adapter
|
||||
configuration were preserved during this upgrade.
|
||||
|
||||
### Rumble per controller
|
||||
|
||||
Commands remain bound to a USB slot and Bluetooth connection generation. Compatibility output uses a latest-value mailbox; native output keeps a bounded timestamped command history instead of collapsing substeps.
|
||||
|
|
@ -805,15 +870,15 @@ linked binary, not from the larger debug-bearing ELF or UF2 transport file:
|
|||
|
||||
| Resource | Used or reserved | Device capacity |
|
||||
|---|---:|---:|
|
||||
| Executable flash image | 806,872 bytes | 4 MiB |
|
||||
| Executable flash image | 815,192 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 | 141,984 bytes | 520 KiB |
|
||||
| Allocated/reserved SRAM, including heap and stacks | 150,824 bytes | 520 KiB |
|
||||
|
||||
The executable plus persistent reservations consume 1,089,496 bytes of flash,
|
||||
leaving 3,104,808 bytes. Allocated SRAM sections leave 390,496 bytes of link-time
|
||||
The executable plus persistent reservations consume 1,097,816 bytes of flash,
|
||||
leaving 3,096,488 bytes. Allocated SRAM sections leave 381,656 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.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
#include "parser/uni_hid_parser_switch2.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdatomic.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <btstack.h>
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
|
||||
#define SW2_TIMEOUT_MS 2000
|
||||
#define SW2_OUTPUT_INTERVAL_MS 13
|
||||
#define SW2_HAPTICS_CAPACITY 16
|
||||
#define SW2_REPORT_SIZE 63
|
||||
#define SW2_ACK 0x78
|
||||
#define SW2_CCCD_UUID 0x2902
|
||||
|
|
@ -61,6 +63,16 @@ typedef struct {
|
|||
uint16_t positive[2];
|
||||
uint16_t negative[2];
|
||||
} sw2_stick_t;
|
||||
typedef struct {
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
uint32_t expires, serial;
|
||||
bool held, native;
|
||||
} sw2_host_command_t;
|
||||
typedef struct {
|
||||
uint8_t sample[5];
|
||||
uint32_t expires;
|
||||
bool valid, held;
|
||||
} sw2_host_side_t;
|
||||
typedef struct {
|
||||
uni_hid_device_t* device;
|
||||
bd_addr_t address;
|
||||
|
|
@ -92,6 +104,15 @@ typedef struct {
|
|||
uint8_t rumble_id, weak, strong;
|
||||
bool rumble_scheduled, rumble_held;
|
||||
uint32_t rumble_start, rumble_end;
|
||||
sw2_host_command_t host_queue[SW2_HAPTICS_CAPACITY];
|
||||
sw2_host_side_t host_sides[2];
|
||||
uint8_t host_head, host_count;
|
||||
uint32_t host_serial, haptics_epoch, output_revision;
|
||||
uint32_t pending_serial, pending_epoch, pending_revision;
|
||||
uint32_t playback_until;
|
||||
uint8_t pending_guard_ms;
|
||||
bool pending_host, pending_barrier, barrier_pending, output_urgent;
|
||||
bool feedback_active, playback_guard;
|
||||
uint32_t sensor_start, sensor_host_start, sensor_last;
|
||||
uint8_t sensor_warmup;
|
||||
int32_t gyro_full_scale;
|
||||
|
|
@ -100,11 +121,14 @@ typedef struct {
|
|||
// Separate bounded storage: never squeeze transport resources into parser_data
|
||||
// (256 bytes). Retired buffers are not reused until their old BLE link is gone.
|
||||
static sw2_instance_t sw2_instances[CONFIG_BLUEPAD32_MAX_DEVICES];
|
||||
static atomic_uint_least32_t sw2_haptics_drops;
|
||||
|
||||
static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* packet, uint16_t size);
|
||||
static void sw2_output_tick(btstack_timer_source_t* timer);
|
||||
static void sw2_continue(sw2_instance_t* ins);
|
||||
static void sw2_complete_command(sw2_instance_t* ins);
|
||||
static void sw2_rumble_complete(sw2_instance_t* ins);
|
||||
static void sw2_discard_host(sw2_instance_t* ins);
|
||||
|
||||
static bool sw2_product(uint16_t pid) {
|
||||
return pid == UNI_SW2_PRO_PID || pid == UNI_SW2_JOYCON_L_PID || pid == UNI_SW2_JOYCON_R_PID;
|
||||
|
|
@ -149,6 +173,8 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) {
|
|||
if (ins->input_listening)
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&ins->input_listener);
|
||||
ins->response_listening = ins->input_listening = false;
|
||||
sw2_discard_host(ins);
|
||||
++ins->haptics_epoch;
|
||||
ins->command_pending = ins->rumble_scheduled = false;
|
||||
ins->extra_buttons = 0;
|
||||
ins->state = SW2_OFF;
|
||||
|
|
@ -470,11 +496,13 @@ static void sw2_query_complete(sw2_instance_t* ins, uint8_t status) {
|
|||
return;
|
||||
}
|
||||
if (query == SW2_QUERY_RUMBLE) {
|
||||
++ins->rumble_id;
|
||||
sw2_rumble_complete(ins);
|
||||
// A command queued behind this write has its own timeout already.
|
||||
if (!ins->command_pending)
|
||||
sw2_disarm_timeout(ins);
|
||||
sw2_try_command(ins);
|
||||
if (sw2_live(ins))
|
||||
sw2_schedule_output(ins, 1);
|
||||
return;
|
||||
}
|
||||
sw2_disarm_timeout(ins);
|
||||
|
|
@ -881,37 +909,301 @@ void uni_hid_parser_switch2_set_player_leds(uni_hid_device_t* d, uint8_t leds) {
|
|||
sw2_continue(ins);
|
||||
}
|
||||
|
||||
static void sw2_rumble_block(uint8_t* out, uint8_t id, uint8_t weak, uint8_t strong) {
|
||||
// Three consecutive equal frames form a safe sustained HOLD. Weak controls
|
||||
// the high-frequency amplitude, strong the low-frequency amplitude.
|
||||
uint64_t frame = 0x0e1u | ((uint64_t)strong * 4 << 10) | ((uint64_t)0x1e1 << 20) |
|
||||
((uint64_t)weak * 4 << 30);
|
||||
out[0] = 0x50 | (id & 15);
|
||||
for (unsigned i = 0; i < 5; ++i)
|
||||
out[1 + i] = (uint8_t)(frame >> (8 * i));
|
||||
memcpy(out + 6, out + 1, 5);
|
||||
memcpy(out + 11, out + 1, 5);
|
||||
uint32_t uni_hid_parser_switch2_haptics_dropped(void) {
|
||||
return atomic_load_explicit(&sw2_haptics_drops, memory_order_relaxed);
|
||||
}
|
||||
|
||||
static void sw2_send_rumble(sw2_instance_t* ins, uint32_t now) {
|
||||
if (ins->query != SW2_QUERY_NONE || ins->command_pending)
|
||||
return;
|
||||
uint8_t weak = 0, strong = 0;
|
||||
if (ins->rumble_scheduled) {
|
||||
if (!ins->rumble_held && (int32_t)(now - ins->rumble_end) >= 0)
|
||||
ins->rumble_scheduled = false;
|
||||
else if ((int32_t)(now - ins->rumble_start) >= 0) {
|
||||
weak = ins->weak;
|
||||
strong = ins->strong;
|
||||
static void sw2_count_drops(unsigned count) {
|
||||
atomic_fetch_add_explicit(&sw2_haptics_drops, count, memory_order_relaxed);
|
||||
}
|
||||
|
||||
static bool sw2_due(uint32_t now, uint32_t deadline) {
|
||||
return (int32_t)(now - deadline) >= 0;
|
||||
}
|
||||
|
||||
static unsigned sw2_output_sides(const sw2_instance_t* ins) {
|
||||
return ins->device->product_id == UNI_SW2_PRO_PID ? 2 : 1;
|
||||
}
|
||||
|
||||
static void sw2_pop_host(sw2_instance_t* ins) {
|
||||
ins->host_head = (ins->host_head + 1) % SW2_HAPTICS_CAPACITY;
|
||||
--ins->host_count;
|
||||
}
|
||||
|
||||
static void sw2_discard_host(sw2_instance_t* ins) {
|
||||
sw2_count_drops(ins->host_count);
|
||||
ins->host_head = ins->host_count = 0;
|
||||
memset(ins->host_sides, 0, sizeof(ins->host_sides));
|
||||
}
|
||||
|
||||
static void sw2_retain_host(sw2_instance_t* ins, const sw2_host_command_t* command) {
|
||||
for (unsigned i = 0; i < sw2_output_sides(ins); ++i) {
|
||||
const uni_switch2_haptics_side_t* side = &command->frame.sides[i];
|
||||
if (!side->count)
|
||||
continue;
|
||||
sw2_host_side_t* retained = &ins->host_sides[i];
|
||||
memcpy(retained->sample, side->samples[side->count - 1], sizeof(retained->sample));
|
||||
retained->expires = command->expires;
|
||||
retained->held = command->held;
|
||||
retained->valid = true;
|
||||
}
|
||||
}
|
||||
|
||||
static bool sw2_local_active(const sw2_instance_t* ins, uint32_t now) {
|
||||
return ins->rumble_scheduled && sw2_due(now, ins->rumble_start) &&
|
||||
(ins->rumble_held || !sw2_due(now, ins->rumble_end));
|
||||
}
|
||||
|
||||
// Logical host time keeps advancing even when ATT or the local overlay owns
|
||||
// the physical output. Masked sequences become final holds, never a replay log.
|
||||
static void sw2_update_haptics(sw2_instance_t* ins, uint32_t now) {
|
||||
bool active = sw2_local_active(ins, now);
|
||||
bool masked = active || ins->feedback_active;
|
||||
while (ins->host_count) {
|
||||
sw2_host_command_t* command = &ins->host_queue[ins->host_head];
|
||||
if (!command->held && sw2_due(now, command->expires)) {
|
||||
sw2_count_drops(1);
|
||||
sw2_pop_host(ins);
|
||||
// Unplayed history is not a reason to interrupt the current packet.
|
||||
// A matching in-flight packet can still complete after its expiry.
|
||||
if (!active && ins->pending_host &&
|
||||
ins->pending_epoch == ins->haptics_epoch &&
|
||||
ins->pending_serial == command->serial) {
|
||||
ins->output_urgent = true;
|
||||
++ins->output_revision;
|
||||
}
|
||||
} else if (masked) {
|
||||
sw2_retain_host(ins, command);
|
||||
sw2_pop_host(ins);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ins->rumble_data[0] = 0;
|
||||
sw2_rumble_block(ins->rumble_data + 1, ins->rumble_id, weak, strong);
|
||||
uint16_t length = 17;
|
||||
if (ins->device->product_id == UNI_SW2_PRO_PID) {
|
||||
memcpy(ins->rumble_data + 17, ins->rumble_data + 1, 16);
|
||||
length = 33;
|
||||
if (masked)
|
||||
ins->barrier_pending = false;
|
||||
for (unsigned i = 0; i < sw2_output_sides(ins); ++i) {
|
||||
sw2_host_side_t* side = &ins->host_sides[i];
|
||||
if (side->valid && !side->held && sw2_due(now, side->expires)) {
|
||||
side->valid = false;
|
||||
if (!active) {
|
||||
ins->output_urgent = true;
|
||||
++ins->output_revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ins->feedback_active != active) {
|
||||
if (!active)
|
||||
ins->output_urgent = true; // Resume only the current per-side hold.
|
||||
++ins->output_revision;
|
||||
}
|
||||
ins->feedback_active = active;
|
||||
if (ins->rumble_scheduled && !ins->rumble_held && sw2_due(now, ins->rumble_end))
|
||||
ins->rumble_scheduled = false;
|
||||
}
|
||||
|
||||
static void sw2_compat_side(uni_switch2_haptics_side_t* side, uint8_t weak, uint8_t strong) {
|
||||
// Preserve conventional/local frequency and strength, but advertise exactly
|
||||
// one sample. The other ten bytes are padding, not repeated substeps.
|
||||
uint64_t frame = 0x0e1u | ((uint64_t)strong * 4 << 10) | ((uint64_t)0x1e1 << 20) |
|
||||
((uint64_t)weak * 4 << 30);
|
||||
memset(side, 0, sizeof(*side));
|
||||
side->count = 1;
|
||||
for (unsigned i = 0; i < 5; ++i)
|
||||
side->samples[0][i] = (uint8_t)(frame >> (8 * i));
|
||||
}
|
||||
|
||||
static void sw2_host_hold(const sw2_instance_t* ins, uni_switch2_haptics_frame_t* frame) {
|
||||
uni_switch2_haptics_silence(frame);
|
||||
for (unsigned i = 0; i < sw2_output_sides(ins); ++i) {
|
||||
if (ins->host_sides[i].valid)
|
||||
memcpy(frame->sides[i].samples[0], ins->host_sides[i].sample, 5);
|
||||
}
|
||||
}
|
||||
|
||||
static void sw2_host_stop(sw2_instance_t* ins) {
|
||||
sw2_update_haptics(ins, btstack_run_loop_get_time_ms());
|
||||
sw2_discard_host(ins);
|
||||
++ins->haptics_epoch;
|
||||
++ins->output_revision;
|
||||
ins->barrier_pending = true;
|
||||
ins->output_urgent = true;
|
||||
sw2_schedule_output(ins, 1);
|
||||
}
|
||||
|
||||
static bool sw2_physical_stop(const sw2_instance_t* ins, const uni_switch2_haptics_frame_t* frame) {
|
||||
for (unsigned i = 0; i < sw2_output_sides(ins); ++i) {
|
||||
const uni_switch2_haptics_side_t* side = &frame->sides[i];
|
||||
if (!side->count)
|
||||
return false;
|
||||
for (unsigned j = 0; j < side->count; ++j) {
|
||||
const uint8_t* sample = side->samples[j];
|
||||
// Amplitudes occupy bits10..19 and30..39 of each 40-bit sample.
|
||||
if ((sample[1] & 0xfc) || (sample[2] & 0x0f) || (sample[3] & 0xc0) || sample[4])
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool sw2_same_hold(const uni_switch2_haptics_frame_t* a, const uni_switch2_haptics_frame_t* b) {
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (a->sides[i].count > 1 || a->sides[i].count != b->sides[i].count)
|
||||
return false;
|
||||
if (a->sides[i].count && memcmp(a->sides[i].samples[0], b->sides[i].samples[0], 5) != 0)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool sw2_queue_host(sw2_instance_t* ins, const uni_switch2_haptics_frame_t* frame,
|
||||
uint32_t received_ms, uint16_t duration_ms, bool native) {
|
||||
uint32_t now = btstack_run_loop_get_time_ms();
|
||||
sw2_update_haptics(ins, now);
|
||||
bool held = duration_ms == UINT16_MAX;
|
||||
uint32_t lifetime = duration_ms < UNI_SWITCH2_HAPTICS_WATCHDOG_MS ?
|
||||
duration_ms : UNI_SWITCH2_HAPTICS_WATCHDOG_MS;
|
||||
uint32_t expires = received_ms + lifetime;
|
||||
if (!held && sw2_due(now, expires)) {
|
||||
sw2_count_drops(1);
|
||||
return true;
|
||||
}
|
||||
if (ins->host_count) {
|
||||
unsigned previous = (ins->host_head + ins->host_count - 1) % SW2_HAPTICS_CAPACITY;
|
||||
sw2_host_command_t* command = &ins->host_queue[previous];
|
||||
if (command->native == native && sw2_same_hold(&command->frame, frame)) {
|
||||
// Same ordered hold, new source lifetime. Keep its serial and wire
|
||||
// bytes intact even when ATT is still borrowing this queue head.
|
||||
command->expires = expires;
|
||||
command->held = held;
|
||||
sw2_schedule_output(ins, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (ins->host_count == SW2_HAPTICS_CAPACITY)
|
||||
return false;
|
||||
unsigned tail = (ins->host_head + ins->host_count) % SW2_HAPTICS_CAPACITY;
|
||||
ins->host_queue[tail] = (sw2_host_command_t){
|
||||
.frame = *frame, .expires = expires, .serial = ++ins->host_serial, .held = held, .native = native,
|
||||
};
|
||||
++ins->host_count;
|
||||
sw2_schedule_output(ins, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool uni_hid_parser_switch2_queue_haptics(uni_hid_device_t* d,
|
||||
const uni_switch2_haptics_frame_t* frame, uint32_t received_ms) {
|
||||
sw2_instance_t* ins = sw2_instance(d);
|
||||
if (!ins || ins->state != SW2_READY)
|
||||
return false;
|
||||
if (!uni_switch2_haptics_valid(frame) ||
|
||||
(sw2_output_sides(ins) == 1 && !frame->sides[0].count)) {
|
||||
sw2_count_drops(1);
|
||||
return true;
|
||||
}
|
||||
if (sw2_physical_stop(ins, frame)) {
|
||||
sw2_host_stop(ins); // A stop is a barrier, including when full or stale.
|
||||
return true;
|
||||
}
|
||||
return sw2_queue_host(ins, frame, received_ms, UNI_SWITCH2_HAPTICS_WATCHDOG_MS, true);
|
||||
}
|
||||
|
||||
bool uni_hid_parser_switch2_queue_rumble(uni_hid_device_t* d, uint8_t weak, uint8_t strong,
|
||||
uint16_t duration_ms, uint32_t received_ms) {
|
||||
sw2_instance_t* ins = sw2_instance(d);
|
||||
if (!ins || ins->state != SW2_READY)
|
||||
return false;
|
||||
if ((!weak && !strong) || !duration_ms) {
|
||||
sw2_host_stop(ins);
|
||||
return true;
|
||||
}
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
sw2_compat_side(&frame.sides[0], weak, strong);
|
||||
frame.sides[1] = frame.sides[0];
|
||||
return sw2_queue_host(ins, &frame, received_ms, duration_ms, false);
|
||||
}
|
||||
|
||||
void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* d) {
|
||||
sw2_instance_t* ins = sw2_instance(d);
|
||||
if (!ins)
|
||||
return;
|
||||
sw2_discard_host(ins);
|
||||
++ins->haptics_epoch;
|
||||
++ins->output_revision;
|
||||
ins->rumble_scheduled = ins->feedback_active = false;
|
||||
ins->barrier_pending = ins->output_urgent = true;
|
||||
// Do not touch rumble_data or pending metadata: ATT can still borrow them.
|
||||
if (ins->state == SW2_READY)
|
||||
sw2_schedule_output(ins, 1);
|
||||
}
|
||||
|
||||
static void sw2_rumble_complete(sw2_instance_t* ins) {
|
||||
uint32_t now = btstack_run_loop_get_time_ms();
|
||||
++ins->rumble_id; // Only a successful write consumes the physical sequence.
|
||||
ins->playback_until = now + ins->pending_guard_ms;
|
||||
ins->playback_guard = true;
|
||||
sw2_update_haptics(ins, now);
|
||||
if (ins->pending_epoch != ins->haptics_epoch)
|
||||
return;
|
||||
if (ins->pending_host && ins->host_count) {
|
||||
sw2_host_command_t* command = &ins->host_queue[ins->host_head];
|
||||
if (command->serial == ins->pending_serial) {
|
||||
sw2_retain_host(ins, command);
|
||||
sw2_pop_host(ins);
|
||||
}
|
||||
}
|
||||
if (ins->pending_barrier)
|
||||
ins->barrier_pending = false;
|
||||
if (ins->pending_revision == ins->output_revision)
|
||||
ins->output_urgent = false;
|
||||
}
|
||||
|
||||
static bool sw2_send_rumble(sw2_instance_t* ins, uint32_t now) {
|
||||
// Never overwrite the persistent packet while a write request borrows it.
|
||||
if (ins->query != SW2_QUERY_NONE || ins->command_pending)
|
||||
return false;
|
||||
if (ins->playback_guard && !sw2_due(now, ins->playback_until) && !ins->output_urgent)
|
||||
return false;
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
ins->pending_host = false;
|
||||
ins->pending_guard_ms = 6;
|
||||
unsigned sides = sw2_output_sides(ins);
|
||||
if (ins->feedback_active) {
|
||||
sw2_compat_side(&frame.sides[0], ins->weak, ins->strong);
|
||||
frame.sides[1] = frame.sides[0];
|
||||
} else {
|
||||
sw2_host_hold(ins, &frame);
|
||||
while (!ins->barrier_pending && ins->host_count) {
|
||||
const sw2_host_command_t* command = &ins->host_queue[ins->host_head];
|
||||
unsigned count = 1;
|
||||
for (unsigned i = 0; i < sides; ++i)
|
||||
if (command->frame.sides[i].count > count) count = command->frame.sides[i].count;
|
||||
uint8_t guard_ms = (count * 16 + 2) / 3;
|
||||
// Do not begin a native sequence that its original watchdog would
|
||||
// cut off halfway through. Skip it and keep servicing fresh work.
|
||||
if (command->native && !command->held &&
|
||||
(int32_t)(command->expires - now) < guard_ms) {
|
||||
sw2_count_drops(1);
|
||||
sw2_pop_host(ins);
|
||||
continue;
|
||||
}
|
||||
for (unsigned i = 0; i < sides; ++i) {
|
||||
if (command->frame.sides[i].count)
|
||||
frame.sides[i] = command->frame.sides[i];
|
||||
}
|
||||
ins->pending_guard_ms = guard_ms;
|
||||
ins->pending_host = true;
|
||||
ins->pending_serial = command->serial;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ins->pending_epoch = ins->haptics_epoch;
|
||||
ins->pending_revision = ins->output_revision;
|
||||
ins->pending_barrier = ins->barrier_pending;
|
||||
ins->rumble_data[0] = 0;
|
||||
for (unsigned i = 0; i < sides; ++i) {
|
||||
uni_switch2_haptics_write_block(ins->rumble_data + 1 + 16 * i, &frame.sides[i], ins->rumble_id);
|
||||
}
|
||||
uint16_t length = 1 + 16 * sides;
|
||||
bool no_response = (ins->rumble.properties & ATT_PROPERTY_WRITE_WITHOUT_RESPONSE) != 0;
|
||||
uint8_t status;
|
||||
if (no_response) {
|
||||
|
|
@ -919,21 +1211,26 @@ static void sw2_send_rumble(sw2_instance_t* ins, uint32_t now) {
|
|||
length, ins->rumble_data);
|
||||
} else {
|
||||
ins->query = SW2_QUERY_RUMBLE;
|
||||
if (!ins->command_pending)
|
||||
sw2_arm_timeout(ins);
|
||||
sw2_arm_timeout(ins);
|
||||
status = gatt_client_write_value_of_characteristic(sw2_gatt_handler, ins->handle, ins->rumble.value_handle,
|
||||
length, ins->rumble_data);
|
||||
}
|
||||
if (status == ERROR_CODE_SUCCESS) {
|
||||
if (no_response)
|
||||
++ins->rumble_id;
|
||||
} else {
|
||||
ins->query = SW2_QUERY_NONE;
|
||||
if (!ins->command_pending)
|
||||
sw2_disarm_timeout(ins);
|
||||
if (!sw2_transient_write_error(status))
|
||||
sw2_fail(ins, "rumble write failed", status);
|
||||
sw2_rumble_complete(ins);
|
||||
return true;
|
||||
}
|
||||
ins->query = SW2_QUERY_NONE;
|
||||
if (!ins->command_pending)
|
||||
sw2_disarm_timeout(ins);
|
||||
if (!sw2_transient_write_error(status))
|
||||
sw2_fail(ins, "rumble write failed", status);
|
||||
return false;
|
||||
}
|
||||
|
||||
static void sw2_next_boundary(uint32_t now, uint32_t boundary, uint32_t* next) {
|
||||
if (!sw2_due(now, boundary) && boundary - now < *next)
|
||||
*next = boundary - now;
|
||||
}
|
||||
|
||||
static void sw2_output_tick(btstack_timer_source_t* timer) {
|
||||
|
|
@ -947,15 +1244,24 @@ static void sw2_output_tick(btstack_timer_source_t* timer) {
|
|||
if (ins->state != SW2_READY)
|
||||
return; // A blocked setup command rescheduled itself, or awaits its ACK.
|
||||
uint32_t now = btstack_run_loop_get_time_ms();
|
||||
sw2_send_rumble(ins, now);
|
||||
sw2_update_haptics(ins, now);
|
||||
bool sent = sw2_send_rumble(ins, now);
|
||||
if (!ins->device)
|
||||
return;
|
||||
uint32_t next = SW2_OUTPUT_INTERVAL_MS;
|
||||
if (ins->rumble_scheduled && (!ins->rumble_held || (int32_t)(now - ins->rumble_start) < 0)) {
|
||||
uint32_t boundary = (int32_t)(now - ins->rumble_start) < 0 ? ins->rumble_start : ins->rumble_end;
|
||||
if ((int32_t)(boundary - now) > 0 && boundary - now < next)
|
||||
next = boundary - now;
|
||||
if (ins->playback_guard && (ins->host_count || !sent))
|
||||
sw2_next_boundary(now, ins->playback_until, &next);
|
||||
if (ins->rumble_scheduled) {
|
||||
sw2_next_boundary(now, ins->rumble_start, &next);
|
||||
if (!ins->rumble_held)
|
||||
sw2_next_boundary(now, ins->rumble_end, &next);
|
||||
}
|
||||
for (unsigned i = 0; i < sw2_output_sides(ins); ++i) {
|
||||
if (ins->host_sides[i].valid && !ins->host_sides[i].held)
|
||||
sw2_next_boundary(now, ins->host_sides[i].expires, &next);
|
||||
}
|
||||
if (ins->host_count && !ins->host_queue[ins->host_head].held)
|
||||
sw2_next_boundary(now, ins->host_queue[ins->host_head].expires, &next);
|
||||
sw2_schedule_output(ins, next);
|
||||
}
|
||||
|
||||
|
|
@ -965,13 +1271,14 @@ void uni_hid_parser_switch2_play_dual_rumble(uni_hid_device_t* d, uint16_t delay
|
|||
if (!ins || ins->state != SW2_READY)
|
||||
return;
|
||||
uint32_t now = btstack_run_loop_get_time_ms();
|
||||
sw2_update_haptics(ins, now);
|
||||
ins->weak = weak;
|
||||
ins->strong = strong;
|
||||
ins->rumble_start = now + delay_ms;
|
||||
ins->rumble_end = ins->rumble_start + duration_ms;
|
||||
ins->rumble_held = duration_ms == UINT16_MAX;
|
||||
ins->rumble_scheduled = duration_ms != 0 && (weak != 0 || strong != 0);
|
||||
// UINT16_MAX is the host's stateful/held sentinel; every other duration is
|
||||
// finite. Keepalive preserves either effect until replacement or stop.
|
||||
++ins->output_revision;
|
||||
sw2_update_haptics(ins, now);
|
||||
sw2_schedule_output(ins, 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
|
@ -38,6 +39,16 @@ void uni_hid_parser_switch2_parse_input_report(struct uni_hid_device_s* d, const
|
|||
void uni_hid_parser_switch2_set_player_leds(struct uni_hid_device_s* d, uint8_t leds);
|
||||
void uni_hid_parser_switch2_play_dual_rumble(struct uni_hid_device_s* d, uint16_t delay_ms, uint16_t duration_ms,
|
||||
uint8_t weak, uint8_t strong);
|
||||
// BTstack-core-only host queues. False means unavailable/full: nothing accepted.
|
||||
// Invalid/stale input is consumed and counted. received_ms is the source clock,
|
||||
// never a retry timestamp. The callback above is an independent local overlay.
|
||||
bool uni_hid_parser_switch2_queue_haptics(struct uni_hid_device_s* d,
|
||||
const uni_switch2_haptics_frame_t* frame, uint32_t received_ms);
|
||||
bool uni_hid_parser_switch2_queue_rumble(struct uni_hid_device_s* d, uint8_t weak, uint8_t strong,
|
||||
uint16_t duration_ms, uint32_t received_ms);
|
||||
void uni_hid_parser_switch2_reset_haptics(struct uni_hid_device_s* d);
|
||||
// Atomic cumulative transport losses; safe to read without looking up a device.
|
||||
uint32_t uni_hid_parser_switch2_haptics_dropped(void);
|
||||
uint8_t uni_hid_parser_switch2_extra_buttons(const struct uni_hid_device_s* d);
|
||||
// Validated public/static-random advertisement address, never an RPA or SMP identity.
|
||||
bool uni_hid_parser_switch2_identity_address_type(const struct uni_hid_device_s* d, uint8_t* out);
|
||||
|
|
|
|||
84
bluepad32_config/parser/uni_switch2_haptics.c
Normal file
84
bluepad32_config/parser/uni_switch2_haptics.c
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
static uint8_t frequency_index(uint8_t index) {
|
||||
if (index < 1) return 1;
|
||||
return index > 127 ? 127 : index;
|
||||
}
|
||||
|
||||
static uint16_t amplitude_code(uint16_t q15) {
|
||||
if (q15 > 32767) q15 = 32767;
|
||||
// Match SDL's conservative native envelope while retaining a linear input
|
||||
// curve. This is transport gain, not a claim of calibrated physical force.
|
||||
return (uint16_t)(((uint32_t)q15 * 29000u / 32767u) >> 6);
|
||||
}
|
||||
|
||||
static uint64_t load_sample(const uint8_t data[5]) {
|
||||
uint64_t value = 0;
|
||||
for (unsigned i = 0; i < 5; ++i) value |= (uint64_t)data[i] << (8 * i);
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool valid_side(const uni_switch2_haptics_side_t* side) {
|
||||
if (side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false;
|
||||
for (unsigned i = 0; i < side->count; ++i) {
|
||||
uint64_t value = load_sample(side->samples[i]);
|
||||
unsigned first_frequency = value & 1023u;
|
||||
unsigned second_frequency = (value >> 20) & 1023u;
|
||||
if (first_frequency == 0 || first_frequency > 670 ||
|
||||
second_frequency == 0 || second_frequency > 670 ||
|
||||
((value >> 10) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE ||
|
||||
((value >> 30) & 1023u) > UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index,
|
||||
uint16_t low_q15, uint16_t high_q15) {
|
||||
if (!out) return;
|
||||
// The two physical frequency fields share one measured logarithmic scale.
|
||||
// Original Switch indices have32 steps/octave; Switch2 has96 steps/octave.
|
||||
uint64_t value = 193u + 3u * frequency_index(low_index);
|
||||
value |= (uint64_t)amplitude_code(low_q15) << 10;
|
||||
value |= (uint64_t)(289u + 3u * frequency_index(high_index)) << 20;
|
||||
value |= (uint64_t)amplitude_code(high_q15) << 30;
|
||||
for (unsigned i = 0; i < 5; ++i) out[i] = (uint8_t)(value >> (8 * i));
|
||||
}
|
||||
|
||||
void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame) {
|
||||
if (!frame) return;
|
||||
memset(frame, 0, sizeof(*frame));
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
frame->sides[side].count = 1;
|
||||
uni_switch2_haptics_encode_sample(frame->sides[side].samples[0], 64, 64, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame) {
|
||||
return frame && (frame->sides[0].count || frame->sides[1].count) &&
|
||||
valid_side(&frame->sides[0]) && valid_side(&frame->sides[1]);
|
||||
}
|
||||
|
||||
bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame) {
|
||||
if (!uni_switch2_haptics_valid(frame) || !frame->sides[0].count || !frame->sides[1].count)
|
||||
return false;
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned i = 0; i < frame->sides[side].count; ++i) {
|
||||
uint64_t value = load_sample(frame->sides[side].samples[i]);
|
||||
if (((value >> 10) & 1023u) || ((value >> 30) & 1023u)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side,
|
||||
uint8_t sequence) {
|
||||
if (!out || !side || !side->count || side->count > UNI_SWITCH2_HAPTICS_MAX_SAMPLES) return false;
|
||||
memset(out, 0, 16);
|
||||
out[0] = (uint8_t)(0x40u | (side->count << 4) | (sequence & 15u));
|
||||
memcpy(out + 1, side->samples, 5u * side->count);
|
||||
return true;
|
||||
}
|
||||
36
bluepad32_config/parser/uni_switch2_haptics.h
Normal file
36
bluepad32_config/parser/uni_switch2_haptics.h
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define UNI_SWITCH2_HAPTICS_MAX_SAMPLES 3
|
||||
#define UNI_SWITCH2_HAPTICS_MAX_AMPLITUDE 453
|
||||
#define UNI_SWITCH2_HAPTICS_WATCHDOG_MS 50
|
||||
|
||||
typedef struct {
|
||||
uint8_t count; // Zero leaves this side's host state/watchdog untouched.
|
||||
uint8_t samples[UNI_SWITCH2_HAPTICS_MAX_SAMPLES][5];
|
||||
} uni_switch2_haptics_side_t;
|
||||
typedef struct {
|
||||
uni_switch2_haptics_side_t sides[2]; // Physical left/right; Joy-Con uses side 0.
|
||||
} uni_switch2_haptics_frame_t;
|
||||
|
||||
// Source indices64 mean160Hz(low)/320Hz(high); amplitudes are linear Q0.15.
|
||||
// Measured native frequency law: Hz ~=10*2^((code-1)/96), hence exact index mapping.
|
||||
// Linear amplitude scaling uses SDL's29000/65535 safety envelope (10-bit max453).
|
||||
void uni_switch2_haptics_encode_sample(uint8_t out[5], uint8_t low_index, uint8_t high_index,
|
||||
uint16_t low_q15, uint16_t high_q15);
|
||||
void uni_switch2_haptics_silence(uni_switch2_haptics_frame_t* frame);
|
||||
bool uni_switch2_haptics_valid(const uni_switch2_haptics_frame_t* frame);
|
||||
bool uni_switch2_haptics_is_stop(const uni_switch2_haptics_frame_t* frame);
|
||||
// Native callers validate the host envelope separately; conventional feedback
|
||||
// retains its existing full10-bit amplitude codes.
|
||||
// Requires count1..3; unused slots are zero, header selects only the valid samples.
|
||||
bool uni_switch2_haptics_write_block(uint8_t out[16], const uni_switch2_haptics_side_t* side,
|
||||
uint8_t sequence);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -24,6 +24,7 @@
|
|||
#include <pico/stdlib.h>
|
||||
#include <uni.h>
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
|
||||
#include "adapter/adapter_usb_mode.h"
|
||||
|
|
@ -112,10 +113,26 @@ struct RumbleEnvelope {
|
|||
uint32_t connection_generation;
|
||||
ControllerRumbleOutput rumble;
|
||||
uint16_t duration_ms;
|
||||
uint32_t received_ms = 0;
|
||||
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
|
||||
uint64_t received_us = 0;
|
||||
#endif
|
||||
};
|
||||
|
||||
constexpr uint8_t kSwitch2IngressCapacity = 16;
|
||||
struct Switch2HostCommand {
|
||||
RumbleEnvelope envelope;
|
||||
uint32_t generation;
|
||||
uint8_t accepted_halves;
|
||||
};
|
||||
struct Switch2Ingress {
|
||||
Switch2HostCommand commands[kSwitch2IngressCapacity];
|
||||
uint32_t generation;
|
||||
uint8_t host_mode;
|
||||
uint8_t head;
|
||||
uint8_t count;
|
||||
bool reset_pending;
|
||||
};
|
||||
struct FeedbackEnvelope {
|
||||
uint32_t connection_generation;
|
||||
uint16_t duration_ms;
|
||||
|
|
@ -176,6 +193,7 @@ struct BackendSlot {
|
|||
RumbleEnvelope pending_rumble;
|
||||
bool retained_host_rumble_valid;
|
||||
RumbleEnvelope retained_host_rumble;
|
||||
Switch2Ingress switch2_ingress;
|
||||
FeedbackEnvelope pending_feedback;
|
||||
ProfileFeedbackEnvelope
|
||||
pending_profile_feedback[kProfileFeedbackQueueCapacity];
|
||||
|
|
@ -226,6 +244,7 @@ uint32_t g_controller_reports = 0;
|
|||
uint32_t g_host_rumble_requests = 0;
|
||||
uint32_t g_local_feedback_requests = 0;
|
||||
uint32_t g_rumble_dispatches = 0;
|
||||
uint32_t g_switch2_ingress_drops = 0;
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
uint32_t g_seeded_native_run_id = 0;
|
||||
#endif
|
||||
|
|
@ -239,6 +258,14 @@ uint16_t host_rumble_duration_ms() {
|
|||
return kSwitchHostRumbleDurationMs;
|
||||
}
|
||||
|
||||
uint8_t switch2_host_mode() {
|
||||
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
|
||||
return static_cast<uint8_t>(adapter_host_probe_mode());
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
ControllerState make_neutral_state() {
|
||||
return controller_neutral_state();
|
||||
}
|
||||
|
|
@ -332,6 +359,154 @@ int joycon_side(const uni_hid_device_t* device) {
|
|||
return device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : 0;
|
||||
}
|
||||
|
||||
// Caller holds the cross-core state lock. Only Core 1 resets parser state.
|
||||
void clear_switch2_ingress(BackendSlot& slot) {
|
||||
Switch2Ingress& ingress = slot.switch2_ingress;
|
||||
__atomic_add_fetch(&g_switch2_ingress_drops, ingress.count, __ATOMIC_RELAXED);
|
||||
ingress.head = 0;
|
||||
ingress.count = 0;
|
||||
++ingress.generation;
|
||||
ingress.reset_pending = true;
|
||||
}
|
||||
|
||||
void reset_switch2_outputs(BackendSlot& slot) {
|
||||
uni_hid_device_t* targets[] = {slot.device, slot.companion};
|
||||
for (uni_hid_device_t* target : targets) {
|
||||
if (uni_hid_parser_switch2_is_ble_device(target)) {
|
||||
uni_hid_parser_switch2_reset_haptics(target);
|
||||
}
|
||||
}
|
||||
slot.switch2_ingress.reset_pending = false;
|
||||
}
|
||||
|
||||
bool switch2_has_hd(const ControllerRumbleOutput& rumble) {
|
||||
return rumble.hd.actuators[0].sample_count != 0 ||
|
||||
rumble.hd.actuators[1].sample_count != 0;
|
||||
}
|
||||
|
||||
bool switch2_host_stop(const ControllerRumbleOutput& rumble) {
|
||||
if (!switch2_has_hd(rumble)) {
|
||||
return (rumble.low_frequency_magnitude | rumble.high_frequency_magnitude) == 0;
|
||||
}
|
||||
for (const SwitchHapticsActuatorFrame& side : rumble.hd.actuators) {
|
||||
if (side.sample_count == 0 || side.sample_count > 3) return false;
|
||||
for (uint8_t index = 0; index < side.sample_count; ++index) {
|
||||
if (side.samples[index].low_amplitude_q15 != 0 ||
|
||||
side.samples[index].high_amplitude_q15 != 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void encode_switch2_side(uni_switch2_haptics_side_t& output,
|
||||
const SwitchHapticsActuatorFrame& input) {
|
||||
output.count = input.sample_count;
|
||||
for (uint8_t index = 0; index < input.sample_count; ++index) {
|
||||
const SwitchHapticsSample& sample = input.samples[index];
|
||||
uni_switch2_haptics_encode_sample(
|
||||
output.samples[index], sample.low_frequency_index,
|
||||
sample.high_frequency_index, sample.low_amplitude_q15,
|
||||
sample.high_amplitude_q15);
|
||||
}
|
||||
}
|
||||
|
||||
uni_switch2_haptics_frame_t switch2_physical_frame(
|
||||
const ControllerRumbleOutput& rumble, const uni_hid_device_t* target,
|
||||
bool paired) {
|
||||
uni_switch2_haptics_frame_t frame{};
|
||||
const SwitchHapticsActuatorFrame& left = rumble.hd.actuators[0];
|
||||
const SwitchHapticsActuatorFrame& right = rumble.hd.actuators[1];
|
||||
const int side = joycon_side(target);
|
||||
if (side == 0) {
|
||||
encode_switch2_side(frame.sides[0], left);
|
||||
encode_switch2_side(frame.sides[1], right);
|
||||
} else if (paired) {
|
||||
encode_switch2_side(frame.sides[0], side < 0 ? left : right);
|
||||
} else {
|
||||
// Mono chooses each band's louder source independently. A short side
|
||||
// holds its final substep; an absent side contributes no update.
|
||||
frame.sides[0].count =
|
||||
left.sample_count > right.sample_count ? left.sample_count : right.sample_count;
|
||||
for (uint8_t index = 0; index < frame.sides[0].count; ++index) {
|
||||
const SwitchHapticsSample* l = left.sample_count == 0 ? nullptr :
|
||||
&left.samples[index < left.sample_count ? index : left.sample_count - 1];
|
||||
const SwitchHapticsSample* r = right.sample_count == 0 ? nullptr :
|
||||
&right.samples[index < right.sample_count ? index : right.sample_count - 1];
|
||||
const SwitchHapticsSample* low = !r || (l && l->low_amplitude_q15 >= r->low_amplitude_q15) ? l : r;
|
||||
const SwitchHapticsSample* high = !r || (l && l->high_amplitude_q15 >= r->high_amplitude_q15) ? l : r;
|
||||
uni_switch2_haptics_encode_sample(
|
||||
frame.sides[0].samples[index], low->low_frequency_index,
|
||||
high->high_frequency_index, low->low_amplitude_q15,
|
||||
high->high_amplitude_q15);
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
void drain_switch2_ingress(uint8_t slot_index, uint32_t now_ms) {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
if (!slot.active || !uni_hid_parser_switch2_is_ble_device(slot.device)) {
|
||||
critical_section_exit(&g_state_lock);
|
||||
return;
|
||||
}
|
||||
Switch2Ingress& ingress = slot.switch2_ingress;
|
||||
const uint16_t duration_ms = host_rumble_duration_ms();
|
||||
const uint8_t host_mode = switch2_host_mode();
|
||||
if (ingress.host_mode != host_mode) {
|
||||
clear_switch2_ingress(slot);
|
||||
ingress.host_mode = host_mode;
|
||||
}
|
||||
if (ingress.reset_pending) reset_switch2_outputs(slot);
|
||||
for (uint8_t budget = 0; budget < kSwitch2IngressCapacity && ingress.count != 0; ++budget) {
|
||||
Switch2HostCommand& command = ingress.commands[ingress.head];
|
||||
const RumbleEnvelope& envelope = command.envelope;
|
||||
const bool hd = switch2_has_hd(envelope.rumble);
|
||||
const bool stop = switch2_host_stop(envelope.rumble);
|
||||
const bool stale = command.generation != ingress.generation ||
|
||||
envelope.connection_generation != slot.connection_generation ||
|
||||
envelope.duration_ms != duration_ms ||
|
||||
(!stop && (hd || duration_ms != kXInputHostRumbleDurationMs) &&
|
||||
static_cast<uint32_t>(now_ms - envelope.received_ms) >= UNI_SWITCH2_HAPTICS_WATCHDOG_MS);
|
||||
const bool invalid = envelope.rumble.hd.actuators[0].sample_count > 3 ||
|
||||
envelope.rumble.hd.actuators[1].sample_count > 3;
|
||||
if (stale || invalid) {
|
||||
__atomic_add_fetch(&g_switch2_ingress_drops, 1, __ATOMIC_RELAXED);
|
||||
} else {
|
||||
uni_hid_device_t* targets[] = {slot.device, slot.companion};
|
||||
const uint8_t target_mask = slot.companion == nullptr ? 1 : 3;
|
||||
for (uint8_t half = 0; half < 2; ++half) {
|
||||
const uint8_t bit = 1u << half;
|
||||
if (!(target_mask & bit) || (command.accepted_halves & bit)) continue;
|
||||
bool accepted;
|
||||
if (hd) {
|
||||
const uni_switch2_haptics_frame_t frame =
|
||||
switch2_physical_frame(envelope.rumble, targets[half], slot.companion != nullptr);
|
||||
if (frame.sides[0].count == 0 && frame.sides[1].count == 0) {
|
||||
command.accepted_halves |= bit;
|
||||
continue;
|
||||
}
|
||||
accepted = uni_hid_parser_switch2_queue_haptics(
|
||||
targets[half], &frame, envelope.received_ms);
|
||||
} else {
|
||||
accepted = uni_hid_parser_switch2_queue_rumble(
|
||||
targets[half], envelope.rumble.high_frequency_magnitude,
|
||||
envelope.rumble.low_frequency_magnitude,
|
||||
stop ? 0 : envelope.duration_ms, envelope.received_ms);
|
||||
}
|
||||
if (accepted) {
|
||||
command.accepted_halves |= bit;
|
||||
__atomic_add_fetch(&g_rumble_dispatches, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
}
|
||||
if (command.accepted_halves != target_mask) break;
|
||||
}
|
||||
ingress.head = (ingress.head + 1u) % kSwitch2IngressCapacity;
|
||||
--ingress.count;
|
||||
}
|
||||
critical_section_exit(&g_state_lock);
|
||||
}
|
||||
|
||||
bool addresses_equal(const bd_addr_t first, const bd_addr_t second) {
|
||||
return memcmp(first, second, sizeof(bd_addr_t)) == 0;
|
||||
}
|
||||
|
|
@ -621,6 +796,8 @@ void publish_device_state(uint8_t slot, uni_hid_device_t* device,
|
|||
void publish_all_neutral() {
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
for (BackendSlot& slot : g_slots) {
|
||||
clear_switch2_ingress(slot);
|
||||
reset_switch2_outputs(slot);
|
||||
slot.state = make_neutral_state();
|
||||
slot.pre_hotkey_button_mask = 0;
|
||||
slot.identity = controller_identity_global();
|
||||
|
|
@ -1030,6 +1207,8 @@ void reset_slot_hotkeys(BackendSlot& slot) {
|
|||
}
|
||||
|
||||
void invalidate_slot(BackendSlot& slot) {
|
||||
clear_switch2_ingress(slot);
|
||||
reset_switch2_outputs(slot);
|
||||
reset_slot_hotkeys(slot);
|
||||
slot.rumble_pending = false;
|
||||
slot.pending_rumble = {};
|
||||
|
|
@ -1634,6 +1813,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
|
|||
#endif
|
||||
|
||||
for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) {
|
||||
drain_switch2_ingress(slot_index, now_ms);
|
||||
RumbleEnvelope envelope{};
|
||||
FeedbackEnvelope feedback{};
|
||||
ProfileFeedbackEnvelope profile_feedback{};
|
||||
|
|
@ -2453,6 +2633,9 @@ void bluepad32_input_backend_diagnostics(
|
|||
__atomic_load_n(&g_local_feedback_requests, __ATOMIC_RELAXED);
|
||||
out->rumble_dispatches =
|
||||
__atomic_load_n(&g_rumble_dispatches, __ATOMIC_RELAXED);
|
||||
out->switch2_ingress_drops =
|
||||
__atomic_load_n(&g_switch2_ingress_drops, __ATOMIC_RELAXED);
|
||||
out->switch2_output_drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
for (const BackendSlot& slot : g_slots) {
|
||||
|
|
@ -2467,7 +2650,7 @@ void bluepad32_input_backend_diagnostics(
|
|||
slot.pending_profile_feedback_count != 0) {
|
||||
++out->feedback_pending_slots;
|
||||
}
|
||||
if (slot.rumble_pending) {
|
||||
if (slot.rumble_pending || slot.switch2_ingress.count != 0) {
|
||||
++out->rumble_pending_slots;
|
||||
}
|
||||
}
|
||||
|
|
@ -2577,16 +2760,47 @@ void bluepad32_input_backend_queue_rumble(
|
|||
bool native_candidate = false;
|
||||
#endif
|
||||
const uint16_t duration_ms = host_rumble_duration_ms();
|
||||
const uint32_t received_ms = btstack_run_loop_get_time_ms();
|
||||
critical_section_enter_blocking(&g_state_lock);
|
||||
BackendSlot& slot = g_slots[slot_index];
|
||||
if (slot.active && slot.device != nullptr) {
|
||||
if (uni_hid_parser_switch2_is_ble_device(slot.device)) {
|
||||
Switch2Ingress& ingress = slot.switch2_ingress;
|
||||
const uint8_t host_mode = switch2_host_mode();
|
||||
if (ingress.host_mode != host_mode) {
|
||||
clear_switch2_ingress(slot);
|
||||
ingress.host_mode = host_mode;
|
||||
}
|
||||
if (switch2_host_stop(rumble)) {
|
||||
// Host stop is a barrier, not a local-feedback cancellation.
|
||||
ingress.head = 0;
|
||||
ingress.count = 0;
|
||||
} else if (ingress.count == kSwitch2IngressCapacity) {
|
||||
ingress.head = (ingress.head + 1u) % kSwitch2IngressCapacity;
|
||||
--ingress.count;
|
||||
__atomic_add_fetch(&g_switch2_ingress_drops, 1, __ATOMIC_RELAXED);
|
||||
}
|
||||
Switch2HostCommand& command =
|
||||
ingress.commands[(ingress.head + ingress.count) % kSwitch2IngressCapacity];
|
||||
command = {};
|
||||
command.envelope.slot = slot_index;
|
||||
command.envelope.connection_generation = slot.connection_generation;
|
||||
command.envelope.rumble = rumble;
|
||||
command.envelope.duration_ms = duration_ms;
|
||||
command.envelope.received_ms = received_ms;
|
||||
command.generation = ingress.generation;
|
||||
++ingress.count;
|
||||
__atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED);
|
||||
critical_section_exit(&g_state_lock);
|
||||
return;
|
||||
}
|
||||
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
|
||||
native_generation = slot.connection_generation;
|
||||
native_candidate = true;
|
||||
#endif
|
||||
const RumbleEnvelope envelope{
|
||||
slot_index, slot.connection_generation, rumble,
|
||||
duration_ms
|
||||
duration_ms, received_ms
|
||||
#if defined(SWITCH_PICO_HAPTICS_EXPERIMENT) || defined(SWITCH_PICO_NATIVE_SWITCH_RUMBLE)
|
||||
, received_us
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ struct Bluepad32BackendDiagnostics {
|
|||
uint8_t rumble_capable_slots;
|
||||
uint8_t feedback_pending_slots;
|
||||
uint8_t rumble_pending_slots;
|
||||
uint32_t switch2_ingress_drops;
|
||||
uint32_t switch2_output_drops;
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ size_t encode_info(uint8_t* output, size_t output_size) {
|
|||
size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) {
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
uint8_t payload[32]{};
|
||||
uint8_t payload[40]{};
|
||||
write_u32(&payload[0], diagnostics.initialization_stage);
|
||||
write_u32(&payload[4], diagnostics.rumble_timer_ticks);
|
||||
write_u32(&payload[8], diagnostics.configuration_timer_ticks);
|
||||
|
|
@ -205,6 +205,8 @@ size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) {
|
|||
payload[29] = diagnostics.rumble_capable_slots;
|
||||
payload[30] = diagnostics.feedback_pending_slots;
|
||||
payload[31] = diagnostics.rumble_pending_slots;
|
||||
write_u32(&payload[32], diagnostics.switch2_ingress_drops);
|
||||
write_u32(&payload[36], diagnostics.switch2_output_drops);
|
||||
return encode_response(Operation::kRuntimeDiagnostics, Status::kOk,
|
||||
0, 0, 0, payload, sizeof(payload), output,
|
||||
output_size);
|
||||
|
|
|
|||
|
|
@ -374,6 +374,8 @@ class RuntimeDiagnostics:
|
|||
rumble_capable_slots: int
|
||||
feedback_pending_slots: int
|
||||
rumble_pending_slots: int
|
||||
switch2_ingress_drops: int | None = None
|
||||
switch2_output_drops: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -2701,15 +2703,22 @@ def capture_macro_steps(page: MacroCapturePage) -> tuple[MacroStep, ...]:
|
|||
def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics:
|
||||
envelope = _control_in(device, OP_RUNTIME_DIAGNOSTICS)
|
||||
_raise_status(envelope)
|
||||
if len(envelope.payload) != 32:
|
||||
if len(envelope.payload) not in (32, 40):
|
||||
raise ConfigManagerError("invalid runtime-diagnostics payload")
|
||||
counters = struct.unpack_from("<7I", envelope.payload)
|
||||
ingress_drops, output_drops = (
|
||||
struct.unpack_from("<2I", envelope.payload, 32)
|
||||
if len(envelope.payload) == 40
|
||||
else (None, None)
|
||||
)
|
||||
return RuntimeDiagnostics(
|
||||
*counters,
|
||||
active_slots=envelope.payload[28],
|
||||
rumble_capable_slots=envelope.payload[29],
|
||||
feedback_pending_slots=envelope.payload[30],
|
||||
rumble_pending_slots=envelope.payload[31],
|
||||
switch2_ingress_drops=ingress_drops,
|
||||
switch2_output_drops=output_drops,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -4466,6 +4475,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
print(f"Rumble-capable slots: {diagnostics.rumble_capable_slots}")
|
||||
print(f"Feedback-pending slots: {diagnostics.feedback_pending_slots}")
|
||||
print(f"Rumble-pending slots: {diagnostics.rumble_pending_slots}")
|
||||
if diagnostics.switch2_ingress_drops is not None:
|
||||
print(f"Switch 2 ingress drops: {diagnostics.switch2_ingress_drops}")
|
||||
print(f"Switch 2 output drops: {diagnostics.switch2_output_drops}")
|
||||
elif args.command == "haptics-experiment":
|
||||
_run_haptics_experiment_command(device, args)
|
||||
elif args.command == "reboot":
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@
|
|||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#ifdef SWITCH_PICO_HAPTICS_EXPERIMENT
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#endif
|
||||
|
||||
#include <uni.h>
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
#include "parser/uni_switch2_pairing.h"
|
||||
#include "platform/pico/controller_color_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
|
|
@ -75,6 +76,18 @@ uint8_t switch2_pairing_types[UNI_SWITCH2_PAIRING_CAPACITY]{};
|
|||
uint8_t switch2_pairing_count = 0;
|
||||
bool switch2_clear_succeeds = true;
|
||||
|
||||
struct Switch2HostEvent {
|
||||
uni_hid_device_t* device;
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
uint32_t received_ms;
|
||||
uint16_t duration_ms;
|
||||
uint8_t weak;
|
||||
uint8_t strong;
|
||||
bool hd;
|
||||
};
|
||||
std::vector<Switch2HostEvent> switch2_host_events;
|
||||
uint32_t switch2_output_drops = 0;
|
||||
|
||||
struct CoreStopped {};
|
||||
|
||||
|
||||
|
|
@ -184,6 +197,45 @@ extern "C" bool uni_hid_parser_switch2_is_ble_device(
|
|||
device->product_id == UNI_SW2_JOYCON_R_PID);
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_switch2_queue_haptics(
|
||||
uni_hid_device_t* device, const uni_switch2_haptics_frame_t* frame,
|
||||
uint32_t received_ms) {
|
||||
require(uni_switch2_haptics_valid(frame), "backend emitted invalid physical HD frame");
|
||||
const bool stop = device->product_id == UNI_SW2_PRO_PID ?
|
||||
uni_switch2_haptics_is_stop(frame) : [&]() {
|
||||
uni_switch2_haptics_frame_t stereo = *frame;
|
||||
stereo.sides[1] = stereo.sides[0];
|
||||
return uni_switch2_haptics_is_stop(&stereo);
|
||||
}();
|
||||
if (device->switch2_host_blocked && !stop) return false;
|
||||
++device->switch2_host_calls;
|
||||
switch2_host_events.push_back({device, *frame, received_ms, 0, 0, 0, true});
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" bool uni_hid_parser_switch2_queue_rumble(
|
||||
uni_hid_device_t* device, uint8_t weak, uint8_t strong,
|
||||
uint16_t duration_ms, uint32_t received_ms) {
|
||||
if (device->switch2_host_blocked && (weak | strong) != 0) return false;
|
||||
++device->switch2_host_calls;
|
||||
device->last_high = weak;
|
||||
device->last_low = strong;
|
||||
device->last_rumble_duration_ms = duration_ms;
|
||||
switch2_host_events.push_back({device, {}, received_ms, duration_ms, weak, strong, false});
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" void uni_hid_parser_switch2_reset_haptics(uni_hid_device_t* device) {
|
||||
++device->switch2_haptics_resets;
|
||||
device->last_high = 0;
|
||||
device->last_low = 0;
|
||||
device->last_rumble_duration_ms = 0;
|
||||
}
|
||||
|
||||
extern "C" uint32_t uni_hid_parser_switch2_haptics_dropped(void) {
|
||||
return switch2_output_drops;
|
||||
}
|
||||
|
||||
extern "C" uint8_t uni_hid_parser_switch2_extra_buttons(
|
||||
const uni_hid_device_t* device) {
|
||||
return uni_hid_parser_switch2_is_ble_device(device)
|
||||
|
|
@ -859,6 +911,347 @@ Bluepad32SlotSnapshot slot_snapshot(uint8_t index) {
|
|||
return result;
|
||||
}
|
||||
|
||||
ControllerRumbleOutput ordered_switch2_hd(uint8_t frequency = 40) {
|
||||
ControllerRumbleOutput rumble{17, 29};
|
||||
rumble.hd.actuators[0].sample_count = 3;
|
||||
rumble.hd.actuators[1].sample_count = 2;
|
||||
rumble.hd.actuators[0].samples[0] = {frequency, 61, 24000, 4000};
|
||||
rumble.hd.actuators[0].samples[1] = {41, 62, 5000, 25000};
|
||||
rumble.hd.actuators[0].samples[2] = {42, 63, 20000, 14000};
|
||||
rumble.hd.actuators[1].samples[0] = {81, 91, 3000, 21000};
|
||||
rumble.hd.actuators[1].samples[1] = {82, 92, 23000, 7000};
|
||||
return rumble;
|
||||
}
|
||||
|
||||
void require_switch2_sample(const uint8_t encoded[5],
|
||||
const SwitchHapticsSample& source) {
|
||||
uint64_t bits = 0;
|
||||
for (unsigned index = 0; index < 5; ++index) {
|
||||
bits |= uint64_t{encoded[index]} << (index * 8);
|
||||
}
|
||||
require((bits & 1023u) == 193u + 3u * source.low_frequency_index &&
|
||||
((bits >> 20) & 1023u) == 289u + 3u * source.high_frequency_index &&
|
||||
((bits >> 10) & 1023u) ==
|
||||
((uint32_t{source.low_amplitude_q15} * 29000u / 32767u) >> 6) &&
|
||||
((bits >> 30) & 1023u) ==
|
||||
((uint32_t{source.high_amplitude_q15} * 29000u / 32767u) >> 6),
|
||||
"native physical sample lost its measured frequency or independent band amplitude");
|
||||
}
|
||||
|
||||
void test_switch2_hd_pro() {
|
||||
start_pairing_backend();
|
||||
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(pro);
|
||||
now_ms = 100;
|
||||
const auto first = ordered_switch2_hd(40);
|
||||
const auto second = ordered_switch2_hd(50);
|
||||
bluepad32_input_backend_queue_rumble(0, first);
|
||||
now_ms = 101;
|
||||
bluepad32_input_backend_queue_rumble(0, second);
|
||||
now_ms = 105;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2 && pro.rumble_calls == 0 &&
|
||||
switch2_host_events[0].received_ms == 100 &&
|
||||
switch2_host_events[1].received_ms == 101,
|
||||
"rapid HD commands must bypass the compatibility mailbox in original order and time");
|
||||
for (unsigned event = 0; event < 2; ++event) {
|
||||
const auto& input = event == 0 ? first : second;
|
||||
const auto& output = switch2_host_events[event].frame;
|
||||
require(output.sides[0].count == 3 && output.sides[1].count == 2,
|
||||
"Pro output must preserve both native side counts");
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned step = 0; step < output.sides[side].count; ++step) {
|
||||
require_switch2_sample(output.sides[side].samples[step],
|
||||
input.hd.actuators[side].samples[step]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ControllerRumbleOutput partial{};
|
||||
partial.hd.actuators[0].sample_count = 1;
|
||||
bluepad32_input_backend_queue_rumble(0, first);
|
||||
bluepad32_input_backend_queue_rumble(0, partial);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 4 &&
|
||||
switch2_host_events.back().frame.sides[1].count == 0,
|
||||
"a silent partial update must neither flush older commands nor invent a right stop");
|
||||
|
||||
pro.switch2_host_blocked = true;
|
||||
bluepad32_input_backend_queue_rumble(0, first);
|
||||
ControllerRumbleOutput stop{};
|
||||
stop.hd.actuators[0].sample_count = 1;
|
||||
stop.hd.actuators[1].sample_count = 1;
|
||||
bluepad32_input_backend_queue_rumble(0, stop);
|
||||
now_ms += 100;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 5 &&
|
||||
uni_switch2_haptics_is_stop(&switch2_host_events.back().frame),
|
||||
"explicit native stop must clear older ingress and bypass age/full barriers");
|
||||
pro.switch2_host_blocked = false;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{19, 23});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(!switch2_host_events.back().hd && switch2_host_events.back().strong == 19 &&
|
||||
switch2_host_events.back().weak == 23 && pro.rumble_calls == 0,
|
||||
"both empty HD sides must use the dedicated conventional host API");
|
||||
}
|
||||
|
||||
void test_switch2_hd_solo(bool right) {
|
||||
start_pairing_backend();
|
||||
auto solo = switch2_device(0, right ? UNI_SW2_JOYCON_R_PID : UNI_SW2_JOYCON_L_PID);
|
||||
ready_switch2(solo);
|
||||
const auto input = ordered_switch2_hd();
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 1 &&
|
||||
switch2_host_events[0].frame.sides[0].count == 3 &&
|
||||
switch2_host_events[0].frame.sides[1].count == 0,
|
||||
"either solo half must receive a mono sequence in physical side zero");
|
||||
const SwitchHapticsSample expected[] = {
|
||||
{40, 91, 24000, 21000}, {82, 62, 23000, 25000}, {82, 63, 23000, 14000}};
|
||||
for (unsigned index = 0; index < 3; ++index) {
|
||||
require_switch2_sample(switch2_host_events[0].frame.sides[0].samples[index],
|
||||
expected[index]);
|
||||
}
|
||||
auto tie = input;
|
||||
tie.hd.actuators[0].sample_count = 1;
|
||||
tie.hd.actuators[1].sample_count = 1;
|
||||
tie.hd.actuators[1].samples[0].low_amplitude_q15 = 24000;
|
||||
tie.hd.actuators[1].samples[0].high_amplitude_q15 = 4000;
|
||||
bluepad32_input_backend_queue_rumble(0, tie);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[0],
|
||||
tie.hd.actuators[0].samples[0]);
|
||||
auto one_side = input;
|
||||
one_side.hd.actuators[0].sample_count = 0;
|
||||
bluepad32_input_backend_queue_rumble(0, one_side);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.back().frame.sides[0].count == 2,
|
||||
"solo absent source side must not fabricate substeps");
|
||||
require_switch2_sample(switch2_host_events.back().frame.sides[0].samples[1],
|
||||
one_side.hd.actuators[1].samples[1]);
|
||||
}
|
||||
|
||||
void test_switch2_hd_pair() {
|
||||
start_pairing_backend();
|
||||
auto right = switch2_device(0, UNI_SW2_JOYCON_R_PID);
|
||||
auto left = switch2_device(1, UNI_SW2_JOYCON_L_PID);
|
||||
ready_switch2(right);
|
||||
ready_switch2(left);
|
||||
right.switch2_host_blocked = true;
|
||||
now_ms = 10;
|
||||
const auto input = ordered_switch2_hd();
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms = 15;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 1 && switch2_host_events[0].device == &left,
|
||||
"one blocked half must not duplicate acceptance on its ready partner");
|
||||
right.switch2_host_blocked = false;
|
||||
now_ms = 20;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2 && switch2_host_events[1].device == &right &&
|
||||
switch2_host_events[1].received_ms == 10,
|
||||
"paired retry must retain the source timestamp and original missing half");
|
||||
for (unsigned half = 0; half < 2; ++half) {
|
||||
const auto& output = switch2_host_events[half].frame;
|
||||
require(output.sides[0].count == (half == 0 ? 3 : 2) && output.sides[1].count == 0,
|
||||
"pair stereo must map selected source side onto each physical side zero");
|
||||
for (unsigned step = 0; step < output.sides[0].count; ++step) {
|
||||
require_switch2_sample(output.sides[0].samples[step],
|
||||
input.hd.actuators[half].samples[step]);
|
||||
}
|
||||
}
|
||||
auto right_only = input;
|
||||
right_only.hd.actuators[0].sample_count = 0;
|
||||
bluepad32_input_backend_queue_rumble(0, right_only);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3 && switch2_host_events.back().device == &right,
|
||||
"count zero on one paired half must not submit an invented neutral update");
|
||||
right.switch2_host_blocked = true;
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms += 50;
|
||||
right.switch2_host_blocked = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1,
|
||||
"an expired partly accepted pair must drop visibly without late or duplicate delivery");
|
||||
right.switch2_host_blocked = true;
|
||||
bluepad32_input_backend_queue_rumble(0, input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
ControllerRumbleOutput stop{};
|
||||
stop.hd.actuators[0].sample_count = 1;
|
||||
stop.hd.actuators[1].sample_count = 1;
|
||||
bluepad32_input_backend_queue_rumble(0, stop);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 7 &&
|
||||
switch2_host_events[5].device == &left &&
|
||||
switch2_host_events[6].device == &right &&
|
||||
switch2_host_events[5].frame.sides[0].count == 1 &&
|
||||
switch2_host_events[6].frame.sides[0].count == 1,
|
||||
"paired explicit stop must reach both halves even after partial acceptance and backpressure");
|
||||
}
|
||||
|
||||
void test_switch2_hd_overflow() {
|
||||
start_pairing_backend();
|
||||
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(pro);
|
||||
now_ms = 100;
|
||||
for (uint8_t command = 0; command < 17; ++command) {
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(40 + command));
|
||||
}
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.switch2_ingress_drops == 1 && diagnostics.rumble_pending_slots == 1,
|
||||
"bounded ingress overflow must expose one oldest-command drop");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 16,
|
||||
"one drain must preserve all sixteen surviving commands, not a latest-value mailbox");
|
||||
for (uint8_t index = 0; index < 16; ++index) {
|
||||
const auto input = ordered_switch2_hd(41 + index);
|
||||
require_switch2_sample(switch2_host_events[index].frame.sides[0].samples[0],
|
||||
input.hd.actuators[0].samples[0]);
|
||||
}
|
||||
pro.switch2_host_blocked = true;
|
||||
now_ms = UINT32_MAX - 20;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
now_ms = 28; // 49ms across wrap.
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.rumble_pending_slots == 1 && diagnostics.switch2_ingress_drops == 1,
|
||||
"backpressure must retain an unexpired command across clock wrap");
|
||||
now_ms = 29;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.rumble_pending_slots == 0 && diagnostics.switch2_ingress_drops == 2 &&
|
||||
switch2_host_events.size() == 16,
|
||||
"original 50ms age must release a backpressured ingress head exactly at expiry");
|
||||
pro.switch2_host_blocked = false;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
auto& stale = g_slots[0].switch2_ingress;
|
||||
--stale.commands[stale.head].envelope.connection_generation;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 3,
|
||||
"stale logical generation must be rejected before physical submission");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
--stale.commands[stale.head].generation;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 16 && diagnostics.switch2_ingress_drops == 4,
|
||||
"stale haptics epoch must not survive a same-connection mode reset");
|
||||
switch2_output_drops = 7;
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(diagnostics.switch2_output_drops == 7,
|
||||
"physical output loss must remain visible separately from ingress loss");
|
||||
}
|
||||
|
||||
void test_switch2_hd_epochs() {
|
||||
start_pairing_backend();
|
||||
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
|
||||
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
|
||||
ready_switch2(left);
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const unsigned left_resets = left.switch2_haptics_resets;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
ready_switch2(right);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 1 && left.switch2_haptics_resets > left_resets &&
|
||||
right.switch2_haptics_resets != 0,
|
||||
"merge must reset accepted physical output and drop queued solo commands");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const unsigned pair_resets = left.switch2_haptics_resets;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
platform_on_device_disconnected(&right);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3 && left.switch2_haptics_resets > pair_resets,
|
||||
"survivor transition must flush both physical and ingress pair state");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
platform_on_device_disconnected(&left);
|
||||
auto replacement = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(replacement);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3,
|
||||
"reconnect must not inherit a former logical generation's host output");
|
||||
#ifdef SWITCH_PICO_USB_OUTPUT_MODES
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{27, 35});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
const unsigned mode_resets = replacement.switch2_haptics_resets;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
test_adapter_mode = AdapterUsbMode::kSwitchProbe;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 4 && replacement.switch2_haptics_resets > mode_resets,
|
||||
"mode boundary without new input must neutralize held output and queued old-mode HD");
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50));
|
||||
test_adapter_mode = AdapterUsbMode::kXInput;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{45, 55});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 5 && !switch2_host_events.back().hd &&
|
||||
switch2_host_events.back().duration_ms == UINT16_MAX &&
|
||||
switch2_host_events.back().strong == 45,
|
||||
"producer-side mode transition must discard old epoch before accepting new host state");
|
||||
#endif
|
||||
}
|
||||
|
||||
void test_switch2_hd_feedback() {
|
||||
start_pairing_backend();
|
||||
auto pro = switch2_device(0, UNI_SW2_PRO_PID);
|
||||
ready_switch2(pro);
|
||||
bluepad32_input_backend_queue_profile_feedback(
|
||||
0, slot_snapshot(0).connection_generation, 2,
|
||||
ControllerProfileConfirmationPolicy::kRumble);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pro.rumble_calls == 1, "profile confirmation must retain local-feedback ownership");
|
||||
now_ms = 10;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd());
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms = 20;
|
||||
bluepad32_input_backend_queue_rumble(0, ordered_switch2_hd(50));
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2 && pro.rumble_calls == 1 &&
|
||||
switch2_host_events[0].received_ms == 10 &&
|
||||
switch2_host_events[1].received_ms == 20,
|
||||
"host substeps must advance during feedback without compatibility dispatch or fresh timestamps");
|
||||
now_ms = 300;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 2,
|
||||
"feedback completion must not replay historical host substeps");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{33, 44});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3 && !switch2_host_events.back().hd &&
|
||||
switch2_host_events.back().duration_ms == host_rumble_duration_ms(),
|
||||
"conventional host vibration must share the bounded host queue, not local feedback");
|
||||
now_ms += 1000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 3,
|
||||
"stateful host output must not require backend periodic resubmission");
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{});
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(switch2_host_events.size() == 4 && switch2_host_events.back().duration_ms == 0 &&
|
||||
switch2_host_events.back().weak == 0 && switch2_host_events.back().strong == 0,
|
||||
"conventional all-zero host command must explicitly stop retained state");
|
||||
pro.switch2_host_blocked = true;
|
||||
const uint32_t received_ms = now_ms;
|
||||
bluepad32_input_backend_queue_rumble(0, ControllerRumbleOutput{65, 75});
|
||||
now_ms += 1000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
pro.switch2_host_blocked = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
if (host_rumble_duration_ms() == UINT16_MAX) {
|
||||
require(switch2_host_events.size() == 5 &&
|
||||
switch2_host_events.back().received_ms == received_ms &&
|
||||
switch2_host_events.back().strong == 65,
|
||||
"held XInput host state must survive backpressure without retimestamping");
|
||||
} else {
|
||||
Bluepad32BackendDiagnostics diagnostics{};
|
||||
bluepad32_input_backend_diagnostics(&diagnostics);
|
||||
require(switch2_host_events.size() == 4 && diagnostics.switch2_ingress_drops == 1,
|
||||
"finite conventional host state must expire under feedback/backpressure, not revive");
|
||||
}
|
||||
}
|
||||
|
||||
void test_switch2_pair_lifecycle(bool right_first) {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t left = switch2_device(
|
||||
|
|
@ -3262,7 +3655,21 @@ int main(int argc, char** argv) {
|
|||
return 0;
|
||||
}
|
||||
#endif
|
||||
if (scenario == "switch2-forward") {
|
||||
if (scenario == "switch2-hd-pro") {
|
||||
test_switch2_hd_pro();
|
||||
} else if (scenario == "switch2-hd-solo-left") {
|
||||
test_switch2_hd_solo(false);
|
||||
} else if (scenario == "switch2-hd-solo-right") {
|
||||
test_switch2_hd_solo(true);
|
||||
} else if (scenario == "switch2-hd-pair") {
|
||||
test_switch2_hd_pair();
|
||||
} else if (scenario == "switch2-hd-overflow") {
|
||||
test_switch2_hd_overflow();
|
||||
} else if (scenario == "switch2-hd-epochs") {
|
||||
test_switch2_hd_epochs();
|
||||
} else if (scenario == "switch2-hd-feedback") {
|
||||
test_switch2_hd_feedback();
|
||||
} else if (scenario == "switch2-forward") {
|
||||
test_switch2_pair_lifecycle(false);
|
||||
} else if (scenario == "switch2-reverse") {
|
||||
test_switch2_pair_lifecycle(true);
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ struct uni_hid_device_s {
|
|||
uint8_t switch2_extra_buttons;
|
||||
bool switch2_identity_valid;
|
||||
uint8_t switch2_identity_address_type;
|
||||
bool switch2_host_blocked;
|
||||
unsigned switch2_host_calls;
|
||||
unsigned switch2_haptics_resets;
|
||||
};
|
||||
|
||||
void uni_hid_parser_xboxone_play_dual_rumble(
|
||||
|
|
|
|||
70
tests/switch2_haptics_test.c
Normal file
70
tests/switch2_haptics_test.c
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include "parser/uni_switch2_haptics.h"
|
||||
|
||||
static uint64_t unpack(const uint8_t sample[5]) {
|
||||
uint64_t result = 0;
|
||||
for (unsigned byte = 0; byte < 5; ++byte) result |= (uint64_t)sample[byte] << (byte * 8);
|
||||
return result;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
uni_switch2_haptics_silence(&frame);
|
||||
assert(uni_switch2_haptics_valid(&frame));
|
||||
assert(uni_switch2_haptics_is_stop(&frame));
|
||||
uint64_t sample = unpack(frame.sides[0].samples[0]);
|
||||
assert((sample & 1023) == 385); // Measured160Hz anchor.
|
||||
assert(((sample >> 20) & 1023) == 481); // Measured320Hz anchor.
|
||||
assert(((sample >> 10) & 1023) == 0 && ((sample >> 30) & 1023) == 0);
|
||||
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 96, 96, 32767, 16384);
|
||||
sample = unpack(frame.sides[0].samples[0]);
|
||||
assert((sample & 1023) == 481);
|
||||
assert(((sample >> 20) & 1023) == 577); // Upper frequency bit is frequency, not tone mode.
|
||||
assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 226);
|
||||
assert(!uni_switch2_haptics_is_stop(&frame));
|
||||
|
||||
frame.sides[0].count = 3;
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[1], 32, 64, 8192, 0);
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[2], 64, 127, 0, 32767);
|
||||
uint8_t block[16];
|
||||
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 0xab));
|
||||
assert(block[0] == 0x7b);
|
||||
assert((unpack(block + 1) & 1023) == 481);
|
||||
assert((unpack(block + 6) & 1023) == 289);
|
||||
assert(((unpack(block + 6) >> 10) & 1023) == 113);
|
||||
assert(((unpack(block + 11) >> 20) & 1023) == 670);
|
||||
assert(((unpack(block + 11) >> 10) & 1023) == 0);
|
||||
assert(((unpack(block + 11) >> 30) & 1023) == 453);
|
||||
|
||||
frame.sides[0].count = 2;
|
||||
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 15));
|
||||
assert(block[0] == 0x6f);
|
||||
for (unsigned i = 11; i < 16; ++i) assert(block[i] == 0);
|
||||
frame.sides[0].count = 1;
|
||||
assert(uni_switch2_haptics_write_block(block, &frame.sides[0], 16));
|
||||
assert(block[0] == 0x50);
|
||||
for (unsigned i = 6; i < 16; ++i) assert(block[i] == 0);
|
||||
|
||||
uni_switch2_haptics_encode_sample(frame.sides[0].samples[0], 0, 255, 65535, 65535);
|
||||
sample = unpack(frame.sides[0].samples[0]);
|
||||
assert((sample & 1023) == 196 && ((sample >> 20) & 1023) == 670);
|
||||
assert(((sample >> 10) & 1023) == 453 && ((sample >> 30) & 1023) == 453);
|
||||
assert(uni_switch2_haptics_valid(&frame));
|
||||
// A malformed internal native frame cannot bypass the amplitude envelope.
|
||||
frame.sides[0].samples[0][2] |= 0x0f;
|
||||
assert(!uni_switch2_haptics_valid(&frame));
|
||||
|
||||
uni_switch2_haptics_silence(&frame);
|
||||
frame.sides[1].count = 0;
|
||||
assert(uni_switch2_haptics_valid(&frame));
|
||||
assert(!uni_switch2_haptics_is_stop(&frame)); // A side-only mute must not clear its partner.
|
||||
frame.sides[0].count = 0;
|
||||
assert(!uni_switch2_haptics_valid(&frame));
|
||||
assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0));
|
||||
frame.sides[0].count = 4;
|
||||
assert(!uni_switch2_haptics_valid(&frame));
|
||||
assert(!uni_switch2_haptics_write_block(block, &frame.sides[0], 0));
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
// The parser uses the SDK's actual GATT types/accessors. Only the asynchronous
|
||||
// radio, run loop, platform admission and persistence endpoints are simulated.
|
||||
enum fixture_query { QUERY_NONE, QUERY_SERVICE, QUERY_CHARACTERISTICS, QUERY_DESCRIPTORS, QUERY_CCCD, QUERY_WRITE };
|
||||
#define FIXTURE_RUMBLE_HISTORY 128
|
||||
struct fixture_peer {
|
||||
uni_hid_device_t device;
|
||||
bool used, link_alive;
|
||||
|
|
@ -16,7 +17,10 @@ struct fixture_peer {
|
|||
uint16_t command_length;
|
||||
const uint8_t* pending_write;
|
||||
uint16_t pending_length;
|
||||
uint8_t pending_snapshot[33];
|
||||
uint8_t rumble[33];
|
||||
uint16_t rumble_length;
|
||||
unsigned commands, rumbles;
|
||||
uint8_t rumble_history[FIXTURE_RUMBLE_HISTORY][33];
|
||||
uint32_t rumble_times[FIXTURE_RUMBLE_HISTORY];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -220,6 +220,9 @@ static uint8_t capture_write(hci_con_handle_t handle, uint16_t value_handle, uin
|
|||
assert(value_handle == RUMBLE_HANDLE && length <= sizeof(peer->rumble));
|
||||
memcpy(peer->rumble, value, length);
|
||||
peer->rumble_length = length;
|
||||
unsigned slot = peer->rumbles % FIXTURE_RUMBLE_HISTORY;
|
||||
memcpy(peer->rumble_history[slot], value, length);
|
||||
peer->rumble_times[slot] = now_ms;
|
||||
++peer->rumbles;
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -235,6 +238,7 @@ uint8_t gatt_client_write_value_of_characteristic(btstack_packet_handler_t callb
|
|||
struct fixture_peer* peer = peer_for_handle(handle);
|
||||
peer->pending_write = value;
|
||||
peer->pending_length = length;
|
||||
memcpy(peer->pending_snapshot, value, length);
|
||||
return begin_query(callback, handle, QUERY_WRITE);
|
||||
}
|
||||
|
||||
|
|
@ -269,6 +273,8 @@ static void query_done(struct fixture_peer* peer, uint8_t status) {
|
|||
uint8_t data[9] = {GATT_EVENT_QUERY_COMPLETE};
|
||||
if (peer->query == QUERY_CCCD)
|
||||
assert(peer->pending_length == 2 && peer->pending_write[0] == 1 && peer->pending_write[1] == 0);
|
||||
if (peer->query == QUERY_WRITE)
|
||||
assert(memcmp(peer->pending_write, peer->pending_snapshot, peer->pending_length) == 0);
|
||||
peer->query = QUERY_NONE;
|
||||
data[8] = status;
|
||||
event(peer, data, sizeof(data));
|
||||
|
|
@ -617,7 +623,7 @@ static void test_rumble_delay_expiry_retry_and_teardown(void) {
|
|||
advance(24);
|
||||
assert(!rumble_active(peer));
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 12, 34);
|
||||
advance(70000); // Stateful host rumble does not expire at 65.535 seconds.
|
||||
advance(70000); // Held local feedback does not expire at65.535 seconds.
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 136 && ((rumble_frame(peer) >> 30) & 1023) == 48);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 255, 255);
|
||||
advance(1);
|
||||
|
|
@ -664,6 +670,360 @@ static void test_write_request_buffers_and_failed_completion(void) {
|
|||
assert(peer->rumbles == writes);
|
||||
}
|
||||
|
||||
static struct fixture_peer* ready_peer(uint16_t pid, bool requests) {
|
||||
reset();
|
||||
request_writes = requests;
|
||||
struct fixture_peer* peer = connect_peer(pid, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
return peer;
|
||||
}
|
||||
|
||||
static uni_switch2_haptics_frame_t native_frame(unsigned left, unsigned right, unsigned seed) {
|
||||
uni_switch2_haptics_frame_t frame = {0};
|
||||
frame.sides[0].count = left;
|
||||
frame.sides[1].count = right;
|
||||
for (unsigned side = 0; side < 2; ++side) {
|
||||
for (unsigned i = 0; i < frame.sides[side].count; ++i) {
|
||||
uni_switch2_haptics_encode_sample(frame.sides[side].samples[i],
|
||||
10 + seed + 5 * i + side, 80 + seed + i + side,
|
||||
1000 + seed * 100 + i * 200 + side * 3000,
|
||||
5000 + seed * 100 + i * 700 + side * 500);
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
static uni_switch2_haptics_side_t final_hold(const uni_switch2_haptics_side_t* source) {
|
||||
uni_switch2_haptics_side_t side = {.count = 1};
|
||||
memcpy(side.samples[0], source->samples[source->count - 1], 5);
|
||||
return side;
|
||||
}
|
||||
|
||||
static void assert_block(const struct fixture_peer* peer, unsigned side,
|
||||
const uni_switch2_haptics_side_t* expected) {
|
||||
assert(peer->rumble_length >= 17 + side * 16);
|
||||
const uint8_t* block = peer->rumble + 1 + side * 16;
|
||||
assert((block[0] & 0xf0) == (0x40 | (expected->count << 4)));
|
||||
assert(memcmp(block + 1, expected->samples, 5 * expected->count) == 0);
|
||||
for (unsigned i = 1 + 5 * expected->count; i < 16; ++i)
|
||||
assert(block[i] == 0);
|
||||
}
|
||||
|
||||
static void assert_silent(const struct fixture_peer* peer, unsigned side) {
|
||||
const uint8_t* block = peer->rumble + 1 + side * 16;
|
||||
assert((block[0] & 0xf0) == 0x50);
|
||||
assert(!(block[2] & 0xfc) && !(block[3] & 0x0f) && !(block[4] & 0xc0) && !block[5]);
|
||||
for (unsigned i = 6; i < 16; ++i)
|
||||
assert(block[i] == 0);
|
||||
}
|
||||
|
||||
static void test_native_fifo_stereo_counts_and_retry(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 2, 1);
|
||||
uni_switch2_haptics_frame_t b = native_frame(2, 0, 2);
|
||||
uni_switch2_haptics_frame_t c = native_frame(1, 3, 3);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
next_write_error = BTSTACK_ACL_BUFFERS_FULL;
|
||||
advance(1);
|
||||
assert(peer->rumbles == 0);
|
||||
advance(13);
|
||||
assert(peer->rumbles == 1 && peer->rumble_length == 33);
|
||||
assert(peer->rumble[1] == 0x70 && peer->rumble[17] == 0x60);
|
||||
assert_block(peer, 0, &a.sides[0]);
|
||||
assert_block(peer, 1, &a.sides[1]);
|
||||
// C arrives while A plays, with enough source lifetime for all three frames.
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &c, now_ms));
|
||||
advance(15);
|
||||
assert(peer->rumbles == 1); // Three samples cannot be interrupted at13ms.
|
||||
advance(1);
|
||||
assert(peer->rumbles == 2 && peer->rumble[1] == 0x61);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
uni_switch2_haptics_side_t right = final_hold(&a.sides[1]);
|
||||
assert_block(peer, 1, &right);
|
||||
advance(10);
|
||||
assert(peer->rumbles == 2);
|
||||
advance(1);
|
||||
assert(peer->rumbles == 3 && peer->rumble[1] == 0x52 && peer->rumble[17] == 0x72);
|
||||
assert_block(peer, 0, &c.sides[0]);
|
||||
assert_block(peer, 1, &c.sides[1]);
|
||||
assert(peer->rumble_times[1] - peer->rumble_times[0] == 16);
|
||||
assert(peer->rumble_times[2] - peer->rumble_times[1] == 11);
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
advance(23); // C expires at receipt14 +50, not transmission41 +50.
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
}
|
||||
|
||||
static void test_native_hold_and_absent_side_watchdogs(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 2, 4);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert_block(peer, 0, &a.sides[0]);
|
||||
advance(15);
|
||||
assert(peer->rumbles == 1);
|
||||
advance(1);
|
||||
uni_switch2_haptics_side_t left = final_hold(&a.sides[0]);
|
||||
uni_switch2_haptics_side_t right = final_hold(&a.sides[1]);
|
||||
assert(peer->rumbles == 2);
|
||||
assert_block(peer, 0, &left);
|
||||
assert_block(peer, 1, &right);
|
||||
advance(3); // t20; only the left actuator receives a fresh source update.
|
||||
uni_switch2_haptics_frame_t b = native_frame(1, 0, 5);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(3); // Last count1 hold guarded until t23.
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
assert_block(peer, 1, &right);
|
||||
advance(27);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
assert_silent(peer, 1); // Absent side must not gain a new50ms watchdog.
|
||||
advance(20);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
}
|
||||
|
||||
static void test_feedback_advances_host_and_resumes_only_final_samples(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
uni_switch2_haptics_frame_t a = native_frame(2, 3, 6);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 40, 20, 30);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 120);
|
||||
assert((peer->rumble[1] & 0xf0) == 0x50);
|
||||
advance(9);
|
||||
uni_switch2_haptics_frame_t b = native_frame(3, 0, 7);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 120);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
uni_switch2_haptics_side_t left = final_hold(&b.sides[0]);
|
||||
uni_switch2_haptics_side_t right = final_hold(&a.sides[1]);
|
||||
assert_block(peer, 0, &left);
|
||||
assert_block(peer, 1, &right);
|
||||
for (unsigned i = 0; i < peer->rumbles; ++i) {
|
||||
assert((peer->rumble_history[i][1] & 0xf0) == 0x50);
|
||||
assert((peer->rumble_history[i][17] & 0xf0) == 0x50);
|
||||
}
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 100, 50, 60);
|
||||
advance(48); // Both original host watchdogs elapse under feedback.
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
}
|
||||
|
||||
static void test_full_fifo_stop_barrier_and_async_completion(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_R_PID, true);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 0, 8);
|
||||
uni_switch2_haptics_frame_t b = native_frame(2, 0, 9);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert(peer->query == QUERY_WRITE && peer->rumble_length == 17);
|
||||
const uint8_t* borrowed = peer->pending_write;
|
||||
uint8_t snapshot[17];
|
||||
memcpy(snapshot, borrowed, sizeof(snapshot));
|
||||
for (unsigned i = 1; i < 16; ++i)
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
uni_switch2_haptics_frame_t stop;
|
||||
uni_switch2_haptics_silence(&stop);
|
||||
stop.sides[1].count = 0; // Physical Joy-Con stop is not a logical stereo stop.
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stop, now_ms - 100));
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 16);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(1);
|
||||
assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0);
|
||||
query_done(peer, 0); // Completion of old epoch must not consume b/the stop.
|
||||
advance(1);
|
||||
assert(peer->rumbles == 2 && peer->rumble[1] == 0x51);
|
||||
assert_silent(peer, 0);
|
||||
query_done(peer, 0);
|
||||
advance(6);
|
||||
assert(peer->rumbles == 3 && peer->rumble[1] == 0x62);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
query_done(peer, 0);
|
||||
advance(11);
|
||||
uni_switch2_haptics_side_t hold = final_hold(&b.sides[0]);
|
||||
assert_block(peer, 0, &hold);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
|
||||
static void test_async_expiry_and_topology_reset_do_not_revive_state(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 2, 10);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
advance(49); // Write request is still borrowing the original batch.
|
||||
assert(peer->rumbles == 1 && uni_hid_parser_switch2_haptics_dropped() == drops + 1);
|
||||
query_done(peer, 0);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
query_done(peer, 0);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 40, 50);
|
||||
advance(6);
|
||||
assert(rumble_active(peer) && peer->query == QUERY_WRITE);
|
||||
uni_switch2_haptics_frame_t b = native_frame(2, 1, 11);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
uni_hid_parser_switch2_reset_haptics(&peer->device);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &b, now_ms));
|
||||
advance(1);
|
||||
query_done(peer, 0); // Old local overlay must not clear the reset neutral.
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
query_done(peer, 0);
|
||||
advance(6);
|
||||
assert_block(peer, 0, &b.sides[0]);
|
||||
assert_block(peer, 1, &b.sides[1]);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
|
||||
static void test_host_stop_does_not_cancel_local_feedback(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, UINT16_MAX, 100, 200);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 3, 12);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 0, 0, 0, now_ms));
|
||||
advance(1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 800);
|
||||
uni_hid_parser_switch2_play_dual_rumble(&peer->device, 0, 0, 0, 0);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
assert_silent(peer, 1);
|
||||
}
|
||||
|
||||
static void test_conventional_host_lifetimes_and_invalid_native_input(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, false);
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 255, 200, UINT16_MAX, now_ms));
|
||||
advance(70000);
|
||||
assert((rumble_frame(peer) & 1023) == 0xe1);
|
||||
assert(((rumble_frame(peer) >> 20) & 1023) == 0x1e1);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 800);
|
||||
assert(((rumble_frame(peer) >> 30) & 1023) == 1020);
|
||||
assert((peer->rumble[1] & 0xf0) == 0x50);
|
||||
for (unsigned i = 7; i < 17; ++i)
|
||||
assert(peer->rumble[i] == 0);
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 1, 2, 20, now_ms - 10));
|
||||
advance(9);
|
||||
assert(((rumble_frame(peer) >> 10) & 1023) == 8);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
uni_switch2_haptics_frame_t invalid = native_frame(1, 0, 13);
|
||||
invalid.sides[0].count = 4;
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &invalid, now_ms));
|
||||
uni_switch2_haptics_frame_t stale = native_frame(1, 0, 14);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 50));
|
||||
assert(uni_hid_parser_switch2_queue_rumble(&peer->device, 10, 20, 100, now_ms - 50));
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 3);
|
||||
advance(13);
|
||||
assert_silent(peer, 0);
|
||||
}
|
||||
|
||||
static void test_native_watchdog_clock_wrap(void) {
|
||||
reset();
|
||||
now_ms = UINT32_MAX - 20;
|
||||
struct fixture_peer* peer = connect_peer(UNI_SW2_JOYCON_L_PID, false);
|
||||
discover(peer);
|
||||
subscribe_response(peer);
|
||||
finish_setup(peer);
|
||||
uni_switch2_haptics_frame_t frame = native_frame(1, 0, 15);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
advance(49);
|
||||
assert_block(peer, 0, &frame.sides[0]);
|
||||
advance(1);
|
||||
assert_silent(peer, 0);
|
||||
}
|
||||
|
||||
static void test_identical_hold_coalescing_refreshes_borrowed_head(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_JOYCON_L_PID, true);
|
||||
uni_switch2_haptics_frame_t frame = native_frame(1, 0, 16);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
advance(1);
|
||||
const uint8_t* borrowed = peer->pending_write;
|
||||
uint8_t snapshot[17];
|
||||
memcpy(snapshot, borrowed, sizeof(snapshot));
|
||||
advance(39);
|
||||
for (unsigned i = 0; i < 32; ++i)
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
assert(memcmp(snapshot, borrowed, sizeof(snapshot)) == 0);
|
||||
advance(10); // The original t0 hold would expire now without source refresh.
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
query_done(peer, 0);
|
||||
advance(39);
|
||||
assert_block(peer, 0, &frame.sides[0]);
|
||||
query_done(peer, 0);
|
||||
advance(1); // Refreshed deadline remains t40+50, not ATT completion+50.
|
||||
assert_silent(peer, 0);
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops);
|
||||
query_done(peer, 0);
|
||||
}
|
||||
|
||||
static void test_hold_coalescing_requires_identical_samples_and_side_masks(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, true);
|
||||
uni_switch2_haptics_frame_t frame;
|
||||
for (unsigned i = 0; i < 16; ++i) {
|
||||
frame = native_frame(1, 1, i + 1);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
}
|
||||
// An identical tail hold can refresh even at capacity. A partial-side update
|
||||
// is a different command, as is a changed sample; neither may erase history.
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
frame.sides[1].count = 0;
|
||||
assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
frame = native_frame(1, 1, 17);
|
||||
assert(!uni_hid_parser_switch2_queue_haptics(&peer->device, &frame, now_ms));
|
||||
advance(1);
|
||||
uni_switch2_haptics_frame_t first = native_frame(1, 1, 1);
|
||||
assert_block(peer, 0, &first.sides[0]);
|
||||
assert_block(peer, 1, &first.sides[1]);
|
||||
}
|
||||
|
||||
static void test_expired_history_does_not_starve_fresh_sequences(void) {
|
||||
struct fixture_peer* peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uni_switch2_haptics_frame_t a = native_frame(3, 3, 20);
|
||||
uni_switch2_haptics_frame_t stale = native_frame(3, 3, 21);
|
||||
uni_switch2_haptics_frame_t fresh = native_frame(3, 3, 22);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms));
|
||||
advance(1);
|
||||
unsigned sent = peer->rumbles;
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &stale, now_ms - 40));
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms));
|
||||
advance(10);
|
||||
assert(peer->rumbles == sent); // Unplayed stale history must not interrupt A.
|
||||
advance(6);
|
||||
assert_block(peer, 0, &fresh.sides[0]);
|
||||
assert_block(peer, 1, &fresh.sides[1]);
|
||||
|
||||
peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
a = native_frame(1, 1, 23);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &a, now_ms - 43));
|
||||
advance(1);
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms));
|
||||
advance(6); // A's source watchdog expires as its playback guard ends.
|
||||
assert_block(peer, 0, &fresh.sides[0]); // No unnecessary silent/HOLD packet.
|
||||
|
||||
peer = ready_peer(UNI_SW2_PRO_PID, false);
|
||||
uint32_t drops = uni_hid_parser_switch2_haptics_dropped();
|
||||
assert(uni_hid_parser_switch2_queue_haptics(&peer->device, &fresh, now_ms - 40));
|
||||
advance(1);
|
||||
assert_silent(peer, 0); // Nine ms cannot contain a complete three-frame batch.
|
||||
assert(uni_hid_parser_switch2_haptics_dropped() == drops + 1);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_connected_callback_rejection();
|
||||
test_advertisement_bounds_and_admission();
|
||||
|
|
@ -672,6 +1032,17 @@ int main(void) {
|
|||
test_calibration_physical_inputs_and_sensor_units();
|
||||
test_rumble_delay_expiry_retry_and_teardown();
|
||||
test_write_request_buffers_and_failed_completion();
|
||||
test_native_fifo_stereo_counts_and_retry();
|
||||
test_native_hold_and_absent_side_watchdogs();
|
||||
test_feedback_advances_host_and_resumes_only_final_samples();
|
||||
test_full_fifo_stop_barrier_and_async_completion();
|
||||
test_async_expiry_and_topology_reset_do_not_revive_state();
|
||||
test_host_stop_does_not_cancel_local_feedback();
|
||||
test_conventional_host_lifetimes_and_invalid_native_input();
|
||||
test_native_watchdog_clock_wrap();
|
||||
test_identical_hold_coalescing_refreshes_borrowed_head();
|
||||
test_hold_coalescing_requires_identical_samples_and_side_masks();
|
||||
test_expired_history_does_not_starve_fresh_sequences();
|
||||
reset();
|
||||
puts("Switch2 protocol boundaries, setup failure, pairing, calibration, physical input, motion and rumble passed");
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
f"-I{root / 'src' / 'firmware'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "tests" / "bluepad32_backend_lifecycle_test.cpp"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(
|
||||
root / "src" / "firmware" / "input" / "controller_macro_capture.cpp"
|
||||
),
|
||||
|
|
@ -98,6 +99,13 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"switch2-multiple-pairs",
|
||||
"switch2-admission",
|
||||
"switch2-pairing-inventory",
|
||||
"switch2-hd-pro",
|
||||
"switch2-hd-solo-left",
|
||||
"switch2-hd-solo-right",
|
||||
"switch2-hd-pair",
|
||||
"switch2-hd-overflow",
|
||||
"switch2-hd-epochs",
|
||||
"switch2-hd-feedback",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
if native:
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@ class FakeDevice:
|
|||
if request == config_manager.OP_RUNTIME_DIAGNOSTICS:
|
||||
return make_response(
|
||||
request,
|
||||
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1),
|
||||
struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1)
|
||||
+ getattr(self, "runtime_diagnostics_tail", b""),
|
||||
)
|
||||
if request == config_manager.OP_NATIVE_SWITCH_RUMBLE:
|
||||
return make_response(
|
||||
|
|
@ -1399,6 +1400,20 @@ def test_reenumeration_reports_missing_disappearance_and_return(
|
|||
config_manager._wait_for_reenumeration(snapshot, 0)
|
||||
|
||||
|
||||
def test_runtime_diagnostics_distinguishes_unreported_from_zero_drops() -> None:
|
||||
device = FakeDevice()
|
||||
legacy = config_manager.read_runtime_diagnostics(device)
|
||||
assert legacy.switch2_ingress_drops is None
|
||||
assert legacy.switch2_output_drops is None
|
||||
device.runtime_diagnostics_tail = struct.pack("<II", 0, 0xFFFFFFFF)
|
||||
current = config_manager.read_runtime_diagnostics(device)
|
||||
assert current.switch2_ingress_drops == 0
|
||||
assert current.switch2_output_drops == 0xFFFFFFFF
|
||||
device.runtime_diagnostics_tail = bytes(4)
|
||||
with pytest.raises(config_manager.ConfigManagerError):
|
||||
config_manager.read_runtime_diagnostics(device)
|
||||
|
||||
|
||||
def test_requested_and_active_mode_response_validation() -> None:
|
||||
device = FakeDevice()
|
||||
device.configuration = struct.pack(
|
||||
|
|
|
|||
30
tests/test_switch2_haptics_native.py
Normal file
30
tests/test_switch2_haptics_native.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch2_haptics_encoding(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("cc") or shutil.which("gcc")
|
||||
assert compiler is not None, "a host C compiler is required"
|
||||
executable = tmp_path / "switch2_haptics"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c11",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(root / "tests" / "switch2_haptics_test.c"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
|
|
@ -46,6 +46,7 @@ def test_switch2_parser_protocol_and_lifecycle(tmp_path: Path) -> None:
|
|||
f"-I{btstack.parent / '3rd-party' / 'yxml'}",
|
||||
str(root / "tests" / "switch2_parser_native_test.c"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c"),
|
||||
str(root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c"),
|
||||
str(btstack / "btstack_util.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ void test_vendor_requests() {
|
|||
"pairing read did not use the versioned envelope");
|
||||
|
||||
current_diagnostics = {
|
||||
6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1,
|
||||
6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, 3, UINT32_MAX,
|
||||
};
|
||||
request = setup_request(
|
||||
Operation::kRuntimeDiagnostics, TUSB_DIR_IN,
|
||||
|
|
@ -241,7 +241,9 @@ void test_vendor_requests() {
|
|||
read_u32(control_payload, kResponseHeaderSize) == 6 &&
|
||||
read_u32(control_payload, kResponseHeaderSize + 4) == 1200 &&
|
||||
control_payload[kResponseHeaderSize + 28] == 2 &&
|
||||
control_payload[kResponseHeaderSize + 31] == 1,
|
||||
control_payload[kResponseHeaderSize + 31] == 1 &&
|
||||
read_u32(control_payload, kResponseHeaderSize + 32) == 3 &&
|
||||
read_u32(control_payload, kResponseHeaderSize + 36) == UINT32_MAX,
|
||||
"runtime diagnostics did not expose backend counters");
|
||||
|
||||
perform_out(Operation::kPairingRefresh, {});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue