feat: preserve native HD rumble on Switch 2 controllers

This commit is contained in:
Joey Yakimowich-Payne 2026-09-07 08:19:53 -06:00
commit 006b573ee8
25 changed files with 1722 additions and 60 deletions

View file

@ -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);

View file

@ -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(

View 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;
}

View file

@ -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];
};

View file

@ -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;

View file

@ -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:

View file

@ -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(

View 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)

View file

@ -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",

View file

@ -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, {});