Add DualSense native pair and Wii-only IMU settling
This commit is contained in:
parent
1748910316
commit
ab70536fdf
31 changed files with 2617 additions and 77 deletions
14
tests/bluepad32_native_stubs/parser/uni_hid_parser_ds5.h
Normal file
14
tests/bluepad32_native_stubs/parser/uni_hid_parser_ds5.h
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <uni.h>
|
||||
|
||||
typedef struct {
|
||||
uint32_t report_sequence;
|
||||
uint32_t motion_sequence;
|
||||
bool motion_valid;
|
||||
} uni_ds5_bridge_snapshot_t;
|
||||
|
||||
void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t);
|
||||
bool uni_hid_parser_ds5_bridge_rumble(uni_hid_device_t*, uint16_t, uint8_t, uint8_t);
|
||||
bool uni_hid_parser_ds5_bridge_snapshot(uni_hid_device_t*, uni_ds5_bridge_snapshot_t*);
|
||||
|
|
@ -86,6 +86,7 @@ enum {
|
|||
typedef enum {
|
||||
CONTROLLER_TYPE_UnknownController = 0,
|
||||
CONTROLLER_TYPE_WiiController = 35,
|
||||
CONTROLLER_TYPE_PS5Controller = 46,
|
||||
} uni_controller_type_t;
|
||||
|
||||
typedef enum {
|
||||
|
|
@ -140,6 +141,7 @@ struct uni_report_parser_t {
|
|||
uni_set_player_leds_t set_player_leds;
|
||||
uni_set_lightbar_color_t set_lightbar_color;
|
||||
uni_play_dual_rumble_t play_dual_rumble;
|
||||
void (*parse_input_report)(uni_hid_device_t*, const uint8_t*, uint16_t);
|
||||
};
|
||||
|
||||
enum uni_bt_conn_protocol_t {
|
||||
|
|
|
|||
291
tests/dualsense_backend_test.cpp
Normal file
291
tests/dualsense_backend_test.cpp
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// Reuse the backend's transport/storage fixture; these scenarios exercise only
|
||||
// the native DualSense contract, not a second implementation of its scheduler.
|
||||
#define main backend_fixture_main
|
||||
#include "bluepad32_backend_lifecycle_test.cpp"
|
||||
#undef main
|
||||
|
||||
namespace {
|
||||
struct SensorFixture {
|
||||
uni_hid_device_t* device = nullptr;
|
||||
uni_ds5_bridge_snapshot_t metadata{};
|
||||
bool valid = false;
|
||||
};
|
||||
SensorFixture sensors[4];
|
||||
void (*during_dualsense_dispatch)() = nullptr;
|
||||
bool dualsense_transport_available = true;
|
||||
|
||||
void observe_dualsense_rumble(uni_hid_device_t* target, uint16_t delay,
|
||||
uint16_t duration, uint8_t right, uint8_t left) {
|
||||
require(state_lock_depth == 0, "DS5 driver dispatch must not hold the shared state lock");
|
||||
play_rumble(target, delay, duration, right, left);
|
||||
if (during_dualsense_dispatch) during_dualsense_dispatch();
|
||||
}
|
||||
|
||||
uni_hid_device_t dualsense(int index) {
|
||||
auto result = device(index, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
result.vendor_id = 0x054c;
|
||||
result.product_id = index == 0 ? 0x0ce6 : 0x0df2;
|
||||
result.controller_type = CONTROLLER_TYPE_PS5Controller;
|
||||
result.report_parser.parse_input_report = uni_hid_parser_ds5_parse_input_report;
|
||||
result.report_parser.play_dual_rumble = observe_dualsense_rumble;
|
||||
return result;
|
||||
}
|
||||
|
||||
void report_dualsense(uni_hid_device_t& pad, bool fresh_motion = true) {
|
||||
SensorFixture& sensor = sensors[pad.idx];
|
||||
sensor.device = &pad;
|
||||
sensor.valid = true;
|
||||
++sensor.metadata.report_sequence;
|
||||
if (fresh_motion) ++sensor.metadata.motion_sequence;
|
||||
sensor.metadata.motion_valid = fresh_motion;
|
||||
pad.controller.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
pad.controller.gamepad.buttons = BUTTON_A | BUTTON_SHOULDER_L;
|
||||
pad.controller.gamepad.accel[1] = 8193;
|
||||
pad.controller.gamepad.gyro[2] = -123456;
|
||||
pad.controller.battery = 176;
|
||||
platform_on_controller_data(&pad, &pad.controller);
|
||||
}
|
||||
|
||||
Bluepad32DualSenseBridgeSnapshot bridge_snapshot() {
|
||||
Bluepad32DualSenseBridgeSnapshot result{};
|
||||
bluepad32_input_backend_dualsense_snapshot(&result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void source_isolation() {
|
||||
start_pairing_backend();
|
||||
auto ordinary = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
ordinary.vendor_id = 0x054c;
|
||||
ordinary.product_id = 0x0ce6;
|
||||
require(platform_on_device_ready(&ordinary) == UNI_ERROR_INVALID_CONTROLLER,
|
||||
"an unsupported parser must not enter the dedicated DualSense output slots");
|
||||
bluepad32_input_backend_select_dualsense_source(ordinary.conn.btaddr);
|
||||
require(!bridge_snapshot().controller.active, "VID/PID/address alone must never select a non-PS5 parser");
|
||||
platform_on_device_disconnected(&ordinary);
|
||||
bluepad32_input_backend_select_dualsense_source(nullptr);
|
||||
auto first = dualsense(0);
|
||||
auto second = dualsense(1);
|
||||
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS, "first DS5 must connect");
|
||||
now_ms = 100;
|
||||
report_dualsense(first);
|
||||
const auto initial = bridge_snapshot();
|
||||
require(initial.slot == 0 && initial.controller.active && initial.controller.state.button_south &&
|
||||
initial.battery == 176 && initial.motion_valid && initial.accel_q13[1] == 8193 &&
|
||||
initial.gyro_q10[2] == -123456 && initial.motion_received_us == 100000,
|
||||
"native snapshot must preserve coherent physical controls and calibrated precision");
|
||||
now_ms = 120;
|
||||
platform_on_controller_data(&first, &first.controller);
|
||||
bluepad32_input_backend_report_sent(0);
|
||||
auto snapshot = bridge_snapshot();
|
||||
require(snapshot.state_generation == initial.state_generation && snapshot.received_us == 100000 &&
|
||||
snapshot.motion_sequence == initial.motion_sequence,
|
||||
"polling/cached callbacks and USB consumption must not freshen input or motion");
|
||||
report_dualsense(first, false);
|
||||
snapshot = bridge_snapshot();
|
||||
require(snapshot.received_us == 120000 && snapshot.motion_received_us == 100000 && !snapshot.motion_valid,
|
||||
"a controls-only admission must not refresh a duplicate sensor timestamp");
|
||||
sensors[0].valid = false;
|
||||
first.controller.gamepad.buttons = 0;
|
||||
platform_on_controller_data(&first, &first.controller);
|
||||
require(bridge_snapshot().controller.state.button_south,
|
||||
"malformed parser input must not publish an invented button release");
|
||||
uint64_t old_token;
|
||||
require(bluepad32_input_backend_dualsense_sample_request(0, 1, &old_token), "first source cue must queue");
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "Edge must connect");
|
||||
report_dualsense(second);
|
||||
require(!bridge_snapshot().controller.active &&
|
||||
bluepad32_input_backend_dualsense_sample_result(0, old_token) == -1,
|
||||
"auto ambiguity must fail closed and retire source work immediately");
|
||||
platform_on_device_disconnected(&second);
|
||||
platform_on_controller_data(&first, &first.controller);
|
||||
require(!bridge_snapshot().controller.active, "returning to a source cannot resurrect cached state");
|
||||
report_dualsense(first);
|
||||
snapshot = bridge_snapshot();
|
||||
require(snapshot.controller.active && snapshot.controller.connection_generation != initial.controller.connection_generation,
|
||||
"a missed ambiguous interval still needs a new adapter epoch");
|
||||
platform_on_device_connected(&second);
|
||||
require(platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "Edge reconnect must succeed");
|
||||
bluepad32_input_backend_select_dualsense_source(first.conn.btaddr);
|
||||
report_dualsense(first);
|
||||
report_dualsense(second);
|
||||
require(bridge_snapshot().slot == 0, "explicit source must ignore another live PS5");
|
||||
platform_on_device_disconnected(&first);
|
||||
require(!bridge_snapshot().controller.active, "disconnect must not migrate an explicit source");
|
||||
}
|
||||
|
||||
void stable_logical_slot() {
|
||||
start_pairing_backend();
|
||||
auto unrelated = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
auto pad = dualsense(1);
|
||||
pad.report_parser.set_lightbar_color = set_lightbar;
|
||||
// An earlier, still-unclassified transport connection must not reserve player 1.
|
||||
platform_on_device_connected(&unrelated);
|
||||
platform_on_device_connected(&pad);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "DS5 must complete setup");
|
||||
now_ms = 100;
|
||||
report_dualsense(pad);
|
||||
const auto first = bridge_snapshot();
|
||||
const auto color = switch_pro_get_slot_light_color(0);
|
||||
require(first.controller.active && first.slot == 0 &&
|
||||
pad.lightbar_red == color.red && pad.lightbar_green == color.green &&
|
||||
pad.lightbar_blue == color.blue,
|
||||
"a transport-index-1 DualSense must own logical slot 0 and its lightbar color");
|
||||
require(platform_on_device_ready(&unrelated) == UNI_ERROR_INVALID_CONTROLLER,
|
||||
"dedicated DualSense mode must reject an unrelated ready controller");
|
||||
platform_on_device_disconnected(&unrelated);
|
||||
platform_on_device_connected(&unrelated);
|
||||
require(platform_on_device_ready(&unrelated) == UNI_ERROR_INVALID_CONTROLLER,
|
||||
"remembered unrelated reconnects must remain outside logical slots");
|
||||
platform_on_device_disconnected(&unrelated);
|
||||
require(bridge_snapshot().slot == 0 &&
|
||||
bridge_snapshot().controller.connection_generation == first.controller.connection_generation &&
|
||||
pad.lightbar_calls == 1,
|
||||
"unrelated connection churn must not rebind or recolor the active DualSense");
|
||||
|
||||
platform_on_device_disconnected(&pad);
|
||||
auto reconnected = dualsense(2);
|
||||
memcpy(reconnected.conn.btaddr, pad.conn.btaddr, sizeof(pad.conn.btaddr));
|
||||
reconnected.report_parser.set_lightbar_color = set_lightbar;
|
||||
platform_on_device_connected(&reconnected);
|
||||
require(platform_on_device_ready(&reconnected) == UNI_ERROR_SUCCESS, "DS5 reconnect must succeed");
|
||||
now_ms = 200;
|
||||
report_dualsense(reconnected);
|
||||
const auto next = bridge_snapshot();
|
||||
require(next.controller.active && next.slot == 0 &&
|
||||
controller_identity_equal(next.controller.identity, first.controller.identity) &&
|
||||
next.controller.connection_generation != first.controller.connection_generation &&
|
||||
reconnected.lightbar_red == color.red && reconnected.lightbar_green == color.green &&
|
||||
reconnected.lightbar_blue == color.blue,
|
||||
"reusing another Bluetooth index must preserve identity and the first logical slot");
|
||||
auto aborted = dualsense(3);
|
||||
platform_on_device_connected(&aborted);
|
||||
platform_on_device_disconnected(&aborted);
|
||||
require(platform_on_device_ready(&aborted) == UNI_ERROR_NO_SLOTS,
|
||||
"a late ready callback must not resurrect an unassigned disconnected transport");
|
||||
require(bridge_snapshot().controller.active && bridge_snapshot().slot == 0,
|
||||
"an aborted second setup must not disturb the active source");
|
||||
auto pending0 = device(0, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
auto pending1 = device(1, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
auto pending3 = device(3, true, UNI_BT_CONN_PROTOCOL_BR_EDR);
|
||||
platform_on_device_connected(&pending0);
|
||||
platform_on_device_connected(&pending1);
|
||||
platform_on_device_connected(&pending3);
|
||||
require(!incoming_connections && !scanning_enabled,
|
||||
"unclassified transports still consume physical connection capacity");
|
||||
platform_on_device_disconnected(&pending0);
|
||||
require(incoming_connections && bridge_snapshot().slot == 0 &&
|
||||
bridge_snapshot().controller.connection_generation == next.controller.connection_generation,
|
||||
"freeing pending transport capacity must not move the logical source");
|
||||
platform_on_device_disconnected(&pending1);
|
||||
platform_on_device_disconnected(&pending3);
|
||||
}
|
||||
|
||||
void cue_lifetime() {
|
||||
start_pairing_backend();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "DS5 must connect");
|
||||
uint64_t right, left, stop;
|
||||
require(bluepad32_input_backend_dualsense_sample_request(0, 6, &right) &&
|
||||
bluepad32_input_backend_dualsense_sample_request(1, 1, &left) && right != left &&
|
||||
bluepad32_input_backend_dualsense_sample_result(0, right) == 0 && pad.rumble_calls == 0,
|
||||
"independent acceptance is not driver completion");
|
||||
dualsense_transport_available = false;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(bluepad32_input_backend_dualsense_sample_result(0, right) == 0 && pad.rumble_calls == 0,
|
||||
"a busy source driver must not count as dispatch completion");
|
||||
dualsense_transport_available = true;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 96 && pad.last_low == 160 && pad.last_rumble_duration_ms == 60 &&
|
||||
bluepad32_input_backend_dualsense_sample_result(0, right) == 1 &&
|
||||
bluepad32_input_backend_dualsense_sample_result(1, right) == -1 &&
|
||||
bluepad32_input_backend_dualsense_sample_result(1, left) == 1,
|
||||
"combined output must route R weak/right and L strong/left with the shortest safe timer");
|
||||
now_ms = 60;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 0 && pad.last_low == 160 && pad.last_rumble_duration_ms == 940,
|
||||
"ending right must preserve only the left pulse's original remaining lifetime");
|
||||
require(bluepad32_input_backend_dualsense_sample_request(0, 3, &right), "right can restart independently");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
now_ms = 85;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 0 && pad.last_low == 160, "right gap must not stop the left motor");
|
||||
now_ms = 175;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 96 && pad.last_low == 160, "later pulse must resume after its gap");
|
||||
require(bluepad32_input_backend_dualsense_sample_request(1, 0, &stop), "left stop must replace only left");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_high == 96 && pad.last_low == 0 &&
|
||||
bluepad32_input_backend_dualsense_sample_result(1, stop) == 1 &&
|
||||
bluepad32_input_backend_dualsense_sample_result(1, left) == -1,
|
||||
"a side stop needs actual dispatch and cannot stop its sibling");
|
||||
now_ms = 200;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "final pulse expiry must release both motors");
|
||||
const int stopped = pad.rumble_calls;
|
||||
now_ms = 5000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.rumble_calls == stopped, "expired pulses must never replay after a stall");
|
||||
require(bluepad32_input_backend_dualsense_sample_request(0, 1, &right), "pending timeout cue must queue");
|
||||
now_ms += 2000;
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(bluepad32_input_backend_dualsense_sample_result(0, right) == -1 && pad.rumble_calls == stopped,
|
||||
"an undispatched expired cue must fail without producing a late pulse");
|
||||
}
|
||||
|
||||
void cue_races() {
|
||||
start_pairing_backend();
|
||||
auto pad = dualsense(0);
|
||||
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "DS5 must connect");
|
||||
uint64_t token;
|
||||
require(bluepad32_input_backend_dualsense_sample_request(0, 1, &token), "race cue must queue");
|
||||
during_dualsense_dispatch = [] { bluepad32_input_backend_dualsense_sample_cancel(0); };
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
during_dualsense_dispatch = nullptr;
|
||||
require(bluepad32_input_backend_dualsense_sample_result(0, token) == -1,
|
||||
"cancellation during dispatch must defeat a late completion");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "in-flight cancellation must retain a bounded stop obligation");
|
||||
require(bluepad32_input_backend_dualsense_sample_request(1, 1, &token), "reselection race must queue");
|
||||
during_dualsense_dispatch = [] { bluepad32_input_backend_select_dualsense_source(nullptr); };
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
during_dualsense_dispatch = nullptr;
|
||||
require(bluepad32_input_backend_dualsense_sample_result(1, token) == -1,
|
||||
"reselection must retire an in-flight token even for the same physical source");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(pad.last_rumble_duration_ms == 0, "reselection cannot orphan the just-dispatched motor");
|
||||
require(bluepad32_input_backend_dualsense_sample_request(0, 1, &token), "disconnect race must queue");
|
||||
platform_on_device_disconnected(&pad);
|
||||
auto replacement = dualsense(0);
|
||||
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS, "replacement must connect");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(bluepad32_input_backend_dualsense_sample_result(0, token) == -1 && replacement.rumble_calls == 0,
|
||||
"old tokens and deferred stops must never enter a replacement connection");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t) {}
|
||||
extern "C" bool uni_hid_parser_ds5_bridge_rumble(
|
||||
uni_hid_device_t* pad, uint16_t duration, uint8_t right, uint8_t left) {
|
||||
if (!dualsense_transport_available) return false;
|
||||
observe_dualsense_rumble(pad, 0, duration, right, left);
|
||||
return true;
|
||||
}
|
||||
extern "C" bool uni_hid_parser_ds5_bridge_snapshot(uni_hid_device_t* pad, uni_ds5_bridge_snapshot_t* out) {
|
||||
for (const auto& fixture : sensors) {
|
||||
if (fixture.device != pad || !fixture.valid) continue;
|
||||
*out = fixture.metadata;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
require(argc == 2, "scenario required");
|
||||
const std::string scenario = argv[1];
|
||||
if (scenario == "source-isolation") source_isolation();
|
||||
else if (scenario == "cue-lifetime") cue_lifetime();
|
||||
else if (scenario == "cue-races") cue_races();
|
||||
else if (scenario == "stable-logical-slot") stable_logical_slot();
|
||||
else require(false, "unknown DualSense scenario");
|
||||
return 0;
|
||||
}
|
||||
262
tests/dualsense_parser_native_test.c
Normal file
262
tests/dualsense_parser_native_test.c
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "parser/uni_hid_parser_ds5.h"
|
||||
#include "uni_hid_device.h"
|
||||
#include "uni_utils.h"
|
||||
|
||||
// Real staged DS5 parser; substitute only the radio, virtual mouse and clock.
|
||||
static uni_hid_device_t device;
|
||||
static unsigned ready_count;
|
||||
static uint8_t requested_feature;
|
||||
static uint8_t output[79];
|
||||
static unsigned output_count;
|
||||
static uint32_t now_ms;
|
||||
static bool transport_available = true;
|
||||
static int send_status = ERROR_CODE_SUCCESS;
|
||||
static unsigned disconnect_count;
|
||||
static struct {
|
||||
btstack_timer_source_t* timer;
|
||||
uint32_t deadline;
|
||||
bool active;
|
||||
} timers[2];
|
||||
|
||||
void uni_log(const char* fmt, ...) { (void)fmt; }
|
||||
void uni_hid_device_send_ctrl_report(uni_hid_device_t* d, const uint8_t* bytes, uint16_t len) {
|
||||
assert(d == &device && len == 2 && bytes[0] == 0x43);
|
||||
requested_feature = bytes[1];
|
||||
}
|
||||
void uni_hid_device_send_intr_report(uni_hid_device_t* d, const uint8_t* bytes, uint16_t len) {
|
||||
assert(d == &device && len == sizeof(output));
|
||||
memcpy(output, bytes, len);
|
||||
++output_count;
|
||||
}
|
||||
uint32_t btstack_run_loop_get_time_ms(void) { return now_ms; }
|
||||
int l2cap_can_send_packet_now(uint16_t cid) {
|
||||
assert(cid == device.conn.interrupt_cid);
|
||||
return transport_available;
|
||||
}
|
||||
int l2cap_send(uint16_t cid, uint8_t* bytes, uint16_t len) {
|
||||
assert(cid == device.conn.interrupt_cid);
|
||||
if (send_status != ERROR_CODE_SUCCESS) return send_status;
|
||||
uni_hid_device_send_intr_report(&device, bytes, len);
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
void uni_hid_device_disconnect(uni_hid_device_t* d) {
|
||||
assert(d == &device);
|
||||
++disconnect_count;
|
||||
uni_hid_parser_ds5_bridge_teardown(d);
|
||||
}
|
||||
bool uni_hid_device_set_ready_complete(uni_hid_device_t* d) {
|
||||
assert(d == &device);
|
||||
++ready_count;
|
||||
return true;
|
||||
}
|
||||
uni_hid_device_t* uni_hid_device_create_virtual(uni_hid_device_t* d) { (void)d; return NULL; }
|
||||
void uni_hid_device_set_cod(uni_hid_device_t* d, uint32_t cod) { (void)d; (void)cod; }
|
||||
void uni_hid_device_connect(uni_hid_device_t* d) { (void)d; }
|
||||
void uni_hid_device_process_controller(uni_hid_device_t* d) { (void)d; }
|
||||
uint8_t uni_hid_parser_hat_to_dpad(uint8_t hat) {
|
||||
const uint8_t values[8] = {DPAD_UP, DPAD_UP | DPAD_RIGHT, DPAD_RIGHT,
|
||||
DPAD_RIGHT | DPAD_DOWN, DPAD_DOWN, DPAD_DOWN | DPAD_LEFT,
|
||||
DPAD_LEFT, DPAD_LEFT | DPAD_UP};
|
||||
return hat < 8 ? values[hat] : 0;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms) {
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (timers[i].timer != timer && timers[i].timer != NULL) continue;
|
||||
timers[i].timer = timer;
|
||||
timers[i].deadline = now_ms + ms;
|
||||
return;
|
||||
}
|
||||
assert(false);
|
||||
}
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||
for (unsigned i = 0; i < 2; ++i)
|
||||
if (timers[i].timer == timer) { timers[i].active = true; return; }
|
||||
assert(false);
|
||||
}
|
||||
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer) {
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (timers[i].timer != timer) continue;
|
||||
const bool was_active = timers[i].active;
|
||||
timers[i].active = false;
|
||||
return was_active;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static void advance(uint32_t time) {
|
||||
now_ms = time;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
if (!timers[i].active || (int32_t)(now_ms - timers[i].deadline) < 0) continue;
|
||||
timers[i].active = false;
|
||||
timers[i].timer->process(timers[i].timer);
|
||||
}
|
||||
}
|
||||
static void put16(uint8_t* bytes, int16_t value) {
|
||||
bytes[0] = (uint16_t)value;
|
||||
bytes[1] = (uint16_t)value >> 8;
|
||||
}
|
||||
static void put32(uint8_t* bytes, uint32_t value) {
|
||||
for (unsigned i = 0; i < 4; ++i) bytes[i] = value >> (8 * i);
|
||||
}
|
||||
static void seal(uint8_t* bytes, size_t size, uint8_t transaction) {
|
||||
uint32_t crc = uni_crc32_le(UINT32_MAX, &transaction, 1);
|
||||
crc = ~uni_crc32_le(crc, bytes, size - 4);
|
||||
put32(bytes + size - 4, crc);
|
||||
}
|
||||
static void feature(uint8_t* bytes, uint16_t len) {
|
||||
seal(bytes, len, 0xa3);
|
||||
uni_hid_parser_ds5_parse_feature_report(&device, bytes, len);
|
||||
}
|
||||
static void calibration(uint8_t bytes[41], bool fallback) {
|
||||
memset(bytes, 0, 41);
|
||||
bytes[0] = 5;
|
||||
const int16_t bias[3] = {10, -20, 30};
|
||||
for (unsigned axis = 0; axis < 3; ++axis) {
|
||||
put16(bytes + 1 + axis * 2, bias[axis]);
|
||||
put16(bytes + 7 + axis * 4, bias[axis] + 100);
|
||||
put16(bytes + 9 + axis * 4, bias[axis] - 100);
|
||||
put16(bytes + 23 + axis * 4, 8192);
|
||||
put16(bytes + 25 + axis * 4, -8192);
|
||||
}
|
||||
put16(bytes + 19, 100);
|
||||
put16(bytes + 21, 100);
|
||||
if (fallback) {
|
||||
put16(bytes + 23, 0);
|
||||
put16(bytes + 25, 0);
|
||||
}
|
||||
}
|
||||
static void input(uint8_t bytes[78], uint32_t timestamp) {
|
||||
memset(bytes, 0, 78);
|
||||
bytes[0] = 0x31;
|
||||
bytes[2] = bytes[3] = bytes[4] = bytes[5] = 127;
|
||||
bytes[9] = 0x28; // Cross + neutral hat.
|
||||
bytes[11] = 0x02; // Touchpad click, not mute.
|
||||
put16(bytes + 17, 11);
|
||||
put16(bytes + 19, -20);
|
||||
put16(bytes + 21, 30);
|
||||
put16(bytes + 25, 8192);
|
||||
put32(bytes + 29, timestamp);
|
||||
seal(bytes, 78, 0xa1);
|
||||
}
|
||||
static uni_ds5_bridge_snapshot_t feed(uint8_t* bytes, uint16_t len, bool admitted) {
|
||||
uni_hid_parser_ds5_init_report(&device);
|
||||
uni_hid_parser_ds5_parse_input_report(&device, bytes, len);
|
||||
uni_ds5_bridge_snapshot_t snapshot = {0};
|
||||
assert(uni_hid_parser_ds5_bridge_snapshot(&device, &snapshot) == admitted);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
device.controller_type = CONTROLLER_TYPE_PS5Controller;
|
||||
device.product_id = 0x0df2; // Edge takes the real PS5 path.
|
||||
device.report_parser.setup = uni_hid_parser_ds5_setup;
|
||||
device.conn.interrupt_cid = 0x40;
|
||||
uni_hid_parser_ds5_setup(&device);
|
||||
assert(requested_feature == 9);
|
||||
uint8_t pairing[20] = {9};
|
||||
feature(pairing, sizeof(pairing));
|
||||
assert(requested_feature == 0x20);
|
||||
uint8_t firmware[64] = {0x20};
|
||||
feature(firmware, sizeof(firmware));
|
||||
assert(requested_feature == 5);
|
||||
uint8_t calib[41];
|
||||
calibration(calib, false);
|
||||
seal(calib, sizeof(calib), 0xa3);
|
||||
uni_hid_parser_ds5_parse_feature_report(&device, calib, 40);
|
||||
assert(ready_count == 0); // A partial feature cannot initialize calibration.
|
||||
calib[25] ^= 1;
|
||||
uni_hid_parser_ds5_parse_feature_report(&device, calib, sizeof(calib));
|
||||
assert(ready_count == 0); // Nor can a full feature with a corrupt CRC.
|
||||
calibration(calib, true);
|
||||
feature(calib, sizeof(calib));
|
||||
assert(ready_count == 1);
|
||||
uint8_t report[78];
|
||||
input(report, 100);
|
||||
uni_ds5_bridge_snapshot_t snapshot = feed(report, sizeof(report), true);
|
||||
assert(!snapshot.motion_valid && snapshot.motion_sequence == 0);
|
||||
assert(device.controller.gamepad.buttons & BUTTON_A); // Controls survive fallback.
|
||||
calibration(calib, false);
|
||||
feature(calib, sizeof(calib));
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(!snapshot.motion_valid && snapshot.motion_sequence == 0); // Calibration alone is not fresh motion.
|
||||
input(report, 101);
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(snapshot.motion_valid && snapshot.motion_sequence == 1);
|
||||
assert(device.controller.gamepad.gyro[0] == 1024 && device.controller.gamepad.gyro[1] == 0);
|
||||
assert(device.controller.gamepad.accel[1] == 8192);
|
||||
assert(device.controller.gamepad.misc_buttons & MISC_BUTTON_CAPTURE);
|
||||
const uint32_t report_sequence = snapshot.report_sequence;
|
||||
uni_ds5_bridge_snapshot_t polled;
|
||||
assert(uni_hid_parser_ds5_bridge_snapshot(&device, &polled) && polled.report_sequence == report_sequence);
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(!snapshot.motion_valid && snapshot.motion_sequence == 1);
|
||||
input(report, 99);
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(!snapshot.motion_valid && snapshot.motion_sequence == 1);
|
||||
input(report, 102);
|
||||
feed(report, 77, false);
|
||||
report[9] ^= 0x20;
|
||||
feed(report, sizeof(report), false);
|
||||
feed(NULL, 0, false);
|
||||
input(report, 102);
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(snapshot.motion_valid && snapshot.motion_sequence == 2);
|
||||
|
||||
// A real uint32 sensor-clock wrap is forward progress, not a duplicate.
|
||||
uni_hid_parser_ds5_setup(&device);
|
||||
calibration(calib, false);
|
||||
feature(calib, sizeof(calib));
|
||||
input(report, UINT32_MAX - 15);
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(snapshot.motion_valid && snapshot.motion_sequence == 1);
|
||||
input(report, 16);
|
||||
snapshot = feed(report, sizeof(report), true);
|
||||
assert(snapshot.motion_valid && snapshot.motion_sequence == 2);
|
||||
|
||||
const unsigned before_busy = output_count;
|
||||
transport_available = false;
|
||||
assert(!uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217) && output_count == before_busy);
|
||||
transport_available = true;
|
||||
send_status = BTSTACK_ACL_BUFFERS_FULL;
|
||||
assert(!uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217) && output_count == before_busy);
|
||||
send_status = ERROR_CODE_SUCCESS;
|
||||
assert(uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217));
|
||||
assert(output[6] == 31 && output[7] == 217); // Wire motor right/left, not callback echoes.
|
||||
advance(59);
|
||||
assert(output[6] == 31 && output[7] == 217);
|
||||
advance(60);
|
||||
assert(output[6] == 0 && output[7] == 0); // Real parser's finite duration timer stops both.
|
||||
uni_hid_parser_ds5_play_dual_rumble(&device, 100, 1000, 90, 0);
|
||||
const unsigned sent = output_count;
|
||||
uni_hid_parser_ds5_bridge_teardown(&device);
|
||||
advance(2000);
|
||||
assert(output_count == sent && !uni_hid_parser_ds5_bridge_snapshot(&device, &snapshot));
|
||||
uni_hid_parser_ds5_play_dual_rumble(&device, 0, 1000, 0, 90);
|
||||
const unsigned active_sent = output_count;
|
||||
uni_hid_parser_ds5_bridge_teardown(&device);
|
||||
advance(4000);
|
||||
assert(output_count == active_sent); // No timer callback into reused parser memory.
|
||||
assert(uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217));
|
||||
transport_available = false;
|
||||
advance(4060);
|
||||
assert(output[6] == 31 && output[7] == 217 && disconnect_count == 0);
|
||||
transport_available = true;
|
||||
advance(4065);
|
||||
assert(output[6] == 0 && output[7] == 0 && disconnect_count == 0);
|
||||
assert(uni_hid_parser_ds5_bridge_rumble(&device, 60, 31, 217));
|
||||
transport_available = false;
|
||||
advance(4125);
|
||||
advance(6125);
|
||||
assert(disconnect_count == 1); // OFF cannot stall forever on a live link.
|
||||
const unsigned after_disconnect = output_count;
|
||||
advance(9000);
|
||||
assert(output_count == after_disconnect);
|
||||
puts("DualSense calibrated admission and bounded driver lifetime passed");
|
||||
return 0;
|
||||
}
|
||||
335
tests/switch2_dualsense_bridge_test.cpp
Normal file
335
tests/switch2_dualsense_bridge_test.cpp
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "controller_input.h"
|
||||
#include "input/bluepad32_input_backend.h"
|
||||
#include "model.h"
|
||||
#include "pico/stdlib.h"
|
||||
#include "platform/pico/bootsel_pairing_button.h"
|
||||
#include "profile/controller_profile_runtime.h"
|
||||
|
||||
namespace {
|
||||
uint64_t now_us = 1000000;
|
||||
uint32_t stage;
|
||||
Bluepad32DualSenseBridgeSnapshot source;
|
||||
ControllerProfile profile;
|
||||
bool alternating_shortcut;
|
||||
bool shortcut_phase;
|
||||
probe_controller_input controls[2];
|
||||
uint8_t reports[2][63];
|
||||
}
|
||||
|
||||
uint32_t time_us_32() { return static_cast<uint32_t>(now_us); }
|
||||
absolute_time_t get_absolute_time() { return now_us; }
|
||||
uint32_t to_ms_since_boot(absolute_time_t time) { return static_cast<uint32_t>(time / 1000); }
|
||||
void system_clock_initialize() {}
|
||||
extern "C" int probe_debug_printf(const char*, ...) { return 0; }
|
||||
BootselPairingButtonEvent bootsel_pairing_button_task() { return BootselPairingButtonEvent::kNone; }
|
||||
void bluepad32_input_backend_init() { stage = 1; }
|
||||
void bluepad32_input_backend_start() { stage = 2; }
|
||||
void bluepad32_input_backend_poll() {}
|
||||
void bluepad32_input_backend_diagnostics(Bluepad32BackendDiagnostics* out) { *out = {}; out->initialization_stage = stage; }
|
||||
void bluepad32_input_backend_open_pairing_window() {}
|
||||
void bluepad32_input_backend_select_dualsense_source(const uint8_t*) {}
|
||||
void bluepad32_input_backend_dualsense_snapshot(Bluepad32DualSenseBridgeSnapshot* out) { *out = source; }
|
||||
bool bluepad32_input_backend_dualsense_sample_request(uint8_t, uint8_t, uint64_t*) { return false; }
|
||||
int bluepad32_input_backend_dualsense_sample_result(uint8_t, uint64_t) { return -1; }
|
||||
void bluepad32_input_backend_dualsense_sample_cancel(uint8_t) {}
|
||||
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
|
||||
void controller_profile_runtime_reset() { profile = controller_profile_default(controller_identity_global(), 0); }
|
||||
bool controller_profile_runtime_take_initial_profile_indication(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
|
||||
bool controller_profile_runtime_take_profile_change(uint8_t, ControllerProfileRuntimeProfileChangeEvent*) { return false; }
|
||||
ControllerProfileTransformResult controller_profile_runtime_transform(
|
||||
uint8_t, const Bluepad32SlotSnapshot& input, uint32_t, AdapterUsbMode) {
|
||||
if (!input.active) return {};
|
||||
auto result = controller_profile_transform(input.state, profile);
|
||||
if (alternating_shortcut) {
|
||||
// Model a runtime synthetic transition spanning the two halves. Two
|
||||
// evaluations for one paired report would expose contradictory states.
|
||||
shortcut_phase = !shortcut_phase;
|
||||
result.state.button_system = result.state.button_capture = shortcut_phase;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace {
|
||||
uint32_t now_ms() { return to_ms_since_boot(now_us); }
|
||||
void put_pair(uint8_t* out, uint16_t x, uint16_t y) {
|
||||
out[0] = static_cast<uint8_t>(x);
|
||||
out[1] = static_cast<uint8_t>((x >> 8) | (y << 4));
|
||||
out[2] = static_cast<uint8_t>(y >> 4);
|
||||
}
|
||||
void calibrate(uint8_t instance, uint16_t x, uint16_t y, uint16_t px, uint16_t py, uint16_t nx, uint16_t ny) {
|
||||
uint8_t record[9];
|
||||
put_pair(record, x, y);
|
||||
put_pair(record + 3, px, py);
|
||||
put_pair(record + 6, nx, ny);
|
||||
probe_controller_input_set_full_stick_calibration(instance, record);
|
||||
}
|
||||
uint16_t stick_x(uint8_t instance) { return reports[instance][5] | ((reports[instance][6] & 15u) << 8); }
|
||||
uint16_t stick_y(uint8_t instance) { return (reports[instance][6] >> 4) | (reports[instance][7] << 4); }
|
||||
uint8_t imu_length(uint8_t instance) { return reports[instance][probe_model_imu_length_offset(instance)]; }
|
||||
uint32_t bits(const uint8_t* bytes, unsigned offset, unsigned count) {
|
||||
uint32_t value = 0;
|
||||
for (unsigned i = 0; i < count; ++i) value |= uint32_t((bytes[(offset + i) / 8] >> ((offset + i) % 8)) & 1) << i;
|
||||
return value;
|
||||
}
|
||||
void quaternion(uint8_t instance, double out[4]) {
|
||||
const uint8_t* imu = reports[instance] + probe_model_imu_data_offset(instance);
|
||||
assert(imu_length(instance) == 30);
|
||||
const unsigned largest = bits(imu, 32, 3);
|
||||
assert(largest < 4);
|
||||
double ratios[3], norm = 1;
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
ratios[i] = bits(imu, 35 + 31 * i, 31) / 1073741824.0 - 1;
|
||||
norm += ratios[i] * ratios[i];
|
||||
}
|
||||
out[largest] = 1 / sqrt(norm);
|
||||
for (unsigned i = 0; i < 3; ++i) out[(largest + i + 1) & 3] = ratios[i] * out[largest];
|
||||
}
|
||||
void publish(bool motion = true) {
|
||||
now_us += 4000;
|
||||
source.received_us = time_us_32();
|
||||
++source.state_generation;
|
||||
if (motion) {
|
||||
source.motion_received_us = time_us_32();
|
||||
++source.motion_sequence;
|
||||
}
|
||||
}
|
||||
uint32_t peek(uint8_t instance) {
|
||||
probe_controller_input_poll(instance, now_ms(), &controls[instance]);
|
||||
return probe_controller_input_peek_native_report(instance, now_ms(), reports[instance]);
|
||||
}
|
||||
void consume(uint8_t instance) {
|
||||
const uint32_t token = peek(instance);
|
||||
assert(token && probe_controller_input_commit_native_report(instance, token));
|
||||
}
|
||||
void pair() { consume(0); consume(1); }
|
||||
void no_mouse_or_rails() {
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
assert((reports[i][3] & 0xc0) == 0);
|
||||
assert(reports[i][9] == 0 && reports[i][10] == 0 && reports[i][11] == 0 && reports[i][12] == 0);
|
||||
assert(reports[i][13] == 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
void mapped_halves_and_calibration() {
|
||||
source.slot = 2;
|
||||
source.controller.active = true;
|
||||
source.controller.connection_generation = 7;
|
||||
source.controller.identity = controller_identity_global();
|
||||
publish();
|
||||
// Neither an absent source nor an uncalibrated child masquerades as active.
|
||||
assert(!peek(0) && !controls[0].active);
|
||||
calibrate(0, 2000, 2100, 1500, 1400, 1600, 1700);
|
||||
assert(peek(0));
|
||||
assert(!peek(1) && !controls[1].active);
|
||||
calibrate(1, 1800, 1900, 1700, 1800, 1400, 1500);
|
||||
pair();
|
||||
assert(stick_x(0) == 2000 && stick_y(0) == 2100);
|
||||
assert(stick_x(1) == 1800 && stick_y(1) == 1900);
|
||||
// Actual profile transforms can move controls across native children.
|
||||
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kSouth)] =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kDpadRight);
|
||||
profile.button_map[static_cast<unsigned>(ControllerProfileLogicalButton::kDpadLeft)] =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kEast);
|
||||
profile.triggers[0].digital_threshold = 20000;
|
||||
profile.triggers[1].digital_threshold = 30000;
|
||||
ControllerState& state = source.controller.state;
|
||||
state.button_south = state.dpad_left = true;
|
||||
state.button_left_shoulder = state.button_right_shoulder = true;
|
||||
state.button_select = state.button_start = true;
|
||||
state.button_left_stick = state.button_right_stick = true;
|
||||
state.button_system = state.button_capture = true;
|
||||
state.left_trigger = 19999;
|
||||
state.right_trigger = 30000;
|
||||
state.right_stick_x = INT16_MAX;
|
||||
state.left_stick_y = INT16_MIN;
|
||||
publish(); pair();
|
||||
assert(reports[0][2] == 0xf2 && reports[1][2] == 0xd2);
|
||||
assert(reports[0][3] == 1 && reports[1][3] == 1);
|
||||
assert(stick_x(0) == 3500 && stick_y(0) == 2100);
|
||||
assert(stick_x(1) == 1800 && stick_y(1) == 3700);
|
||||
no_mouse_or_rails();
|
||||
state = {};
|
||||
state.button_west = state.button_north = true;
|
||||
state.dpad_up = state.dpad_down = true;
|
||||
state.left_trigger = 20000;
|
||||
state.right_stick_x = INT16_MIN;
|
||||
state.left_stick_y = INT16_MAX;
|
||||
publish(); pair();
|
||||
assert(reports[0][2] == 0x0c && reports[1][2] == 0x29);
|
||||
assert(stick_x(0) == 400 && stick_y(1) == 400);
|
||||
// A malformed calibration may not spill a 12-bit axis into its neighbor.
|
||||
const uint32_t left_pending = peek(1);
|
||||
calibrate(0, 2000, 2100, 3000, 1400, 1600, 1700);
|
||||
assert(!peek(0));
|
||||
assert(probe_controller_input_commit_native_report(1, left_pending));
|
||||
calibrate(0, 2000, 2100, 1500, 1400, 1600, 1700);
|
||||
state = {};
|
||||
profile = controller_profile_default(controller_identity_global(), 0);
|
||||
alternating_shortcut = true;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
publish(); pair();
|
||||
assert(reports[0][3] == reports[1][3]);
|
||||
}
|
||||
alternating_shortcut = false;
|
||||
}
|
||||
|
||||
void independent_backpressure_and_resets() {
|
||||
publish();
|
||||
const uint32_t blocked_left = peek(1);
|
||||
const uint32_t right = peek(0);
|
||||
uint8_t saved[63]; memcpy(saved, reports[0], sizeof(saved));
|
||||
assert(peek(0) == right && memcmp(saved, reports[0], sizeof(saved)) == 0);
|
||||
assert(!probe_controller_input_commit_native_report(1, right));
|
||||
assert(probe_controller_input_commit_native_report(0, right));
|
||||
assert(!probe_controller_input_commit_native_report(0, right));
|
||||
assert(probe_controller_input_commit_native_report(1, blocked_left));
|
||||
publish();
|
||||
const uint32_t obsolete = peek(1);
|
||||
for (unsigned i = 0; i < 40; ++i) {
|
||||
source.controller.state.dpad_down = (i & 1) != 0;
|
||||
source.controller.state.button_east = (i & 1) != 0;
|
||||
publish(); consume(0);
|
||||
}
|
||||
const uint32_t latest = peek(1);
|
||||
assert(latest != obsolete && reports[1][2] == 1);
|
||||
assert(!probe_controller_input_commit_native_report(1, obsolete));
|
||||
probe_controller_input_set_native_stream(0, false);
|
||||
assert(!peek(0));
|
||||
assert(probe_controller_input_commit_native_report(1, latest));
|
||||
probe_controller_input_set_native_stream(0, true);
|
||||
const uint32_t right_pending = peek(0);
|
||||
probe_controller_input_set_native_stream(1, false);
|
||||
assert(probe_controller_input_commit_native_report(0, right_pending));
|
||||
probe_controller_input_set_native_stream(1, true);
|
||||
source.controller.state = {};
|
||||
}
|
||||
|
||||
void real_motion_admission_and_loss() {
|
||||
source.motion_valid = true;
|
||||
source.accel_q13[1] = 8192; // SDL face-up gravity -> native +Z, no mouse mounting.
|
||||
// These values have already passed the DS5 factory-calibration path.
|
||||
// Even a controller rotating at connection must not wait for stationary bias estimation.
|
||||
source.gyro_q10[0] = 0;
|
||||
source.gyro_q10[1] = 90 * 1024;
|
||||
source.gyro_q10[2] = 0;
|
||||
publish(); pair();
|
||||
assert(imu_length(0) == 30 && imu_length(1) == 30);
|
||||
source.gyro_q10[1] = 0;
|
||||
publish(); pair();
|
||||
// Polling and fresh button packets cannot create additional IMU samples.
|
||||
for (unsigned i = 0; i < 420; ++i) {
|
||||
publish(false); pair();
|
||||
assert(controls[0].active && controls[1].active);
|
||||
assert(imu_length(0) == 0 && imu_length(1) == 0);
|
||||
}
|
||||
publish(); pair(); // Fresh factory-calibrated data recovers without another settling delay.
|
||||
assert(imu_length(0) == 30 && imu_length(1) == 30);
|
||||
const uint8_t* right_imu = reports[0] + probe_model_imu_data_offset(0);
|
||||
const uint8_t* left_imu = reports[1] + probe_model_imu_data_offset(1);
|
||||
assert(memcmp(right_imu, left_imu, 30) == 0);
|
||||
assert(bits(right_imu, 128, 32) == 0 && bits(right_imu, 160, 32) == 0);
|
||||
assert(bits(right_imu, 192, 32) == (1u << 28));
|
||||
double initial[4]; quaternion(0, initial);
|
||||
// A new controls packet with no new IMU cannot emit the old sample again.
|
||||
source.controller.state.button_east = true;
|
||||
publish(false); pair();
|
||||
assert(reports[0][2] == 2 && imu_length(0) == 0 && imu_length(1) == 0);
|
||||
// A blocked child's motion is not consumed by the other child's endpoint.
|
||||
publish(); consume(0);
|
||||
const uint32_t left_pending = peek(1);
|
||||
assert(imu_length(1) == 30);
|
||||
consume(0); assert(imu_length(0) == 0);
|
||||
probe_controller_input_set_native_stream(0, false);
|
||||
assert(probe_controller_input_commit_native_report(1, left_pending));
|
||||
probe_controller_input_set_native_stream(0, true);
|
||||
publish(); pair();
|
||||
assert(imu_length(0) == 30 && imu_length(1) == 30); // No shared recalibration on USB reset.
|
||||
// One second of genuine 90dps yaw advances the same rigid orientation once,
|
||||
// not twice because two virtual endpoints happen to consume it.
|
||||
source.gyro_q10[1] += 90 * 1024;
|
||||
for (unsigned i = 0; i < 250; ++i) { publish(); pair(); }
|
||||
double turned[4]; quaternion(0, turned);
|
||||
double dot = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) dot += initial[i] * turned[i];
|
||||
assert(fabs(fabs(dot) - sqrt(.5)) < .015);
|
||||
quaternion(1, initial);
|
||||
for (unsigned i = 0; i < 4; ++i) assert(fabs(initial[i] - turned[i]) < 1e-8);
|
||||
source.gyro_q10[1] -= 90 * 1024;
|
||||
publish();
|
||||
const uint32_t obsolete = peek(0);
|
||||
source.motion_valid = false;
|
||||
assert(!probe_controller_input_commit_native_report(0, obsolete));
|
||||
publish(false); pair();
|
||||
assert(controls[0].active && reports[0][2] == 2 && imu_length(0) == 0 && imu_length(1) == 0);
|
||||
source.motion_valid = true;
|
||||
publish(); pair();
|
||||
assert(imu_length(0) == 30);
|
||||
for (unsigned i = 0; i < 38; ++i) { publish(false); pair(); }
|
||||
assert(controls[0].active && reports[0][2] == 2 && imu_length(0) == 0 && imu_length(1) == 0);
|
||||
publish();
|
||||
const uint32_t old_right = peek(0), old_left = peek(1);
|
||||
// Even a reconnect whose teardown was missed retires both USB identities.
|
||||
++source.controller.connection_generation;
|
||||
source.motion_valid = false; // The backend withholds motion until a new report in the new epoch.
|
||||
assert(!probe_controller_input_commit_native_report(0, old_right));
|
||||
assert(!probe_controller_input_commit_native_report(1, old_left));
|
||||
pair();
|
||||
assert(controls[0].active && imu_length(0) == 0 && imu_length(1) == 0);
|
||||
source.controller.active = false;
|
||||
memset(reports[0], 0x5a, 63);
|
||||
assert(!peek(0) && !controls[0].active);
|
||||
for (uint8_t byte : reports[0]) assert(byte == 0x5a);
|
||||
assert(!peek(1) && !controls[1].active);
|
||||
source.controller.active = true;
|
||||
++source.controller.connection_generation;
|
||||
publish(); pair();
|
||||
now_us += 500000;
|
||||
assert(!peek(0) && !peek(1));
|
||||
assert(!controls[0].active && !controls[1].active);
|
||||
}
|
||||
void selected_motion_target_keeps_both_control_halves() {
|
||||
source = {};
|
||||
source.slot = 0;
|
||||
source.controller.active = true;
|
||||
source.controller.connection_generation = 99;
|
||||
source.controller.state.button_south = true;
|
||||
source.controller.state.dpad_up = true;
|
||||
source.motion_valid = true;
|
||||
source.accel_q13[1] = 8192;
|
||||
profile = controller_profile_default(controller_identity_global(), 0);
|
||||
calibrate(0, 2048, 2048, 2047, 2047, 2048, 2048);
|
||||
calibrate(1, 2048, 2048, 2047, 2047, 2048, 2048);
|
||||
publish(); pair();
|
||||
for (uint8_t instance = 0; instance < 2; ++instance) {
|
||||
assert(controls[instance].active);
|
||||
const bool enabled = (SWITCH2_BRIDGE_IMU_TARGET_MASK & (1u << instance)) != 0;
|
||||
assert(imu_length(instance) == (enabled ? 30 : 0));
|
||||
}
|
||||
assert(reports[0][2] == 0x01 && reports[1][2] == 0x08);
|
||||
source.gyro_q10[1] = 90 * 1024;
|
||||
publish(); pair();
|
||||
assert(imu_length(0) == ((SWITCH2_BRIDGE_IMU_TARGET_MASK & 1) ? 30 : 0));
|
||||
assert(imu_length(1) == ((SWITCH2_BRIDGE_IMU_TARGET_MASK & 2) ? 30 : 0));
|
||||
no_mouse_or_rails();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
assert(!probe_controller_input_peek_native_report(0, now_ms(), reports[0]));
|
||||
probe_controller_input_init();
|
||||
assert(probe_controller_input_start());
|
||||
probe_controller_input_set_native_stream(0, true);
|
||||
probe_controller_input_set_native_stream(1, true);
|
||||
assert(!peek(0) && !peek(1));
|
||||
mapped_halves_and_calibration();
|
||||
independent_backpressure_and_resets();
|
||||
if (SWITCH2_BRIDGE_IMU_TARGET_MASK == 3) real_motion_admission_and_loss();
|
||||
selected_motion_target_keeps_both_control_halves();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -409,6 +409,63 @@ static void test_bounded_overflow() {
|
|||
expect_empty(); // Overflow discarded all prior history, not merely its head.
|
||||
}
|
||||
|
||||
static void test_continuous_state_coalescing_preserves_events_and_borrowed_head() {
|
||||
now = 1500;
|
||||
disconnect();
|
||||
probe_controller_input_set_native_stream(0, true);
|
||||
const auto first = native_report(0x80, 30, 0, 0);
|
||||
auto newest = first;
|
||||
emit(first);
|
||||
for (unsigned i = 1; i <= 24; ++i) {
|
||||
now += 8;
|
||||
newest[0] = static_cast<uint8_t>(0x80 + i);
|
||||
newest[5] = static_cast<uint8_t>(first[5] + i);
|
||||
newest[PROBE_IMU_LENGTH_OFFSET + 1] = static_cast<uint8_t>(i);
|
||||
emit(newest);
|
||||
}
|
||||
// No 192ms history of analog/IMU-only updates is replayed to a slow consumer.
|
||||
assert(probe_controller_input_commit_native_report(0, expect_report(newest)));
|
||||
expect_empty();
|
||||
|
||||
emit(first);
|
||||
const uint32_t borrowed = expect_report(first);
|
||||
for (unsigned i = 0; i < 6; ++i) {
|
||||
now += 8;
|
||||
++newest[0];
|
||||
++newest[6];
|
||||
emit(newest);
|
||||
}
|
||||
assert(expect_report(first) == borrowed);
|
||||
assert(probe_controller_input_commit_native_report(0, borrowed));
|
||||
assert(probe_controller_input_commit_native_report(0, expect_report(newest)));
|
||||
expect_empty();
|
||||
|
||||
auto pressed = first;
|
||||
pressed[2] ^= 1;
|
||||
auto last_pressed = pressed;
|
||||
++last_pressed[0]; ++last_pressed[5];
|
||||
auto released = last_pressed;
|
||||
released[2] = first[2];
|
||||
auto surface = released;
|
||||
surface[13] ^= 0x10;
|
||||
auto opaque_tail = surface;
|
||||
opaque_tail.back() ^= 0x80;
|
||||
auto other_format = opaque_tail;
|
||||
other_format[PROBE_IMU_LENGTH_OFFSET] = 40;
|
||||
emit(first); emit(pressed); emit(last_pressed); emit(released);
|
||||
emit(surface); emit(opaque_tail); emit(other_format);
|
||||
for (const auto& expected : {first, last_pressed, released, surface, opaque_tail, other_format})
|
||||
assert(probe_controller_input_commit_native_report(0, expect_report(expected)));
|
||||
expect_empty();
|
||||
|
||||
auto mouse = first;
|
||||
mouse[9] = 7;
|
||||
emit(first); emit(mouse); emit(first);
|
||||
for (const auto& expected : {first, mouse, first})
|
||||
assert(probe_controller_input_commit_native_report(0, expect_report(expected)));
|
||||
expect_empty();
|
||||
}
|
||||
|
||||
static void test_expiry_and_wrapping_clock() {
|
||||
now = 2000;
|
||||
const auto first = native_report(0x61, 30);
|
||||
|
|
@ -580,6 +637,7 @@ int main() {
|
|||
test_selected_source_isolation_and_reconnect();
|
||||
test_side_switch_and_sample_ownership();
|
||||
test_bounded_overflow();
|
||||
test_continuous_state_coalescing_preserves_events_and_borrowed_head();
|
||||
test_expiry_and_wrapping_clock();
|
||||
#if SWITCH2_PROBE_COMPOSITE
|
||||
test_simultaneous_sources();
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ struct Rig {
|
|||
++sample.gyro_sequence;
|
||||
sample.gyro_us = now;
|
||||
}
|
||||
motion.update(now, generation, sample);
|
||||
motion.update(now, generation, sample, ProbeNativeMotionBias::kEstimateStationary);
|
||||
}
|
||||
void settle() {
|
||||
for (unsigned i = 0; i < 65; ++i) fresh();
|
||||
|
|
|
|||
|
|
@ -143,7 +143,9 @@ int main() {
|
|||
source.accel_valid = source.gyro_valid = true;
|
||||
source.accel_q13[1] = 8192; // SDL up -> virtual native rail-down +X.
|
||||
memcpy(source.gyro_q10, bias_q10, sizeof(bias_q10));
|
||||
for (unsigned i = 0; i < 450; ++i) assert(poll());
|
||||
assert(poll());
|
||||
assert(controls.active && packet[15] == 0); // Wii alone still estimates residual bias before IMU output.
|
||||
for (unsigned i = 1; i < 450; ++i) assert(poll());
|
||||
assert(controls.active && packet[15] == 30 && packet[19] == 0x0c);
|
||||
assert(signed32(packet+32) == (1 << 28));
|
||||
assert(signed32(packet+36) == 0 && signed32(packet+40) == 0);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ typedef struct btstack_timer_source {
|
|||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t ms);
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
|
||||
bool btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
|
||||
uint32_t btstack_run_loop_get_time_ms(void);
|
||||
void btstack_run_loop_set_timer_context(btstack_timer_source_t* timer, void* context);
|
||||
void btstack_run_loop_set_timer_handler(btstack_timer_source_t* timer, void (*handler)(btstack_timer_source_t*));
|
||||
void* btstack_run_loop_get_timer_context(btstack_timer_source_t* timer);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
#include "bt/uni_bt_service.h"
|
||||
#include "parser/uni_hid_parser_switch.h"
|
||||
#include "parser/uni_hid_parser_switch2.h"
|
||||
#include "parser/uni_hid_parser_wii.h"
|
||||
#include "platform/uni_platform.h"
|
||||
#include "uni_hid_device.h"
|
||||
|
||||
|
|
@ -31,6 +32,14 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) {
|
|||
(void)d;
|
||||
assert(!"Switch 2 teardown reached a Classic Switch fixture");
|
||||
}
|
||||
void uni_hid_parser_wii_setup(uni_hid_device_t* d) {
|
||||
(void)d;
|
||||
assert(!"Wii setup reached a Classic Switch fixture");
|
||||
}
|
||||
void uni_hid_parser_wii_teardown(uni_hid_device_t* d) {
|
||||
(void)d;
|
||||
assert(!"Wii teardown reached a Classic Switch fixture");
|
||||
}
|
||||
|
||||
static unsigned timer_index(btstack_timer_source_t* timer) {
|
||||
for (unsigned i = 0; i < 32; ++i) {
|
||||
|
|
|
|||
54
tests/test_dualsense_backend_native.py
Normal file
54
tests/test_dualsense_backend_native.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_dualsense_backend_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
executable = tmp_path / "dualsense_backend_test"
|
||||
firmware = root / "src" / "firmware"
|
||||
sources = [
|
||||
root / "tests" / "dualsense_backend_test.cpp",
|
||||
firmware / "profile" / "controller_profile.cpp",
|
||||
firmware / "profile" / "controller_profile_transform.cpp",
|
||||
firmware / "profile" / "controller_synthetic_input.cpp",
|
||||
firmware / "profile" / "controller_profile_runtime.cpp",
|
||||
firmware / "profile" / "profile_storage.cpp",
|
||||
firmware / "input" / "wii_swing.cpp",
|
||||
firmware / "input" / "controller_macro_capture.cpp",
|
||||
root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c",
|
||||
]
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-DSWITCH_PICO_HID_INSTANCE_COUNT=4",
|
||||
"-DSWITCH_PICO_USB_OUTPUT_MODES=1",
|
||||
"-DSWITCH_PICO_ENABLE_BLE=1",
|
||||
"-DSWITCH_PICO_ENABLE_CLASSIC=1",
|
||||
"-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1",
|
||||
f"-I{root / 'tests' / 'bluepad32_native_stubs'}",
|
||||
f"-I{firmware}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
*(str(source) for source in sources),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
for scenario in (
|
||||
"stable-logical-slot",
|
||||
"source-isolation",
|
||||
"cue-lifetime",
|
||||
"cue-races",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
51
tests/test_dualsense_parser_native.py
Normal file
51
tests/test_dualsense_parser_native.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
|
||||
from prepare_bluepad32 import prepare_bluepad32
|
||||
|
||||
|
||||
def test_dualsense_parser_native(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"
|
||||
prepared = prepare_bluepad32(
|
||||
root / "external" / "bluepad32",
|
||||
root / "patches" / "bluepad32-sdl3-imu.patch",
|
||||
tmp_path / "bluepad32-src",
|
||||
)
|
||||
component = prepared / "src" / "components" / "bluepad32"
|
||||
executable = tmp_path / "dualsense_parser_native_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=gnu11",
|
||||
"-O1",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-DENABLE_BLE",
|
||||
"-DENABLE_CLASSIC",
|
||||
"-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1",
|
||||
"-DHID_MESSAGE_TYPE_GET_REPORT=4",
|
||||
"-DHID_REPORT_TYPE_FEATURE=3",
|
||||
f"-I{root / 'tests' / 'switch_parser_native_stubs'}",
|
||||
f"-I{root / 'bluepad32_config'}",
|
||||
f"-I{component / 'include'}",
|
||||
str(root / "tests" / "dualsense_parser_native_test.c"),
|
||||
str(component / "parser" / "uni_hid_parser_ds5.c"),
|
||||
str(component / "uni_utils.c"),
|
||||
"-Wl,--gc-sections",
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
57
tests/test_switch2_dualsense_bridge_native.py
Normal file
57
tests/test_switch2_dualsense_bridge_native.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("imu_target", [1, 2, 3], ids=["right", "left", "both"])
|
||||
def test_dualsense_native_bridge_mapping_motion_and_backpressure(
|
||||
tmp_path: Path,
|
||||
imu_target: int,
|
||||
) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
executable = tmp_path / "switch2_dualsense_bridge_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
"-DSWITCH_PICO_SWITCH2_USB_BRIDGE=1",
|
||||
"-DSWITCH2_BRIDGE_DUALSENSE_INPUT=1",
|
||||
"-DSWITCH2_BRIDGE_SOURCE_AUTO=1",
|
||||
"-DSWITCH2_PROBE_HUB=1",
|
||||
f"-DSWITCH2_BRIDGE_IMU_TARGET_MASK={imu_target}",
|
||||
"-DSWITCH_PICO_BLUEPAD32=1",
|
||||
"-DSWITCH_PICO_ENABLE_CLASSIC=1",
|
||||
f"-I{root / 'tests' / 'switch2_mouse_bridge_native_stubs'}",
|
||||
f"-I{root / 'tests' / 'wii_ir_aiming_native_stubs'}",
|
||||
f"-I{root / 'tools' / 'switch2_usb_probe'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "tests" / "switch2_dualsense_bridge_test.cpp"),
|
||||
str(root / "tools" / "switch2_usb_probe" / "controller_input.cpp"),
|
||||
str(root / "tools" / "switch2_usb_probe" / "dualsense_input.cpp"),
|
||||
str(root / "tools" / "switch2_usb_probe" / "native_imu.cpp"),
|
||||
str(root / "src" / "firmware" / "core" / "controller_identity.cpp"),
|
||||
str(root / "src" / "firmware" / "profile" / "controller_profile.cpp"),
|
||||
str(
|
||||
root
|
||||
/ "src"
|
||||
/ "firmware"
|
||||
/ "profile"
|
||||
/ "controller_profile_transform.cpp"
|
||||
),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
Loading…
Add table
Add a link
Reference in a new issue