Add verified Joy-Con 2 USB bridge with native mouse support

Implement the standalone USB protocol probe and Bluetooth-backed right Joy-Con bridge with its own persistent virtual pairing identity. Preserve complete ordered native reports, including opaque motion data, and match the console feature set. Relay built-in vibration cues only after genuine source acknowledgement and expose safe BOOTSEL pairing control. Include native capture diagnostics and focused protocol, packet-lifecycle, and cue regressions. Native mouse operation confirmed on Switch with bridge 0.24; private captures and firmware backups remain outside the commit.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-10 17:46:03 -06:00
commit 3040c9d294
34 changed files with 3777 additions and 15 deletions

View file

@ -0,0 +1,8 @@
#pragma once
#include <mutex>
struct critical_section_t { std::mutex mutex; };
inline unsigned next_striped_spin_lock_num() { return 16; }
inline void critical_section_init_with_lock_num(critical_section_t*, unsigned) {}
inline void critical_section_enter_blocking(critical_section_t* lock) { lock->mutex.lock(); }
inline void critical_section_exit(critical_section_t* lock) { lock->mutex.unlock(); }

View file

@ -0,0 +1,9 @@
#pragma once
#include <stdint.h>
using absolute_time_t = uint64_t;
absolute_time_t get_absolute_time();
uint32_t to_ms_since_boot(absolute_time_t value);
absolute_time_t make_timeout_time_ms(uint32_t timeout);
bool time_reached(absolute_time_t deadline);
void sleep_ms(uint32_t milliseconds);

View file

@ -0,0 +1,363 @@
#include "controller_input.h"
#include "input/bluepad32_input_backend.h"
#include "input/switch2_mouse_capture.h"
#include "platform/pico/bootsel_pairing_button.h"
#include "parser/uni_hid_parser_switch2.h"
#include "pico/stdlib.h"
#include <array>
#include <cassert>
#include <cstdio>
#include <cstring>
static uint64_t now;
static uint32_t stage;
static BootselPairingButtonEvent next_button_event = BootselPairingButtonEvent::kNone;
static unsigned pairing_requests, clear_requests, button_polls;
BootselPairingButtonEvent bootsel_pairing_button_task() {
++button_polls;
const auto event = next_button_event;
next_button_event = BootselPairingButtonEvent::kNone;
return event;
}
void bluepad32_input_backend_open_pairing_window() { ++pairing_requests; }
uint32_t bluepad32_input_backend_clear_pairings() { ++clear_requests; return 1; }
static const uint8_t source_address[] = {0x98,0xe2,0x55,7,0xdf,0};
static const uint8_t other_address[] = {0x98,0xe2,0x55,7,0xdf,1};
void system_clock_initialize() {}
void bluepad32_input_backend_init() { stage = 1; }
void controller_profile_runtime_reset() {}
void bluepad32_input_backend_start() { stage = 2; }
void bluepad32_input_backend_diagnostics(Bluepad32BackendDiagnostics* out) {
*out = {}; out->initialization_stage = stage;
}
absolute_time_t make_timeout_time_ms(uint32_t timeout) { return now + timeout; }
absolute_time_t get_absolute_time() { return now; }
uint32_t to_ms_since_boot(absolute_time_t value) { return static_cast<uint32_t>(value); }
bool time_reached(absolute_time_t deadline) { return now >= deadline; }
void sleep_ms(uint32_t milliseconds) { now += milliseconds; }
using NativeReport = std::array<uint8_t, 63>;
static NativeReport native_report(uint8_t counter, uint8_t motion_length,
int16_t x = 1, int16_t y = -2) {
NativeReport report{};
// Deliberately opaque, nonzero bytes, including NFC and reserved fields.
// Byte 15 declares 30/40 packed motion bytes at 16..55; do not decode them
// or normalize the unused tail of a 30-byte sample.
for (size_t i = 0; i < report.size(); ++i)
report[i] = static_cast<uint8_t>((i * 37 + counter) % 255 + 1);
report[0] = counter;
report[1] = 0x93;
report[2] = 0x12; report[3] = 0xd1;
report[4] = 0xe7;
report[5] = 0x23; report[6] = 0x81; report[7] = 0x45;
report[8] = 0x38;
report[9] = static_cast<uint8_t>(x);
report[10] = static_cast<uint16_t>(x) >> 8;
report[11] = static_cast<uint8_t>(y);
report[12] = static_cast<uint16_t>(y) >> 8;
report[13] = 0x1b;
report[15] = motion_length;
return report;
}
static void emit(const NativeReport& report, const uint8_t* address = source_address,
uint16_t product_id = 0x2066, uint8_t report_id = 8,
uint16_t length = 63) {
assert(length <= report.size());
switch_pico_switch2_mouse_report(product_id, address, report_id, report.data(),
length, static_cast<uint32_t>(now));
}
static void disconnect(const uint8_t* address = source_address,
uint16_t product_id = 0x2066) {
switch_pico_switch2_mouse_report(product_id, address, 0, nullptr, 0,
static_cast<uint32_t>(now));
}
static probe_controller_input poll(uint32_t timestamp = static_cast<uint32_t>(now)) {
probe_controller_input input{};
probe_controller_input_poll(timestamp, &input);
return input;
}
static uint32_t expect_report(const NativeReport& expected,
uint32_t timestamp = static_cast<uint32_t>(now)) {
NativeReport actual;
actual.fill(0xa5);
const uint32_t serial =
probe_controller_input_peek_native_report(timestamp, actual.data());
assert(serial != 0 && actual == expected);
return serial;
}
static void expect_empty() {
NativeReport actual;
actual.fill(0xa5);
const auto untouched = actual;
assert(probe_controller_input_peek_native_report(
static_cast<uint32_t>(now), actual.data()) == 0);
assert(actual == untouched);
}
static void expect_inactive(const probe_controller_input& input) {
assert(!input.active && input.mouse_epoch == 0);
assert(input.buttons[0] == 0 && input.buttons[1] == 0);
assert(input.stick[0] == 0 && input.stick[1] == 0 && input.stick[2] == 0);
assert(input.native_status == 0 && input.mouse_surface == 0);
assert(input.mouse_total_x == 0 && input.mouse_total_y == 0);
}
static void test_startup_pairing_and_stream_gate() {
next_button_event = BootselPairingButtonEvent::kOpenPairing;
assert(!probe_controller_input_pairing_task() && button_polls == 0 && pairing_requests == 0);
probe_controller_input_set_native_stream(true);
expect_empty();
assert(!probe_controller_input_commit_native_report(1));
expect_inactive(poll());
probe_controller_input_clock_init();
probe_controller_input_init();
// Even an enable request after init must not open the pre-flash-ready gate.
probe_controller_input_set_native_stream(true);
const auto report = native_report(0x31, 30, -6, 9);
emit(report);
expect_empty();
assert(probe_controller_input_start());
expect_empty();
assert(probe_controller_input_pairing_task() && pairing_requests == 1);
assert(!probe_controller_input_pairing_task());
next_button_event = BootselPairingButtonEvent::kClearPairings;
assert(!probe_controller_input_pairing_task() && clear_requests == 0 && pairing_requests == 1);
next_button_event = BootselPairingButtonEvent::kOpenPairing;
assert(probe_controller_input_pairing_task() && pairing_requests == 2 && clear_requests == 0);
const auto input = poll();
assert(input.active && input.buttons[0] == 0x12 && input.buttons[1] == 0xd1);
assert(input.stick[0] == 0x23 && input.stick[1] == 0x81 && input.stick[2] == 0x45);
assert(input.native_status == 0x38 && input.mouse_surface == 0x1b);
assert(input.mouse_total_x == -6 && input.mouse_total_y == 9);
probe_controller_input_set_native_stream(true);
expect_empty(); // Enabling never replays the latest input or raw ring.
emit(report);
const uint32_t pending = expect_report(report);
probe_controller_input_set_native_stream(false);
assert(!probe_controller_input_commit_native_report(pending));
emit(report); // Selected input continues updating while native USB is gated.
assert(poll().active);
expect_empty();
probe_controller_input_set_native_stream(true);
expect_empty();
emit(report);
const uint32_t resumed = expect_report(report);
assert(resumed > pending);
assert(probe_controller_input_commit_native_report(resumed));
expect_empty();
}
static void test_opaque_fidelity_order_and_retry() {
now = 100;
const auto first = native_report(0xfe, 30, -6, 9);
const auto repeated = native_report(0xff, 40, -32768, 32767);
const auto last = native_report(0x00, 30, 1, -2);
emit(first);
const uint32_t first_serial = expect_report(first);
// A failed USB submission simply does not commit. New arrivals must not
// overwrite that retry, combine deltas, or collapse identical packets.
++now; emit(repeated);
++now; emit(repeated);
++now; emit(last);
const uint32_t last_serial = poll().serial;
assert(last_serial > first_serial);
assert(!probe_controller_input_commit_native_report(last_serial));
assert(!probe_controller_input_commit_native_report(0));
probe_controller_input_set_native_stream(true);
assert(expect_report(first) == first_serial);
assert(expect_report(first) == first_serial);
assert(probe_controller_input_commit_native_report(first_serial));
assert(!probe_controller_input_commit_native_report(first_serial));
const uint32_t second_serial = expect_report(repeated);
assert(second_serial > first_serial);
assert(probe_controller_input_commit_native_report(second_serial));
const uint32_t third_serial = expect_report(repeated);
assert(third_serial > second_serial);
assert(!probe_controller_input_commit_native_report(second_serial));
assert(expect_report(repeated) == third_serial);
assert(probe_controller_input_commit_native_report(third_serial));
assert(expect_report(last) == last_serial);
assert(probe_controller_input_commit_native_report(last_serial));
expect_empty();
assert(!probe_controller_input_commit_native_report(last_serial));
expect_empty(); // No cached duplicate report when the source has not advanced.
}
static void test_selected_source_isolation_and_reconnect() {
now = 200;
const auto first = native_report(0x41, 30, -17, 19);
const auto second = native_report(0x42, 40, 31, -37);
const auto unrelated = native_report(0x99, 40, 300, 300);
emit(first);
const auto selected = poll();
const uint32_t first_serial = expect_report(first);
assert(first_serial == selected.serial);
for (unsigned i = 0; i < 30; ++i) emit(unrelated, other_address);
emit(unrelated, source_address, 0x2067); // Left Joy-Con at the same address.
emit(unrelated, source_address, 0x2066, 5);
emit(unrelated, source_address, 0x2066, 0xc0, 12);
emit(unrelated, source_address, 0x2066, 8, 62);
uint8_t oversized[64];
memcpy(oversized, unrelated.data(), unrelated.size());
oversized[63] = 0x5a;
switch_pico_switch2_mouse_report(0x2066, source_address, 8, oversized,
sizeof(oversized), static_cast<uint32_t>(now));
disconnect(other_address);
disconnect(source_address, 0x2067);
const auto isolated = poll();
assert(isolated.active && isolated.serial == selected.serial);
assert(isolated.mouse_epoch == selected.mouse_epoch);
assert(isolated.mouse_total_x == selected.mouse_total_x &&
isolated.mouse_total_y == selected.mouse_total_y);
assert(expect_report(first) == first_serial);
++now; emit(second);
const uint32_t second_serial = poll().serial;
assert(probe_controller_input_commit_native_report(first_serial));
assert(expect_report(second) == second_serial);
assert(probe_controller_input_commit_native_report(second_serial));
expect_empty(); // Unrelated ring entries neither evict nor enter the FIFO.
emit(first);
const uint32_t disconnected_serial = expect_report(first);
disconnect();
++now; emit(second); // Disconnect and reconnect both occur between polls.
const auto reconnected = poll();
assert(reconnected.active && reconnected.mouse_epoch != selected.mouse_epoch);
assert(reconnected.mouse_total_x == 31 && reconnected.mouse_total_y == -37);
assert(!probe_controller_input_commit_native_report(disconnected_serial));
assert(expect_report(second) == reconnected.serial);
assert(reconnected.serial > disconnected_serial);
assert(probe_controller_input_commit_native_report(reconnected.serial));
expect_empty();
emit(first);
const uint32_t pending = expect_report(first);
disconnect();
for (unsigned i = 0; i < 30; ++i) emit(unrelated, other_address);
expect_inactive(poll());
expect_empty();
assert(!probe_controller_input_commit_native_report(pending));
++now; emit(second);
const auto resumed = poll();
assert(resumed.active && resumed.mouse_epoch != reconnected.mouse_epoch);
const uint32_t resumed_serial = expect_report(second);
assert(resumed_serial > pending);
assert(probe_controller_input_commit_native_report(resumed_serial));
emit(first);
const uint32_t old_source = expect_report(first);
emit(unrelated, other_address);
switch2_mouse_capture_select_input(other_address);
expect_empty();
assert(!probe_controller_input_commit_native_report(old_source));
probe_controller_input_set_native_stream(true);
expect_empty(); // Selection cannot revive the other peer's raw history.
emit(first);
expect_empty();
emit(unrelated, other_address);
const uint32_t new_source = expect_report(unrelated);
assert(new_source > old_source);
switch2_mouse_capture_select_input(source_address);
probe_controller_input_set_native_stream(true);
expect_empty();
assert(!probe_controller_input_commit_native_report(new_source));
emit(second);
const uint32_t restored = expect_report(second);
assert(restored > new_source);
assert(probe_controller_input_commit_native_report(restored));
}
static void test_bounded_overflow() {
now = 1000;
const auto first = native_report(0x50, 30);
emit(first);
const uint32_t old_serial = expect_report(first);
// The 32-entry contract bounds backlog independently of the diagnostic ring.
for (unsigned i = 1; i < 32; ++i) {
++now;
emit(native_report(static_cast<uint8_t>(0x50 + i), 40));
}
assert(expect_report(first) == old_serial);
const auto newest = native_report(0xbb, 30, -101, 103);
++now; emit(newest);
assert(!probe_controller_input_commit_native_report(old_serial));
const uint32_t newest_serial = expect_report(newest);
assert(newest_serial > old_serial);
const auto following = native_report(0xbc, 40, 107, -109);
++now; emit(following);
assert(expect_report(newest) == newest_serial);
assert(probe_controller_input_commit_native_report(newest_serial));
const uint32_t following_serial = expect_report(following);
assert(following_serial > newest_serial);
assert(probe_controller_input_commit_native_report(following_serial));
expect_empty(); // Overflow discarded all prior history, not merely its head.
}
static void test_expiry_and_wrapping_clock() {
now = 2000;
const auto first = native_report(0x61, 30);
const auto fresh = native_report(0x62, 40);
emit(first);
const uint32_t expired = expect_report(first);
now += 499;
assert(poll().active && expect_report(first) == expired);
++now;
emit(fresh, other_address); // Wrong-source traffic cannot refresh the timeout.
expect_inactive(poll());
expect_empty();
assert(!probe_controller_input_commit_native_report(expired));
++now; emit(first);
const uint32_t stale_head = expect_report(first);
now += 499; emit(fresh);
assert(expect_report(first) == stale_head);
++now;
assert(poll().active); // Latest source is fresh, but its queued head is not.
expect_empty();
assert(!probe_controller_input_commit_native_report(stale_head));
emit(fresh);
const uint32_t resumed = expect_report(fresh);
assert(resumed > stale_head);
assert(probe_controller_input_commit_native_report(resumed));
expect_empty();
now = static_cast<uint64_t>(UINT32_MAX) - 100;
emit(first);
const uint32_t wrapped = expect_report(first);
// The producer can timestamp input one millisecond after the caller samples
// its clock; a signed age must accept this race rather than expire the input.
const uint32_t before_capture = static_cast<uint32_t>(now) - 1;
assert(poll(before_capture).active);
assert(expect_report(first, before_capture) == wrapped);
now += 499; // Cross the uint32 millisecond rollover with a fresh packet.
assert(poll().active && expect_report(first) == wrapped);
++now;
expect_inactive(poll());
expect_empty();
assert(!probe_controller_input_commit_native_report(wrapped));
++now; emit(fresh);
assert(poll().active);
const uint32_t after_wrap = expect_report(fresh);
assert(after_wrap > wrapped);
assert(probe_controller_input_commit_native_report(after_wrap));
expect_empty();
}
int main() {
test_startup_pairing_and_stream_gate();
test_opaque_fidelity_order_and_retry();
test_selected_source_isolation_and_reconnect();
test_bounded_overflow();
test_expiry_and_wrapping_clock();
puts("Native packet fidelity, FIFO retry/order, source barriers, overflow, expiry and pairing passed");
}

View file

@ -0,0 +1,103 @@
#include <assert.h>
#include <stdint.h>
#include <string.h>
#include "protocol.h"
static const uint8_t sample_command[] = {
0x0a, 0x91, 0, 0x02, 0, 4, 0, 0, 3, 0, 0, 0,
};
static unsigned source_calls;
static uint8_t expected_sample = 3;
static bool source_available = true;
static uint64_t source_token = UINT64_C(0x1234567800000001);
static bool play_sample(uint8_t sample_id, uint64_t* token) {
++source_calls;
assert(sample_id == expected_sample);
*token = source_token;
return source_available;
}
static void expect_no_dispatch(probe_protocol_state* state, const uint8_t* command,
size_t length, size_t capacity) {
uint8_t reply[8];
uint64_t token = UINT64_MAX;
const unsigned calls_before = source_calls;
assert(probe_protocol_command(state, command, length, reply, capacity, &token) == 0);
assert(token == 0);
assert(source_calls == calls_before);
}
int main(void) {
probe_protocol_state state;
probe_protocol_reset(&state);
state.play_sample = play_sample;
// Each transport/header field and reserved payload byte is a dispatch gate.
const struct { uint8_t offset; uint8_t value; } invalid[] = {
{0, 0x18}, {1, 0x01}, {2, 0x01}, {3, 0x01}, {4, 1}, {5, 3},
{6, 1}, {7, 1}, {8, 8}, {9, 1}, {10, 1}, {11, 1},
};
for (size_t i = 0; i < sizeof(invalid) / sizeof(invalid[0]); ++i) {
uint8_t command[sizeof(sample_command)];
memcpy(command, sample_command, sizeof(command));
command[invalid[i].offset] = invalid[i].value;
expect_no_dispatch(&state, command, sizeof(command), 8);
}
expect_no_dispatch(&state, sample_command, 7, 8);
uint8_t resized[13] = {0};
memcpy(resized, sample_command, sizeof(sample_command));
resized[5] = 3;
expect_no_dispatch(&state, resized, 11, 8);
resized[5] = 5;
expect_no_dispatch(&state, resized, sizeof(resized), 8);
expect_no_dispatch(&state, sample_command, sizeof(sample_command), 7);
// Synchronous-only callers cannot accidentally acknowledge a sample.
uint8_t reply[8];
const unsigned calls_before = source_calls;
assert(probe_protocol_command(&state, sample_command, sizeof(sample_command),
reply, sizeof(reply), NULL) == 0);
assert(source_calls == calls_before);
state.play_sample = NULL;
expect_no_dispatch(&state, sample_command, sizeof(sample_command), sizeof(reply));
state.play_sample = play_sample;
// A rejected request must not leak even a token written by the source.
uint64_t token = UINT64_MAX;
source_available = false;
assert(probe_protocol_command(&state, sample_command, sizeof(sample_command),
reply, sizeof(reply), &token) == 0);
assert(token == 0);
source_available = true;
source_token = 0;
token = UINT64_MAX;
assert(probe_protocol_command(&state, sample_command, sizeof(sample_command),
reply, sizeof(reply), &token) == 0);
assert(token == 0);
// The observed sample and range boundaries only produce deferred replies.
const uint8_t samples[] = {3, 0, 7};
const uint8_t sample_ack[] = {0x0a, 0x01, 0, 0x02, 0, 0xf8, 0, 0};
source_token = UINT64_C(0x1234567800000001);
for (size_t i = 0; i < sizeof(samples); ++i) {
uint8_t command[sizeof(sample_command)];
memcpy(command, sample_command, sizeof(command));
command[8] = expected_sample = samples[i];
token = 0;
assert(probe_protocol_command(&state, command, sizeof(command), reply,
sizeof(reply), &token) == sizeof(sample_ack));
assert(token == source_token);
assert(memcmp(reply, sample_ack, sizeof(sample_ack)) == 0);
++source_token;
}
// Ordinary report selection retains its immediate, empty USB ACK.
const uint8_t select_report[] = {0x03, 0x91, 0, 0x0a, 0, 4, 0, 0, 5, 0, 0, 0};
const uint8_t select_ack[] = {0x03, 0x01, 0, 0x0a, 0, 0xf8, 0, 0};
assert(probe_protocol_command(&state, select_report, sizeof(select_report),
reply, sizeof(reply), &token) == sizeof(select_ack));
assert(token == 0);
assert(memcmp(reply, select_ack, sizeof(select_ack)) == 0);
return 0;
}

View file

@ -0,0 +1,26 @@
from pathlib import Path
import shutil
import subprocess
def test_native_packet_fidelity_and_lifecycle(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
cxx = shutil.which("c++") or shutil.which("g++")
assert cxx is not None, "a host C++ compiler is required"
probe = root / "tools" / "switch2_usb_probe"
executable = tmp_path / "switch2_mouse_bridge_test"
subprocess.run(
[cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pthread",
"-DSWITCH_PICO_SWITCH2_USB_BRIDGE=1", "-DSWITCH_PICO_BLUEPAD32=1",
"-DSWITCH_PICO_ENABLE_BLE=1", "-DSWITCH_PICO_SWITCH2_MOUSE_CAPTURE=1",
"-DSWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE=1",
"-DSWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES=0x98,0xe2,0x55,0x07,0xdf,0x00",
f"-I{root / 'tests' / 'switch2_mouse_bridge_native_stubs'}",
f"-I{probe}", f"-I{root / 'src' / 'firmware'}", f"-I{root / 'bluepad32_config'}",
str(root / "tests" / "switch2_mouse_bridge_test.cpp"),
str(probe / "controller_input.cpp"),
str(root / "src" / "firmware" / "input" / "switch2_mouse_capture.cpp"),
"-o", str(executable)],
check=True, cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,343 @@
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
from prepare_bluepad32 import prepare_bluepad32
# Reuse the existing real-BTstack-header radio/run-loop fixture, but drive the
# capture-enabled discovery path and link the actual cross-core capture mailbox.
SOURCE = r'''
#define main parser_fixture_main
#include "switch2_parser_native_test.c"
#undef main
void capture_init(void);
bool capture_request(uint8_t id, uint64_t* token);
int capture_result(uint64_t token);
void capture_cancel(void);
void capture_exhaust_records(void);
#define SECONDARY_HANDLE 0x144
static const uint8_t secondary_uuid[16] = {
0xd5,0xa9,0xe0,0x1e,0x2f,0xfc,0x4c,0xca,0xb2,0x0c,0x8b,0x67,0x14,0x2b,0xf4,0x42};
static const uint8_t sample_ack[8] = {0x0a,1,1,2,0x10,0x78,0,0};
static void native_input(struct fixture_peer* peer) {
uint8_t input[63] = {0};
notify(peer, SECONDARY_HANDLE, input, sizeof(input));
}
static struct fixture_peer* sample_ready_on_handle(bool requests, uint32_t start,
hci_con_handle_t handle) {
reset();
now_ms = start;
capture_init();
request_writes = requests;
uint8_t advertisement_data[64];
size_t advertisement_size = advertisement(advertisement_data, UNI_SW2_JOYCON_R_PID, false);
assert(uni_bt_le_switch2_handle_advertisement(advertisement_data, advertisement_size));
struct fixture_peer* peer = &peers[0];
peer->device.conn.handle = handle;
peer->link_alive = true;
uni_hid_parser_switch2_on_le_connected(&peer->device);
uint8_t service[28] = {GATT_EVENT_SERVICE_QUERY_RESULT};
little_endian_store_16(service, 8, SERVICE_START);
little_endian_store_16(service, 10, SERVICE_START + 0x60);
reverse_128(service_uuid, service + 12);
event(peer, service, sizeof(service));
query_done(peer, 0);
const unsigned write = requests ? ATT_PROPERTY_WRITE : ATT_PROPERTY_WRITE_WITHOUT_RESPONSE;
characteristic(peer, INPUT_HANDLE, input_uuid, ATT_PROPERTY_NOTIFY);
characteristic(peer, RESPONSE_HANDLE, response_uuid, ATT_PROPERTY_NOTIFY);
characteristic(peer, COMMAND_HANDLE, command_uuid, write);
characteristic(peer, RUMBLE_HANDLE, rumble_uuids[2], write);
characteristic(peer, SECONDARY_HANDLE, secondary_uuid, ATT_PROPERTY_NOTIFY);
query_done(peer, 0);
descriptor(peer, RESPONSE_HANDLE + 2);
query_done(peer, 0);
descriptor(peer, INPUT_HANDLE + 2);
query_done(peer, 0);
descriptor(peer, SECONDARY_HANDLE + 2);
query_done(peer, 0);
query_done(peer, 0);
for (unsigned i = 0; i < 24 && !ready && !disconnected; ++i) {
if (peer->query == QUERY_CCCD) {
query_done(peer, 0);
} else if (peer->command[0] == 0x10) {
uint8_t version[20] = {0x10,1,1,1,0x10,0x78,0,0};
version[11] = 1;
if (peer->query == QUERY_WRITE) query_done(peer, 0);
notify(peer, RESPONSE_HANDLE, version, sizeof(version));
} else {
acknowledge(peer, false);
}
}
assert(ready == 1 && !disconnected);
// Let the unchanged neutral-rumble budget quiesce before requesting a cue.
for (unsigned i = 0; i < 6; ++i) {
advance(13);
if (peer->query == QUERY_WRITE) query_done(peer, 0);
}
native_input(peer);
return peer;
}
static struct fixture_peer* sample_ready(bool requests, uint32_t start) {
return sample_ready_on_handle(requests, start, 0);
}
static uint64_t request_sample(struct fixture_peer* peer, uint8_t id) {
uint64_t token = 0;
assert(capture_request(id, &token) && token);
assert(capture_result(token) == 0);
unsigned commands = peer->commands;
advance(13);
const uint8_t expected[12] = {0x0a,0x91,1,2,0,4,0,0,id,0,0,0};
assert(peer->commands == commands + 1 && peer->command_length == sizeof(expected));
assert(memcmp(peer->command, expected, sizeof(expected)) == 0);
assert(capture_result(token) == 0);
return token;
}
static void successful_ack(struct fixture_peer* peer, uint64_t token) {
if (peer->query == QUERY_WRITE) query_done(peer, 0);
assert(capture_result(token) == 0);
notify(peer, RESPONSE_HANDLE, sample_ack, sizeof(sample_ack));
assert(capture_result(token) == 1);
assert(capture_result(token) == -1);
}
static void test_ack_order_and_source_matching(void) {
struct fixture_peer* peer = sample_ready(true, 0);
uint64_t token = request_sample(peer, 3), refused = 99;
assert(!capture_request(4, &refused) && refused == 0);
uint8_t malformed[9] = {0x0a,1,1,2,0x10,0x78,0,0,0};
notify(peer, RESPONSE_HANDLE, malformed, sizeof(malformed));
malformed[3] = 1;
notify(peer, RESPONSE_HANDLE, malformed, 8);
assert(capture_result(token) == 0);
// Deliver a genuine-shaped ACK from a different Bluetooth handle.
uint8_t wrong_peer[20] = {GATT_EVENT_NOTIFICATION,18};
little_endian_store_16(wrong_peer, 2, 99);
little_endian_store_16(wrong_peer, 8, RESPONSE_HANDLE);
little_endian_store_16(wrong_peer, 10, sizeof(sample_ack));
memcpy(wrong_peer + 12, sample_ack, sizeof(sample_ack));
peer->callback(HCI_EVENT_PACKET, 0, wrong_peer, sizeof(wrong_peer));
assert(capture_result(token) == 0);
notify(peer, RESPONSE_HANDLE, sample_ack, sizeof(sample_ack));
assert(capture_result(token) == 0); // Application ACK cannot beat ATT completion.
query_done(peer, 0);
assert(capture_result(token) == 1 && capture_result(token) == -1);
for (uint8_t id = 0; id < 8; ++id) {
uint64_t next = request_sample(peer, id);
assert(next > token);
token = next;
successful_ack(peer, token); // ATT success alone is not application success.
}
assert(!capture_request(8, &refused) && !capture_request(3, NULL));
}
static void test_cancel_does_not_transfer_old_ack(void) {
struct fixture_peer* peer = sample_ready(true, 0);
uint64_t old = request_sample(peer, 3), next;
capture_cancel();
assert(capture_result(old) == -1);
assert(capture_request(4, &next) && next > old);
unsigned commands = peer->commands;
advance(13);
assert(peer->commands == commands); // Old untagged ACK must drain first.
notify(peer, RESPONSE_HANDLE, sample_ack, sizeof(sample_ack));
query_done(peer, 0);
assert(capture_result(old) == -1 && capture_result(next) == 0);
advance(13);
assert(peer->commands == commands + 1 && peer->command[8] == 4);
successful_ack(peer, next);
peer = sample_ready(false, 0);
commands = peer->commands;
assert(capture_request(3, &old));
next_write_error = GATT_CLIENT_BUSY;
advance(13);
assert(peer->commands == commands);
capture_cancel();
assert(capture_request(4, &next) && next > old);
advance(13);
assert(peer->commands == commands + 1 && peer->command[8] == 4);
successful_ack(peer, next);
}
static void test_rejection_disconnect_and_late_link_events(void) {
struct fixture_peer* peer = sample_ready(true, 0);
uint64_t old = request_sample(peer, 3);
notify(peer, RESPONSE_HANDLE, sample_ack, sizeof(sample_ack));
query_done(peer, 0x0e);
assert(disconnected == 1 && capture_result(old) == -1);
peer = sample_ready(false, 0);
old = request_sample(peer, 3);
uint8_t rejected[8];
memcpy(rejected, sample_ack, sizeof(rejected));
rejected[5] = 0x81;
notify(peer, RESPONSE_HANDLE, rejected, sizeof(rejected));
assert(disconnected == 1 && capture_result(old) == -1);
peer = sample_ready(false, 0);
old = request_sample(peer, 3);
btstack_packet_handler_t retired_callback = peer->callback;
uni_hid_device_disconnect(&peer->device);
assert(capture_result(old) == -1);
uint64_t next = 0;
assert(!capture_request(3, &next));
peer = sample_ready_on_handle(false, 0, 1);
next = request_sample(peer, 4);
assert(next > old);
// The retired link's callback still carries its old, now absent handle.
uint8_t late[20] = {GATT_EVENT_NOTIFICATION,18};
little_endian_store_16(late, 2, 0);
little_endian_store_16(late, 8, RESPONSE_HANDLE);
little_endian_store_16(late, 10, sizeof(sample_ack));
memcpy(late + 12, sample_ack, sizeof(sample_ack));
retired_callback(HCI_EVENT_PACKET, 0, late, sizeof(late));
assert(capture_result(next) == 0 && capture_result(old) == -1);
successful_ack(peer, next);
old = request_sample(peer, 3);
notify(peer, RESPONSE_HANDLE, sample_ack, sizeof(sample_ack));
uni_hid_device_disconnect(&peer->device);
assert(capture_result(old) == -1); // Disconnect revokes even unconsumed success.
}
static void test_freshness_timeout_and_clock_wrap(void) {
capture_init();
uint64_t token = 0;
assert(!capture_request(3, &token));
uint8_t input[63] = {0};
uint8_t other[6];
memcpy(other, controller_address, sizeof(other));
++other[5];
switch_pico_switch2_mouse_report(UNI_SW2_JOYCON_L_PID, controller_address, 8, input, 63, now_ms);
switch_pico_switch2_mouse_report(UNI_SW2_JOYCON_R_PID, other, 8, input, 63, now_ms);
assert(!capture_request(3, &token));
struct fixture_peer* peer = sample_ready(false, 0);
token = request_sample(peer, 3);
advance(500);
assert(capture_result(token) == -1 && !capture_request(3, &token));
peer = sample_ready(false, UINT32_MAX - 200);
uint32_t started = now_ms;
token = request_sample(peer, 3);
while ((uint32_t)(now_ms - started) < 1900) {
advance(100);
native_input(peer);
}
advance(1999 - (uint32_t)(now_ms - started));
native_input(peer);
assert(capture_result(token) == 0);
advance(1);
assert(capture_result(token) == -1);
notify(peer, RESPONSE_HANDLE, sample_ack, sizeof(sample_ack));
assert(capture_result(token) == -1);
peer = sample_ready(false, 0);
token = request_sample(peer, 3);
for (unsigned i = 0; i < 21 && !disconnected; ++i) {
advance(100);
if (!disconnected) native_input(peer);
}
assert(disconnected == 1 && capture_result(token) == -1);
}
static void test_teardown_survives_capture_exhaustion(void) {
struct fixture_peer* peer = sample_ready(false, 0);
uint64_t token, next;
assert(capture_request(3, &token));
// The parser has not taken this request, so only source teardown can fail
// the mailbox when the serialized capture cannot record another event.
capture_exhaust_records();
uni_hid_device_disconnect(&peer->device);
assert(capture_result(token) == -1 && !capture_request(3, &next));
}
int main(void) {
test_ack_order_and_source_matching();
test_cancel_does_not_transfer_old_ack();
test_rejection_disconnect_and_late_link_events();
test_freshness_timeout_and_clock_wrap();
test_teardown_survives_capture_exhaustion();
reset();
puts("Source sample relay ACK ordering, ownership, rejection and lifetime passed");
return 0;
}
'''
CAPTURE = r'''
#include "input/switch2_mouse_capture.cpp"
extern "C" uint32_t btstack_run_loop_get_time_ms(void);
extern "C" void capture_init(void) {
const uint8_t address[6] = {0xc0,0x22,0x33,0x44,0x55,0x66};
switch2_mouse_capture_init();
switch2_mouse_capture_select_input(address);
}
extern "C" bool capture_request(uint8_t id, uint64_t* token) {
return switch2_mouse_capture_request_sample(id, btstack_run_loop_get_time_ms(), token);
}
extern "C" int capture_result(uint64_t token) {
return switch2_mouse_capture_sample_result(token, btstack_run_loop_get_time_ms());
}
extern "C" void capture_cancel(void) { switch2_mouse_capture_cancel_sample(); }
// Simulate the boot-lifetime counter boundary without billions of reports.
extern "C" void capture_exhaust_records(void) { g_total_records = UINT32_MAX; }
'''
def test_source_sample_requires_its_real_bluetooth_ack(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[1]
cc = shutil.which("cc") or shutil.which("gcc")
cxx = shutil.which("c++") or shutil.which("g++")
assert cc is not None and cxx is not None, "host C and C++ compilers are required"
sdks = [root / "build" / "_deps" / "pico_sdk-src", root / "external" / "pico-sdk"]
if sdk := os.environ.get("PICO_SDK_PATH"):
sdks.insert(0, Path(sdk))
btstack = next((sdk / "lib" / "btstack" / "src" for sdk in sdks
if (sdk / "lib" / "btstack" / "src" / "ble" / "gatt_client.h").is_file()), None)
if btstack is None:
pytest.skip("Pico SDK BTstack headers required; configure firmware or set PICO_SDK_PATH")
prepared = prepare_bluepad32(root / "external" / "bluepad32",
root / "patches" / "bluepad32-sdl3-imu.patch",
tmp_path / "bluepad32-src")
common = ["-O1", "-Wall", "-Wextra", "-ffunction-sections", "-fdata-sections",
"-DSWITCH_PICO_SWITCH2_USB_BRIDGE=1", "-DSWITCH_PICO_SWITCH2_MOUSE_CAPTURE=1",
"-DSWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE=1", f"-I{root / 'bluepad32_config'}"]
cflags = [*common, "-std=gnu11", "-DENABLE_BLE", "-DENABLE_CLASSIC",
f"-I{root / 'tests'}", f"-I{root / 'tests' / 'switch2_parser_native_stubs'}",
f"-I{prepared / 'src' / 'components' / 'bluepad32' / 'include'}", f"-I{btstack}",
f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'encoder' / 'include'}",
f"-I{btstack.parent / '3rd-party' / 'bluedroid' / 'decoder' / 'include'}",
f"-I{btstack.parent / '3rd-party' / 'yxml'}"]
source = tmp_path / "sample_relay.c"
source.write_text(SOURCE)
capture = tmp_path / "capture.cpp"
capture.write_text(CAPTURE)
objects = []
for index, path in enumerate((source, root / "bluepad32_config" / "parser" / "uni_hid_parser_switch2.c",
root / "bluepad32_config" / "parser" / "uni_switch2_haptics.c",
btstack / "btstack_util.c")):
obj = tmp_path / f"source{index}.o"
subprocess.run([cc, *cflags, "-c", str(path), "-o", str(obj)], check=True, cwd=root)
objects.append(str(obj))
executable = tmp_path / "sample_relay"
subprocess.run([cxx, *common, "-std=c++17", "-pthread",
f"-I{root / 'tests' / 'switch2_mouse_bridge_native_stubs'}",
f"-I{root / 'src' / 'firmware'}", str(capture), *objects,
"-Wl,--gc-sections", "-o", str(executable)], check=True, cwd=root)
subprocess.run([str(executable)], check=True, cwd=root)

View file

@ -0,0 +1,51 @@
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
def test_switch2_usb_probe_deferred_sample(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"
sdk_candidates = [
root / "build" / "_deps" / "pico_sdk-src",
root / "external" / "pico-sdk",
]
if sdk_path := os.environ.get("PICO_SDK_PATH"):
sdk_candidates.insert(0, Path(sdk_path))
mbedtls = next(
(sdk / "lib" / "mbedtls" for sdk in sdk_candidates
if (sdk / "lib" / "mbedtls" / "library" / "aes.c").is_file()),
None,
)
if mbedtls is None:
pytest.skip("Pico SDK mbedTLS required; configure firmware or set PICO_SDK_PATH")
probe = root / "tools" / "switch2_usb_probe"
executable = tmp_path / "switch2_usb_probe_protocol"
subprocess.run(
[
compiler,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
f'-DMBEDTLS_CONFIG_FILE="{probe / "mbedtls_config.h"}"',
f"-I{probe}",
f"-I{mbedtls / 'include'}",
str(probe / "protocol.c"),
str(mbedtls / "library" / "aes.c"),
str(mbedtls / "library" / "platform_util.c"),
str(root / "tests" / "switch2_usb_probe_protocol_test.c"),
"-o",
str(executable),
],
check=True,
cwd=root,
)
subprocess.run([str(executable)], check=True, cwd=root)