diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c0cf01..c6d073b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,12 @@ option(SWITCH_PICO_WII_IR_MOUSE "Experiment: Wii IR as a composite USB mouse with Switch controllers" OFF) option(SWITCH_PICO_WII_IR_GYRO "Experiment: selectable Wii IR or MotionPlus gyro aiming" OFF) +option(SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + "Experiment: capture Joy-Con 2 mouse reports through USB management" OFF) +option(SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE + "Capture native Joy-Con mouse reports instead of common controller reports" OFF) +option(SWITCH_PICO_SWITCH2_USB_BRIDGE + "Experiment: forward a native right Joy-Con 2 through the verified USB probe" OFF) if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") set(SWITCH_PICO_NATIVE_DEFAULT ON) set(SWITCH_PICO_CLOCK_DEFAULT 300) @@ -131,6 +137,40 @@ endif() if(SWITCH_PICO_WII_IR_MOUSE) add_compile_definitions(SWITCH_PICO_WII_IR_MOUSE=1) endif() +if(SWITCH_PICO_SWITCH2_MOUSE_CAPTURE) + if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" + OR NOT SWITCH_PICO_ENABLE_BLE) + message(FATAL_ERROR "Joy-Con mouse capture requires BLUEPAD32 with BLE") + endif() + add_compile_definitions( + SWITCH_PICO_SWITCH2_MOUSE_CAPTURE=1 + SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE=$) +endif() +if(SWITCH_PICO_SWITCH2_USB_BRIDGE) + if(NOT SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" + OR NOT SWITCH_PICO_ENABLE_BLE + OR NOT SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + OR NOT SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE) + message(FATAL_ERROR + "Switch 2 USB bridge requires BLUEPAD32 with BLE and native Joy-Con mouse capture") + endif() + if(SWITCH_PICO_WII_IR_MOUSE OR SWITCH_PICO_WII_IR_GYRO) + message(FATAL_ERROR "Switch 2 USB bridge owns USB; Wii IR USB experiments cannot be combined") + endif() + set(SWITCH2_BRIDGE_SOURCE_ADDRESS "" CACHE STRING + "Physical right Joy-Con 2 Bluetooth source address (xx:xx:xx:xx:xx:xx)") + string(TOLOWER "${SWITCH2_BRIDGE_SOURCE_ADDRESS}" bridge_source_address) + string(LENGTH "${bridge_source_address}" bridge_source_address_length) + if(NOT bridge_source_address_length EQUAL 17 + OR NOT bridge_source_address MATCHES "^([0-9a-f][0-9a-f]:)+[0-9a-f][0-9a-f]$" + OR bridge_source_address STREQUAL "00:00:00:00:00:00" + OR bridge_source_address STREQUAL "ff:ff:ff:ff:ff:ff") + message(FATAL_ERROR "Provide SWITCH2_BRIDGE_SOURCE_ADDRESS as a physical six-byte Bluetooth address") + endif() + string(REPLACE ":" ",0x" SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES "${bridge_source_address}") + string(PREPEND SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES "0x") + add_compile_definitions(SWITCH_PICO_SWITCH2_USB_BRIDGE=1) +endif() set(PICO_BOARD pico CACHE STRING "Board type") if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32" AND NOT PICO_BOARD STREQUAL "pico2_w") @@ -170,6 +210,9 @@ endif() include(pico_sdk_import.cmake) project(switch-pico C CXX ASM) +if(SWITCH_PICO_SWITCH2_USB_BRIDGE) + include(${CMAKE_CURRENT_LIST_DIR}/tools/switch2_usb_probe/probe_build.cmake) +endif() # Initialise the Raspberry Pi Pico SDK pico_sdk_init() @@ -281,11 +324,23 @@ endif() set(SWITCH_PICO_SOURCE_DIR ${CMAKE_CURRENT_LIST_DIR}/src/firmware) add_executable(switch-pico - ${SWITCH_PICO_SOURCE_DIR}/main.cpp ${SWITCH_PICO_SOURCE_DIR}/usb/usb_output_driver.cpp ${SWITCH_PICO_SOURCE_DIR}/usb/switch/switch_pro_driver.cpp ${SWITCH_PICO_SOURCE_DIR}/usb/switch/switch_haptics.cpp ) +if(SWITCH_PICO_SWITCH2_USB_BRIDGE) + switch2_usb_probe_configure(switch-pico) + if(NOT SWITCH2_PROBE_USB_INIT OR NOT SWITCH2_PROBE_FACTORY_FILE + OR NOT SWITCH2_PROBE_USER_CALIBRATION_FILE) + message(FATAL_ERROR "Switch 2 USB bridge requires the initialized probe with factory and user calibration captures") + endif() + target_sources(switch-pico PRIVATE + ${SWITCH2_USB_PROBE_DIR}/controller_input.cpp) + target_compile_definitions(switch-pico PRIVATE + SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES=${SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES}) +else() + target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/main.cpp) +endif() if(SWITCH_PICO_WII_IR_MOUSE OR SWITCH_PICO_WII_IR_GYRO) target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/input/wii_ir_pointer.cpp @@ -295,6 +350,10 @@ if(SWITCH_PICO_WII_IR_MOUSE) target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/usb/wii_ir_mouse_usb.cpp) endif() +if(SWITCH_PICO_SWITCH2_MOUSE_CAPTURE) + target_sources(switch-pico PRIVATE + ${SWITCH_PICO_SOURCE_DIR}/input/switch2_mouse_capture.cpp) +endif() if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") target_sources(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR}/adapter/adapter_host_probe.cpp @@ -391,8 +450,10 @@ else() ) endif() -pico_set_program_name(switch-pico "switch-pico") -pico_set_program_version(switch-pico "0.2.0") +if(NOT SWITCH_PICO_SWITCH2_USB_BRIDGE) + pico_set_program_name(switch-pico "switch-pico") + pico_set_program_version(switch-pico "0.2.0") +endif() # Modify the below lines to enable/disable output over UART/USB # UART0 is enabled for debug logging; USB stdio remains off. @@ -400,7 +461,7 @@ pico_enable_stdio_uart(switch-pico 1) pico_enable_stdio_usb(switch-pico 0) # Add the standard library to the build -target_link_libraries(switch-pico +target_link_libraries(switch-pico PRIVATE pico_stdlib tinyusb_device tinyusb_board @@ -408,7 +469,7 @@ target_link_libraries(switch-pico pico_rand ) if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") - target_link_libraries(switch-pico + target_link_libraries(switch-pico PRIVATE bluepad32 pico_cyw43_arch_none pico_btstack_ble @@ -417,15 +478,15 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") pico_multicore pico_flash ) - target_link_libraries(switch-pico hardware_watchdog hardware_adc hardware_vreg hardware_powman) + target_link_libraries(switch-pico PRIVATE hardware_watchdog hardware_adc hardware_vreg hardware_powman) endif() if (SWITCH_PICO_LOG) target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_LOG=1) endif() -# Internal includes are rooted at src/firmware. TinyUSB discovers tusb_config.h -# through the Pico platform directory. +# Internal includes are rooted at src/firmware. Ordinary builds discover +# tusb_config.h here; the bridge selects the probe config explicitly. target_include_directories(switch-pico PRIVATE ${SWITCH_PICO_SOURCE_DIR} ${SWITCH_PICO_SOURCE_DIR}/platform/pico diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.c b/bluepad32_config/parser/uni_hid_parser_switch2.c index 36c3c8a..4ba668a 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.c +++ b/bluepad32_config/parser/uni_hid_parser_switch2.c @@ -10,6 +10,9 @@ #include #include #include +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +#include +#endif #include #include "bt/uni_bt_defines.h" @@ -33,6 +36,12 @@ static const uint8_t sw2_service_uuid[16] = { 0xab, 0x7d, 0xe9, 0xbe, 0x89, 0xfe, 0x49, 0xad, 0x82, 0x8f, 0x11, 0x8f, 0x09, 0xdf, 0x7f, 0xd0}; static const uint8_t sw2_input_uuid[16] = { 0xab, 0x7d, 0xe9, 0xbe, 0x89, 0xfe, 0x49, 0xad, 0x82, 0x8f, 0x11, 0x8f, 0x09, 0xdf, 0x7f, 0xd2}; +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +static const uint8_t sw2_secondary_left_uuid[16] = { + 0xcc, 0x1b, 0xbb, 0xb5, 0x73, 0x54, 0x4d, 0x32, 0xa7, 0x16, 0xa8, 0x1c, 0xb2, 0x41, 0xa3, 0x2a}; +static const uint8_t sw2_secondary_right_uuid[16] = { + 0xd5, 0xa9, 0xe0, 0x1e, 0x2f, 0xfc, 0x4c, 0xca, 0xb2, 0x0c, 0x8b, 0x67, 0x14, 0x2b, 0xf4, 0x42}; +#endif static const uint8_t sw2_command_uuid[16] = { 0x64, 0x9d, 0x4a, 0xc9, 0x8e, 0xb7, 0x4e, 0x6c, 0xaf, 0x44, 0x1e, 0xa5, 0x4f, 0xe5, 0xf0, 0x05}; static const uint8_t sw2_response_uuid[16] = { @@ -58,6 +67,9 @@ typedef enum { SW2_RESPONSE_DESCRIPTOR, SW2_INPUT_DESCRIPTOR, SW2_SUBSCRIBE_RESPONSE, SW2_INFO, SW2_PAIR, SW2_CALIBRATION, SW2_GYRO_CALIBRATION, SW2_SUBSCRIBE_INPUT, SW2_FEATURES, SW2_READY, +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + SW2_SECONDARY_DESCRIPTOR, SW2_SUBSCRIBE_SECONDARY, +#endif } sw2_state_t; typedef enum { SW2_QUERY_NONE, SW2_QUERY_DISCOVERY, SW2_QUERY_CCCD, SW2_QUERY_COMMAND, SW2_QUERY_RUMBLE } sw2_query_t; typedef struct { @@ -89,12 +101,21 @@ typedef struct { uint16_t response_cccd, input_cccd; gatt_client_notification_t response_listener, input_listener; bool response_listening, input_listening; +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + gatt_client_characteristic_t secondary; + uint16_t secondary_cccd; + gatt_client_notification_t secondary_listener; + bool secondary_listening; +#endif btstack_timer_source_t timeout_timer, output_timer; bool timeout_active, output_active; // ATT write-request buffers must outlive the asynchronous call. uint8_t command_data[32], rumble_data[33], cccd_data[2]; uint8_t command_length; bool command_pending, command_sent, command_acked; +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + uint64_t sample_token; +#endif uint8_t step, calibration_slot; bool factory_calibration, calibration_done; uint32_t memory_address; @@ -138,6 +159,33 @@ static bool sw2_product(uint16_t pid) { return pid == UNI_SW2_PRO_PID || pid == UNI_SW2_JOYCON_L_PID || pid == UNI_SW2_JOYCON_R_PID; } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +static bool sw2_mouse_capture_enabled(const sw2_instance_t* ins) { + return ins->device->product_id == UNI_SW2_JOYCON_L_PID || ins->device->product_id == UNI_SW2_JOYCON_R_PID; +} + +static void sw2_capture(sw2_instance_t* ins, uint8_t report_id, const uint8_t* report, uint16_t length) { + if (sw2_mouse_capture_enabled(ins)) + switch_pico_switch2_mouse_report(ins->device->product_id, ins->address, report_id, report, + length > 64 ? 64 : length, btstack_run_loop_get_time_ms()); +} + +static void sw2_log_capture(const char* label, const sw2_instance_t* ins, + const uint8_t* data, unsigned length) { + if (length > 64 || !sw2_mouse_capture_enabled(ins)) return; + static const char hex[] = "0123456789abcdef"; + char text[129]; + for (unsigned i = 0; i < length; ++i) { + text[2 * i] = hex[data[i] >> 4]; + text[2 * i + 1] = hex[data[i] & 15]; + } + text[length * 2] = 0; + printf("[SW2_%s] pid=%04x address=%02x:%02x:%02x:%02x:%02x:%02x data=%s\n", + label, ins->device->product_id, ins->address[0], ins->address[1], + ins->address[2], ins->address[3], ins->address[4], ins->address[5], text); +} +#endif + bool uni_hid_parser_switch2_is_ble_device(const uni_hid_device_t* d) { return d && d->conn.protocol == UNI_BT_CONN_PROTOCOL_BLE && d->vendor_id == UNI_SW2_NINTENDO_VID && sw2_product(d->product_id); @@ -168,6 +216,13 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { sw2_instance_t* ins = sw2_instance(d); if (!ins) return; +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + if (ins->sample_token) { + switch_pico_switch2_sample_result(d->product_id, ins->address, ins->sample_token, + -1, btstack_run_loop_get_time_ms()); + ins->sample_token = 0; + } +#endif sw2_disarm_timeout(ins); if (ins->output_active) btstack_run_loop_remove_timer(&ins->output_timer); @@ -177,6 +232,12 @@ void uni_hid_parser_switch2_teardown(uni_hid_device_t* d) { if (ins->input_listening) gatt_client_stop_listening_for_characteristic_value_updates(&ins->input_listener); ins->response_listening = ins->input_listening = false; +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (ins->secondary_listening) + gatt_client_stop_listening_for_characteristic_value_updates(&ins->secondary_listener); + ins->secondary_listening = false; + sw2_capture(ins, 0, NULL, 0); +#endif sw2_discard_host(ins); ++ins->haptics_epoch; ins->command_pending = ins->rumble_scheduled = false; @@ -254,6 +315,29 @@ static void sw2_subscribe(sw2_instance_t* ins, bool input) { sw2_gatt_handler, ins->handle, input ? ins->input_cccd : ins->response_cccd, 2, ins->cccd_data)); } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +static void sw2_discover_secondary_descriptors(sw2_instance_t* ins) { + ins->state = SW2_SECONDARY_DESCRIPTOR; + ins->query = SW2_QUERY_DISCOVERY; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_discover_characteristic_descriptors( + sw2_gatt_handler, ins->handle, &ins->secondary)); +} + +static void sw2_subscribe_secondary(sw2_instance_t* ins) { + ins->state = SW2_SUBSCRIBE_SECONDARY; + ins->query = SW2_QUERY_CCCD; + ins->cccd_data[0] = 1; + ins->cccd_data[1] = 0; + gatt_client_listen_for_characteristic_value_updates(&ins->secondary_listener, sw2_gatt_handler, + ins->handle, &ins->secondary); + ins->secondary_listening = true; + sw2_arm_timeout(ins); + sw2_check_query(ins, gatt_client_write_characteristic_descriptor_using_descriptor_handle( + sw2_gatt_handler, ins->handle, ins->secondary_cccd, 2, ins->cccd_data)); +} +#endif + static bool sw2_transient_write_error(uint8_t status) { return status == BTSTACK_ACL_BUFFERS_FULL || status == GATT_CLIENT_BUSY || status == GATT_CLIENT_IN_WRONG_STATE; @@ -262,6 +346,18 @@ static bool sw2_transient_write_error(uint8_t status) { static void sw2_try_command(sw2_instance_t* ins) { if (!ins->command_pending || ins->command_sent || ins->query != SW2_QUERY_NONE) return; +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + if (ins->sample_token && + !switch_pico_switch2_sample_result(ins->device->product_id, ins->address, + ins->sample_token, 0, btstack_run_loop_get_time_ms())) { + // Nothing was sent yet. Once sent, keep the old transaction serialized + // until its ACK/timeout: the empty response has no request nonce. + ins->sample_token = 0; + ins->command_pending = false; + sw2_disarm_timeout(ins); + return; + } +#endif // Re-check immediately before every application-pairing write, including a // deferred write after ACL backpressure. A closed window cannot write MACs. if (ins->command_data[0] == 0x15 && !switch_pico_switch2_pairing_allowed()) { @@ -342,6 +438,12 @@ static bool sw2_calibration(sw2_stick_t* out, const uint8_t* data) { static void sw2_continue(sw2_instance_t* ins) { switch (ins->state) { case SW2_INFO: +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (sw2_mouse_capture_enabled(ins) && ins->step == 1) { + sw2_send_command(ins, 0x10, 0x01, NULL, 0); + break; + } +#endif sw2_read_memory(ins, 0x13000, 0x40); break; case SW2_PAIR: { @@ -372,7 +474,15 @@ static void sw2_continue(sw2_instance_t* ins) { sw2_read_memory(ins, 0x13044, 12); break; case SW2_FEATURES: { +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + // Match the console's complete native feature set, including the + // status associated with bit 5, rather than a mouse-only capture. + const uint8_t features[4] = {sw2_mouse_capture_enabled(ins) ? 0x37 : 0x04, 0, 0, 0}; +#elif SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + const uint8_t features[4] = {sw2_mouse_capture_enabled(ins) ? 0x14 : 0x04, 0, 0, 0}; +#else const uint8_t features[4] = {0x04, 0, 0, 0}; +#endif sw2_send_command(ins, 0x0c, ins->step ? 0x04 : 0x02, features, sizeof(features)); break; } @@ -393,8 +503,22 @@ static void sw2_complete_command(sw2_instance_t* ins) { return; ins->command_pending = false; sw2_disarm_timeout(ins); +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + if (ins->sample_token) { + switch_pico_switch2_sample_result(ins->device->product_id, ins->address, + ins->sample_token, 1, btstack_run_loop_get_time_ms()); + ins->sample_token = 0; + } +#endif switch (ins->state) { case SW2_INFO: +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (sw2_mouse_capture_enabled(ins) && ins->step == 0) { + ins->step = 1; + break; + } + ins->step = 0; +#endif ins->state = ins->needs_pair ? SW2_PAIR : SW2_CALIBRATION; break; case SW2_PAIR: @@ -438,6 +562,19 @@ static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t leng sw2_fail(ins, "negative application ACK", data[5]); return; } +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + // 0A/02 has an empty application ACK, not a returned sample ID or status + // payload. Never interpret a truncated/extended response as its success. + if (ins->sample_token && (length != 8 || data[4] != 0x10 || data[6] || data[7])) + return; +#endif +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (ins->state == SW2_INFO && ins->step == 1) { + const uint8_t expected_type = ins->device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : 0; + if (data[0] != 0x10 || length < 20 || data[11] != expected_type) return; + sw2_log_capture("VERSION", ins, data + 8, 12); + } +#endif if (data[0] == 0x02) { if (length < 16 || data[8] != ins->memory_length || little_endian_read_32(data, 12) != ins->memory_address || length < 16 + ins->memory_length) @@ -449,6 +586,12 @@ static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t leng sw2_fail(ins, "controller identity mismatch", 0); return; } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + // This read is already part of normal setup. Capture its validated + // factory identity for USB enumeration research, without new reads + // or writes to the controller. Keep donor data out of source files. + sw2_log_capture("IDENTITY", ins, value, 64); +#endif } else if (ins->state == SW2_CALIBRATION) { // Both solo Joy-Cons store their one stick in calibration slot 1. unsigned stick = ins->device->product_id == UNI_SW2_JOYCON_R_PID ? 1 : ins->calibration_slot; @@ -483,6 +626,10 @@ static void sw2_response(sw2_instance_t* ins, const uint8_t* data, uint16_t leng return; } } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (ins->state == SW2_FEATURES) + sw2_capture(ins, 0xc0, data, length); +#endif ins->command_acked = true; sw2_complete_command(ins); } @@ -527,6 +674,12 @@ static void sw2_query_complete(sw2_instance_t* ins, uint8_t status) { sw2_fail(ins, "missing required characteristic", 0); return; } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (sw2_mouse_capture_enabled(ins) && !ins->secondary.value_handle) { + sw2_fail(ins, "missing secondary input characteristic", 0); + return; + } +#endif sw2_discover_descriptors(ins, false); break; case SW2_RESPONSE_DESCRIPTOR: @@ -541,13 +694,39 @@ static void sw2_query_complete(sw2_instance_t* ins, uint8_t status) { sw2_fail(ins, "missing input CCCD", 0); return; } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (sw2_mouse_capture_enabled(ins)) { + sw2_discover_secondary_descriptors(ins); + return; + } +#endif sw2_subscribe(ins, false); break; +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + case SW2_SECONDARY_DESCRIPTOR: + if (!ins->secondary_cccd) { + sw2_fail(ins, "missing secondary input CCCD", 0); + return; + } + sw2_subscribe(ins, false); + break; +#endif case SW2_SUBSCRIBE_RESPONSE: ins->state = SW2_INFO; sw2_continue(ins); break; case SW2_SUBSCRIBE_INPUT: +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + case SW2_SUBSCRIBE_SECONDARY: + // This Joy-Con emits the last subscribed input format, not both. + // Native capture is explicit: its packed input is not yet decoded + // by the normal gamepad parser. Common capture preserves controls. + if (SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE && + ins->state == SW2_SUBSCRIBE_INPUT && sw2_mouse_capture_enabled(ins)) { + sw2_subscribe_secondary(ins); + return; + } +#endif ins->state = SW2_FEATURES; ins->step = 0; sw2_continue(ins); @@ -576,6 +755,12 @@ static void sw2_characteristic(sw2_instance_t* ins, const gatt_client_characteri target = &ins->rumble; properties = ATT_PROPERTY_WRITE | ATT_PROPERTY_WRITE_WITHOUT_RESPONSE; } +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (!target && sw2_mouse_capture_enabled(ins) && + memcmp(characteristic->uuid128, ins->device->product_id == UNI_SW2_JOYCON_L_PID ? + sw2_secondary_left_uuid : sw2_secondary_right_uuid, 16) == 0) + target = &ins->secondary; +#endif if (!target) return; if (target->value_handle || !(characteristic->properties & properties) || @@ -632,8 +817,14 @@ static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* pac break; } case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (ins->state != SW2_RESPONSE_DESCRIPTOR && ins->state != SW2_INPUT_DESCRIPTOR && + ins->state != SW2_SECONDARY_DESCRIPTOR) + return; +#else if (ins->state != SW2_RESPONSE_DESCRIPTOR && ins->state != SW2_INPUT_DESCRIPTOR) return; +#endif gatt_client_characteristic_descriptor_t descriptor; gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); if (descriptor.uuid16 != SW2_CCCD_UUID) @@ -641,6 +832,12 @@ static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* pac bool input = ins->state == SW2_INPUT_DESCRIPTOR; gatt_client_characteristic_t* characteristic = input ? &ins->input : &ins->response; uint16_t* handle = input ? &ins->input_cccd : &ins->response_cccd; +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + if (ins->state == SW2_SECONDARY_DESCRIPTOR) { + characteristic = &ins->secondary; + handle = &ins->secondary_cccd; + } +#endif if (*handle || descriptor.handle <= characteristic->value_handle || descriptor.handle > characteristic->end_handle) { sw2_fail(ins, "invalid or ambiguous CCCD", descriptor.handle); return; @@ -657,6 +854,14 @@ static void sw2_gatt_handler(uint8_t packet_type, uint16_t channel, uint8_t* pac return; uint16_t handle = gatt_event_notification_get_value_handle(packet); const uint8_t* value = gatt_event_notification_get_value(packet); +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + // BLE omits report IDs. Keep both formats raw and never route the + // secondary packed motion data through the common gamepad parser. + if (length && ins->input_listening && handle == ins->input.value_handle) + sw2_capture(ins, 0x05, value, length); + else if (length && ins->secondary_listening && handle == ins->secondary.value_handle) + sw2_capture(ins, ins->device->product_id == UNI_SW2_JOYCON_L_PID ? 0x07 : 0x08, value, length); +#endif if (ins->response_listening && handle == ins->response.value_handle) sw2_response(ins, value, length); else if (ins->state == SW2_READY && ins->input_listening && handle == ins->input.value_handle && length == SW2_REPORT_SIZE) { @@ -1271,6 +1476,18 @@ static void sw2_output_tick(btstack_timer_source_t* timer) { if (ins->state != SW2_READY) return; // A blocked setup command rescheduled itself, or awaits its ACK. uint32_t now = btstack_run_loop_get_time_ms(); +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + if (!ins->command_pending && ins->query == SW2_QUERY_NONE) { + uint8_t sample_id; + if (switch_pico_switch2_sample_take(ins->device->product_id, ins->address, now, + &sample_id, &ins->sample_token)) { + const uint8_t data[4] = {sample_id, 0, 0, 0}; + sw2_send_command(ins, 0x0a, 0x02, data, sizeof(data)); + if (!ins->device) + return; + } + } +#endif sw2_update_haptics(ins, now); bool sent = sw2_send_rumble(ins, now); if (!ins->device) diff --git a/bluepad32_config/parser/uni_hid_parser_switch2.h b/bluepad32_config/parser/uni_hid_parser_switch2.h index e7632ca..4b8e0b7 100644 --- a/bluepad32_config/parser/uni_hid_parser_switch2.h +++ b/bluepad32_config/parser/uni_hid_parser_switch2.h @@ -29,6 +29,24 @@ enum { // Supplied by the platform's existing bounded pairing-window policy. bool switch_pico_switch2_pairing_allowed(void); +#if SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +// BTstack-core callback: canonical Bluepad32 address, unmodified BLE payload +// (without a report-ID prefix), at most 64 bytes. 0xc0 is a validated feature +// reply; report_id=0 with length=0 marks teardown and permits a NULL report. +void switch_pico_switch2_mouse_report(uint16_t product_id, const uint8_t address[6], uint8_t report_id, + const uint8_t* report, uint16_t length, uint32_t received_ms); +#endif + +#if SWITCH_PICO_SWITCH2_USB_BRIDGE +// BTstack-core mailbox hooks. Take only when READY with no command/query in +// flight. Result 0 checks ownership before a deferred write, 1 records verified +// command completion, and -1 fails it. False means canceled/stale/wrong source. +bool switch_pico_switch2_sample_take(uint16_t product_id, const uint8_t address[6], + uint32_t now_ms, uint8_t* sample_id, uint64_t* token); +bool switch_pico_switch2_sample_result(uint16_t product_id, const uint8_t address[6], + uint64_t token, int result, uint32_t now_ms); +#endif + bool uni_bt_le_switch2_handle_advertisement(const uint8_t* packet, uint16_t size); bool uni_hid_parser_switch2_is_ble_device(const struct uni_hid_device_s* d); void uni_hid_parser_switch2_on_le_connected(struct uni_hid_device_s* d); diff --git a/src/firmware/input/switch2_mouse_capture.cpp b/src/firmware/input/switch2_mouse_capture.cpp new file mode 100644 index 0000000..61a2333 --- /dev/null +++ b/src/firmware/input/switch2_mouse_capture.cpp @@ -0,0 +1,352 @@ +#include "input/switch2_mouse_capture.h" + +#ifdef SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +#include + +#include "parser/uni_hid_parser_switch2.h" +#include "pico/critical_section.h" + +namespace { +critical_section_t g_lock; +bool g_initialized; +uint8_t g_rows[SWITCH2_MOUSE_CAPTURE_CAPACITY][SWITCH2_MOUSE_CAPTURE_ROW_SIZE]; +uint8_t g_next; +uint8_t g_count; +uint32_t g_total_records; +bool g_input_selected; +uint8_t g_input_address[6]; +Switch2MouseCaptureInput g_latest_input; +#if SWITCH_PICO_SWITCH2_USB_BRIDGE +constexpr uint32_t kInputDeadlineMs = 500; +constexpr uint32_t kSampleDeadlineMs = 2000; +constexpr uint8_t kNativeReportCapacity = 32; +struct NativeReport { + uint8_t report[SWITCH2_MOUSE_CAPTURE_NATIVE_INPUT_SIZE]; + uint32_t serial; + uint32_t received_ms; +}; +NativeReport g_native_reports[kNativeReportCapacity]; +uint8_t g_native_head; +uint8_t g_native_count; +bool g_native_stream; +uint64_t g_sample_serial; +bool g_source_active; +struct { + uint64_t token; + uint32_t started_ms; + uint32_t mouse_epoch; + uint8_t sample_id; + bool taken; + bool acked; +} g_sample; + +void clear_native_reports() { + g_native_head = 0; + g_native_count = 0; +} + +bool source_fresh(uint32_t now_ms) { + return g_input_selected && g_source_active && g_latest_input.active && + static_cast(now_ms - g_latest_input.received_ms) < + static_cast(kInputDeadlineMs); +} + +bool sample_current(uint32_t now_ms) { + if (g_sample.token && + (!source_fresh(now_ms) || g_sample.mouse_epoch != g_latest_input.mouse_epoch || + static_cast(now_ms - g_sample.started_ms) >= + static_cast(kSampleDeadlineMs))) { + g_sample = {}; + } + return g_sample.token != 0; +} +#endif + +void write_u16(uint8_t* output, uint16_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); +} + +void write_u32(uint8_t* output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +int32_t read_i16(const uint8_t* input) { + const int32_t value = input[0] | (static_cast(input[1]) << 8); + return value >= 0x8000 ? value - 0x10000 : value; +} +} // namespace + +void switch2_mouse_capture_init() { + if (g_initialized) return; + // The application and TinyUSB already consume all eight exclusive + // spinlocks. Capture never nests another striped spinlock, so use the + // SDK's shared stripe pool rather than exhausting startup allocation. + critical_section_init_with_lock_num(&g_lock, next_striped_spin_lock_num()); + g_initialized = true; +} + +extern "C" void switch_pico_switch2_mouse_report( + uint16_t product_id, const uint8_t address[6], uint8_t report_id, + const uint8_t* report, uint16_t length, uint32_t received_ms) { + if (!g_initialized || address == nullptr || + (product_id != UNI_SW2_JOYCON_L_PID && + product_id != UNI_SW2_JOYCON_R_PID) || + length > SWITCH2_MOUSE_CAPTURE_REPORT_SIZE || + (length != 0 && report == nullptr)) { + return; + } + if (report_id == 0) { + if (length != 0) return; + } else if (length == 0 || + (report_id != 0x05 && report_id != 0x07 && + report_id != 0x08 && report_id != 0xc0)) { + return; + } + + critical_section_enter_blocking(&g_lock); +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + // Teardown must invalidate source ownership even after capture serials are + // exhausted; this is independent of whether a ring event can be recorded. + if (report_id == 0 && g_input_selected && product_id == UNI_SW2_JOYCON_R_PID && + memcmp(address, g_input_address, sizeof(g_input_address)) == 0) { + g_source_active = false; + clear_native_reports(); + g_sample = {}; + } +#endif + // Never reuse a serial within one boot, even after UINT32_MAX records. + if (g_total_records == UINT32_MAX) { +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + clear_native_reports(); +#endif + critical_section_exit(&g_lock); + return; + } + uint8_t* row = g_rows[g_next]; + write_u32(row, ++g_total_records); +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + // Exhaustion is terminal for the relay, including the last recorded event. + if (g_total_records == UINT32_MAX) clear_native_reports(); +#endif + write_u32(row + 4, received_ms); + write_u16(row + 8, product_id); + row[10] = report_id; + row[11] = static_cast(length); + memcpy(row + 12, address, 6); + row[18] = row[19] = 0; + if (length != 0) memcpy(row + 20, report, length); + memset(row + 20 + length, 0, SWITCH2_MOUSE_CAPTURE_REPORT_SIZE - length); + g_next = static_cast((g_next + 1) % SWITCH2_MOUSE_CAPTURE_CAPACITY); + if (g_input_selected && product_id == UNI_SW2_JOYCON_R_PID && + memcmp(address, g_input_address, sizeof(g_input_address)) == 0 && + (report_id == 0 || + (report_id == 0x08 && length == SWITCH2_MOUSE_CAPTURE_NATIVE_INPUT_SIZE))) { + if (report_id == 0x08) { + if (!g_latest_input.active) { + g_latest_input.mouse_epoch = g_total_records; + g_latest_input.mouse_total_x = 0; + g_latest_input.mouse_total_y = 0; + } + g_latest_input.active = true; +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + g_source_active = true; + if (g_native_stream && g_total_records != UINT32_MAX) { + if (g_native_count == kNativeReportCapacity) clear_native_reports(); + NativeReport& packet = + g_native_reports[(g_native_head + g_native_count) % kNativeReportCapacity]; + memcpy(packet.report, report, sizeof(packet.report)); + packet.serial = g_total_records; + packet.received_ms = received_ms; + ++g_native_count; + } +#endif + memcpy(g_latest_input.buttons, report + 2, sizeof(g_latest_input.buttons)); + memcpy(g_latest_input.stick, report + 5, sizeof(g_latest_input.stick)); + g_latest_input.native_status = report[8]; + // At most UINT32_MAX signed16 additions per boot: magnitude < 2^47. + // Every packet contributes, even when its delta matches the last. + g_latest_input.mouse_total_x += read_i16(report + 9); + g_latest_input.mouse_total_y += read_i16(report + 11); + g_latest_input.mouse_surface = report[13]; + } else { + g_latest_input = {}; + } + g_latest_input.serial = g_total_records; + g_latest_input.received_ms = received_ms; + } + if (g_count < SWITCH2_MOUSE_CAPTURE_CAPACITY) ++g_count; + critical_section_exit(&g_lock); +} + +size_t switch2_mouse_capture_snapshot(uint8_t* output, size_t capacity, + uint32_t* total_records) { + if (!g_initialized || output == nullptr || total_records == nullptr) return 0; + + critical_section_enter_blocking(&g_lock); + const size_t required = SWITCH2_MOUSE_CAPTURE_HEADER_SIZE + + g_count * SWITCH2_MOUSE_CAPTURE_ROW_SIZE; + if (capacity < required) { + critical_section_exit(&g_lock); + return 0; + } + *total_records = g_total_records; + write_u32(output, g_total_records); + output[4] = g_count; + memset(output + 5, 0, SWITCH2_MOUSE_CAPTURE_HEADER_SIZE - 5); + const size_t oldest = g_count == SWITCH2_MOUSE_CAPTURE_CAPACITY ? g_next : 0; + const size_t first_count = + g_count < SWITCH2_MOUSE_CAPTURE_CAPACITY - oldest + ? g_count : SWITCH2_MOUSE_CAPTURE_CAPACITY - oldest; + memcpy(output + SWITCH2_MOUSE_CAPTURE_HEADER_SIZE, g_rows[oldest], + first_count * SWITCH2_MOUSE_CAPTURE_ROW_SIZE); + if (first_count != g_count) { + memcpy(output + SWITCH2_MOUSE_CAPTURE_HEADER_SIZE + + first_count * SWITCH2_MOUSE_CAPTURE_ROW_SIZE, + g_rows[0], (g_count - first_count) * SWITCH2_MOUSE_CAPTURE_ROW_SIZE); + } + critical_section_exit(&g_lock); + return required; +} + +void switch2_mouse_capture_select_input(const uint8_t address[6]) { + if (!g_initialized || address == nullptr) return; + critical_section_enter_blocking(&g_lock); +#if SWITCH_PICO_SWITCH2_USB_BRIDGE + g_source_active = false; + g_native_stream = false; + clear_native_reports(); + g_sample = {}; +#endif + memcpy(g_input_address, address, sizeof(g_input_address)); + g_latest_input = {}; + g_input_selected = true; + critical_section_exit(&g_lock); +} + +bool switch2_mouse_capture_latest_input(uint32_t after_serial, + Switch2MouseCaptureInput* output) { + if (!g_initialized || output == nullptr) return false; + critical_section_enter_blocking(&g_lock); + const bool fresh = g_latest_input.serial > after_serial; + if (fresh) *output = g_latest_input; + critical_section_exit(&g_lock); + return fresh; +} + +#if SWITCH_PICO_SWITCH2_USB_BRIDGE +void switch2_mouse_capture_set_native_stream(bool enabled) { + if (!g_initialized) return; + critical_section_enter_blocking(&g_lock); + g_native_stream = enabled; + if (!enabled) clear_native_reports(); + critical_section_exit(&g_lock); +} + +uint32_t switch2_mouse_capture_peek_native_report( + uint32_t now_ms, uint8_t report[SWITCH2_MOUSE_CAPTURE_NATIVE_INPUT_SIZE]) { + if (!g_initialized || report == nullptr) return 0; + critical_section_enter_blocking(&g_lock); + uint32_t serial = 0; + if (!g_native_stream || !source_fresh(now_ms) || + (g_native_count != 0 && + static_cast(now_ms - g_native_reports[g_native_head].received_ms) >= + static_cast(kInputDeadlineMs))) { + clear_native_reports(); + } else if (g_native_count != 0) { + const NativeReport& packet = g_native_reports[g_native_head]; + memcpy(report, packet.report, sizeof(packet.report)); + serial = packet.serial; + } + critical_section_exit(&g_lock); + return serial; +} + +bool switch2_mouse_capture_commit_native_report(uint32_t serial) { + if (!g_initialized || serial == 0) return false; + critical_section_enter_blocking(&g_lock); + const bool accepted = g_native_stream && g_native_count != 0 && + g_native_reports[g_native_head].serial == serial; + if (accepted) { + g_native_head = static_cast((g_native_head + 1) % kNativeReportCapacity); + --g_native_count; + } + critical_section_exit(&g_lock); + return accepted; +} + +bool switch2_mouse_capture_request_sample(uint8_t sample_id, uint32_t now_ms, + uint64_t* token) { + if (token != nullptr) *token = 0; + if (!g_initialized || token == nullptr || sample_id > 7) return false; + critical_section_enter_blocking(&g_lock); + const bool accepted = !sample_current(now_ms) && source_fresh(now_ms) && + g_sample_serial != UINT64_MAX; + if (accepted) { + g_sample.token = ++g_sample_serial; + g_sample.started_ms = now_ms; + g_sample.mouse_epoch = g_latest_input.mouse_epoch; + g_sample.sample_id = sample_id; + *token = g_sample.token; + } + critical_section_exit(&g_lock); + return accepted; +} + +int switch2_mouse_capture_sample_result(uint64_t token, uint32_t now_ms) { + if (!g_initialized || token == 0) return -1; + critical_section_enter_blocking(&g_lock); + int result = -1; + if (sample_current(now_ms) && g_sample.token == token) { + result = g_sample.acked ? 1 : 0; + if (result == 1) g_sample = {}; + } + critical_section_exit(&g_lock); + return result; +} + +void switch2_mouse_capture_cancel_sample() { + if (!g_initialized) return; + critical_section_enter_blocking(&g_lock); + g_sample = {}; + critical_section_exit(&g_lock); +} + +extern "C" bool switch_pico_switch2_sample_take( + uint16_t product_id, const uint8_t address[6], uint32_t now_ms, + uint8_t* sample_id, uint64_t* token) { + if (!g_initialized || address == nullptr || sample_id == nullptr || token == nullptr) + return false; + critical_section_enter_blocking(&g_lock); + const bool accepted = sample_current(now_ms) && !g_sample.taken && + product_id == UNI_SW2_JOYCON_R_PID && + memcmp(address, g_input_address, sizeof(g_input_address)) == 0; + if (accepted) { + g_sample.taken = true; + *sample_id = g_sample.sample_id; + *token = g_sample.token; + } + critical_section_exit(&g_lock); + return accepted; +} + +extern "C" bool switch_pico_switch2_sample_result( + uint16_t product_id, const uint8_t address[6], uint64_t token, + int result, uint32_t now_ms) { + if (!g_initialized || address == nullptr || token == 0) return false; + critical_section_enter_blocking(&g_lock); + const bool accepted = sample_current(now_ms) && g_sample.taken && + g_sample.token == token && product_id == UNI_SW2_JOYCON_R_PID && + memcmp(address, g_input_address, sizeof(g_input_address)) == 0; + if (accepted) { + if (result > 0) g_sample.acked = true; + else if (result < 0) g_sample = {}; + } + critical_section_exit(&g_lock); + return accepted; +} +#endif +#endif diff --git a/src/firmware/input/switch2_mouse_capture.h b/src/firmware/input/switch2_mouse_capture.h new file mode 100644 index 0000000..6928537 --- /dev/null +++ b/src/firmware/input/switch2_mouse_capture.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include + +constexpr size_t SWITCH2_MOUSE_CAPTURE_CAPACITY = 9; +constexpr size_t SWITCH2_MOUSE_CAPTURE_REPORT_SIZE = 64; +constexpr size_t SWITCH2_MOUSE_CAPTURE_HEADER_SIZE = 16; +constexpr size_t SWITCH2_MOUSE_CAPTURE_ROW_SIZE = 20 + SWITCH2_MOUSE_CAPTURE_REPORT_SIZE; +constexpr size_t SWITCH2_MOUSE_CAPTURE_MAXIMUM_PAYLOAD_SIZE = + SWITCH2_MOUSE_CAPTURE_HEADER_SIZE + + SWITCH2_MOUSE_CAPTURE_CAPACITY * SWITCH2_MOUSE_CAPTURE_ROW_SIZE; + +// Core 0 initializes once before starting the Core 1 Bluetooth producer. +// Repeated initialization never clears the boot-lifetime sequence or records. +void switch2_mouse_capture_init(); + +// Nondestructive, oldest-to-newest snapshot. All integers are little-endian. +// Header: total_records u32, count u8, eleven zero bytes. +// Row: serial u32, received_ms u32, PID u16, report_id u8, length u8, +// address[6] in Bluepad32 order, two zero bytes, raw[64] (zero-padded). +// Returns zero before initialization or when the complete snapshot will not fit. +// total_records is taken under the same lock as the serialized rows. +size_t switch2_mouse_capture_snapshot(uint8_t* output, size_t capacity, + uint32_t* total_records); + +constexpr size_t SWITCH2_MOUSE_CAPTURE_NATIVE_INPUT_SIZE = 63; + +struct Switch2MouseCaptureInput { + uint32_t serial; + uint32_t received_ms; + bool active; + // Unrotated native 08 payload bytes 2..3 and 5..7, without report ID. + uint8_t buttons[2]; + uint8_t stick[3]; + // Latest opaque native 08 byte 8; preserve without interpretation. + uint8_t native_status; + // Cumulative relative motion, never consumed by a snapshot. A stream's + // epoch is its first native packet's boot-lifetime serial; inactive is 0. + uint32_t mouse_epoch; + int64_t mouse_total_x; + int64_t mouse_total_y; + // Latest opaque native 08 byte 13; no interpreted surface semantics. + uint8_t mouse_surface; +}; + +// Select one physical right Joy-Con before launching the Bluetooth producer. +// Selection starts empty; it never replays the serialized ring or clears it. +// Its latest native 08 input / teardown survives unrelated ring traffic. +// Each selection also disables and clears the separate native relay FIFO. +void switch2_mouse_capture_select_input(const uint8_t address[6]); + +// Copy only a selected event newer than after_serial. A teardown is an event +// with active=false and zeroed fields. False leaves output untouched. +// Boot-lifetime serials do not wrap, just as in the serialized capture. +// Cached reads do not consume totals; every selected native packet contributes, +// including identical consecutive deltas. A new stream receives a new epoch +// even when the reader missed its preceding teardown. +bool switch2_mouse_capture_latest_input(uint32_t after_serial, + Switch2MouseCaptureInput* output); + +#if SWITCH_PICO_SWITCH2_USB_BRIDGE +// Core 0 explicitly enables the selected source's ordered native 08 relay. +// Starts disabled; false clears the FIFO, repeated true preserves it. Enable +// never replays earlier capture-ring/latest-input data. Selection disables it; +// teardown and capture-serial exhaustion clear it without changing selection. +// Only exact selected right Joy-Con 63-byte native 08 payloads are queued. +// The 32-entry FIFO is independent of the raw capture ring; overflow discards +// queued history and retains only the arriving packet. +void switch2_mouse_capture_set_native_stream(bool enabled); + +// Copy the complete opaque 63-byte payload without its report ID. Returns its +// nonzero boot-lifetime capture serial, never reused, or 0 with report untouched. +// Peek is nondestructive: retry until commit after successful HID submission. +// If the latest selected source or FIFO head is >=500 ms old, discard the FIFO. +// Signed elapsed time tolerates a producer clock just ahead and uint32 rollover. +uint32_t switch2_mouse_capture_peek_native_report( + uint32_t now_ms, uint8_t report[SWITCH2_MOUSE_CAPTURE_NATIVE_INPUT_SIZE]); + +// Remove only the exact current head once. False leaves the FIFO unchanged, +// including for tokens invalidated by overflow, disable, teardown or selection. +bool switch2_mouse_capture_commit_native_report(uint32_t serial); + +// Core 0: one cue for the selected, active right Joy-Con's native stream. +// IDs 0..7 only; input must be newer than 500 ms. Accepted requests receive a +// nonzero boot-lifetime token, never reused by reset, selection or cancellation. +bool switch2_mouse_capture_request_sample(uint8_t sample_id, uint32_t now_ms, + uint64_t* token); +// 0=pending, 1=verified source ACK (consumed once), -1=failed/stale/expired. +// A request expires 2000 ms after acceptance, including time awaiting dispatch. +int switch2_mouse_capture_sample_result(uint64_t token, uint32_t now_ms); +void switch2_mouse_capture_cancel_sample(); +#endif diff --git a/src/firmware/main.cpp b/src/firmware/main.cpp index 538a17f..2767cdf 100644 --- a/src/firmware/main.cpp +++ b/src/firmware/main.cpp @@ -16,6 +16,9 @@ #ifdef SWITCH_PICO_WII_IR_MOUSE #include "usb/wii_ir_mouse_usb.h" #endif +#ifdef SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +#include "input/switch2_mouse_capture.h" +#endif #ifdef SWITCH_PICO_LOG #define LOG_PRINTF(...) printf(__VA_ARGS__) @@ -219,6 +222,9 @@ int main() { #endif board_init(); stdio_init_all(); +#ifdef SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + switch2_mouse_capture_init(); +#endif #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_init(); diff --git a/src/firmware/platform/pico/bootsel_button_sample.h b/src/firmware/platform/pico/bootsel_button_sample.h new file mode 100644 index 0000000..12fb971 --- /dev/null +++ b/src/firmware/platform/pico/bootsel_button_sample.h @@ -0,0 +1,13 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +// Samples BOOTSEL through flash_safe_execute. Returns 1 pressed, 0 released, +// or -1 when sampling was unsafe/unavailable. Does not trigger pairing actions. +int bootsel_button_sample(void); + +#ifdef __cplusplus +} +#endif diff --git a/src/firmware/platform/pico/bootsel_pairing_button.cpp b/src/firmware/platform/pico/bootsel_pairing_button.cpp index a358c12..ef60cb1 100644 --- a/src/firmware/platform/pico/bootsel_pairing_button.cpp +++ b/src/firmware/platform/pico/bootsel_pairing_button.cpp @@ -1,3 +1,4 @@ +#include "platform/pico/bootsel_button_sample.h" #include "platform/pico/bootsel_pairing_button.h" #include "hardware/gpio.h" #include "hardware/structs/ioqspi.h" @@ -48,19 +49,19 @@ void __no_inline_not_in_flash_func(read_bootsel_callback)(void* parameter) { IO_QSPI_GPIO_QSPI_SS_CTRL_OEOVER_BITS); } -BootselPairingButtonSample sample_bootsel() { + +} // namespace + +int bootsel_button_sample(void) { bool pressed = false; const int result = flash_safe_execute(read_bootsel_callback, &pressed, kFlashSafeTimeoutMs); if (result != PICO_OK) { - return BootselPairingButtonSample::kUnread; + return -1; } - return pressed ? BootselPairingButtonSample::kPressed - : BootselPairingButtonSample::kReleased; + return pressed ? 1 : 0; } -} // namespace - BootselPairingButtonEvent BootselPairingButtonHoldFsm::update( BootselPairingButtonSample sample) { if (sample == BootselPairingButtonSample::kUnread) { @@ -96,5 +97,8 @@ BootselPairingButtonEvent bootsel_pairing_button_task() { } g_last_sample_ms = now_ms; - return g_hold_fsm.update(sample_bootsel()); + const int sample = bootsel_button_sample(); + return g_hold_fsm.update(sample < 0 ? BootselPairingButtonSample::kUnread : + sample ? BootselPairingButtonSample::kPressed : + BootselPairingButtonSample::kReleased); } diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index 2e27a6d..058e578 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -12,6 +12,9 @@ #ifdef SWITCH_PICO_HAPTICS_EXPERIMENT #include "input/haptics_transport_probe.h" #endif +#ifdef SWITCH_PICO_SWITCH2_MOUSE_CAPTURE +#include "input/switch2_mouse_capture.h" +#endif #ifdef SWITCH_PICO_WII_IR_GYRO #include "input/wii_ir_pointer.h" #endif @@ -399,6 +402,28 @@ size_t encode_native_switch_rumble(uint8_t* output, size_t output_size) { #endif } +size_t encode_switch2_mouse_capture(uint8_t* output, size_t output_size) { +#ifdef SWITCH_PICO_SWITCH2_MOUSE_CAPTURE + static_assert(kResponseHeaderSize + SWITCH2_MOUSE_CAPTURE_MAXIMUM_PAYLOAD_SIZE <= + kMaximumResponseSize, + "Switch 2 mouse capture no longer fits the EP0 response buffer"); + if (output == nullptr || output_size < kResponseHeaderSize) return 0; + uint32_t total_records = 0; + uint8_t* payload = output + kResponseHeaderSize; + const size_t payload_size = switch2_mouse_capture_snapshot( + payload, output_size - kResponseHeaderSize, &total_records); + if (payload_size == 0) return 0; + return encode_response( + Operation::kSwitch2MouseCapture, Status::kOk, 0, + kSwitch2MouseCaptureSchemaVersion, total_records, + payload, payload_size, output, output_size); +#else + return encode_response( + Operation::kSwitch2MouseCapture, Status::kUnsupportedSchema, 0, + kSwitch2MouseCaptureSchemaVersion, 0, nullptr, 0, output, output_size); +#endif +} + size_t encode_wii_ir_gyro(uint8_t* output, size_t output_size) { #ifdef SWITCH_PICO_WII_IR_GYRO if (output == nullptr || output_size < kResponseHeaderSize) return 0; @@ -976,6 +1001,7 @@ bool usb_configuration_management_vendor_control( const Operation operation = static_cast(request->bRequest); if ((operation == Operation::kHapticsTransportProbe || + operation == Operation::kSwitch2MouseCapture || operation == Operation::kWiiIrGyro) && request->bmRequestType_bit.direction != TUSB_DIR_IN) { return false; @@ -1058,6 +1084,9 @@ bool usb_configuration_management_vendor_control( case Operation::kNativeSwitchRumble: response_size = encode_native_switch_rumble(response, sizeof(response)); break; + case Operation::kSwitch2MouseCapture: + response_size = encode_switch2_mouse_capture(response, sizeof(response)); + break; case Operation::kWiiIrGyro: response_size = encode_wii_ir_gyro(response, sizeof(response)); break; diff --git a/src/firmware/usb/usb_configuration_management.h b/src/firmware/usb/usb_configuration_management.h index a65fb24..aec6c7f 100644 --- a/src/firmware/usb/usb_configuration_management.h +++ b/src/firmware/usb/usb_configuration_management.h @@ -39,6 +39,7 @@ constexpr size_t kMacroCaptureEventSize = 20; constexpr uint16_t kNativeSwitchRumbleSchemaVersion = 2; constexpr size_t kNativeSwitchRumbleRowSize = 80; constexpr size_t kNativeSwitchRumblePayloadSize = 4 * kNativeSwitchRumbleRowSize; +constexpr uint16_t kSwitch2MouseCaptureSchemaVersion = 1; constexpr size_t kMaximumResponseSize = kResponseHeaderSize + kProfileListPayloadSize; constexpr size_t kMaximumChunkSize = @@ -79,6 +80,7 @@ enum class Operation : uint8_t { kHapticsTransportProbe = 0x41, kMacroCapture = 0x42, kNativeSwitchRumble = 0x43, + kSwitch2MouseCapture = 0x44, kWiiIrGyro = 0x45, }; diff --git a/src/firmware/usb/usb_output_driver.cpp b/src/firmware/usb/usb_output_driver.cpp index b276d2d..e560edc 100644 --- a/src/firmware/usb/usb_output_driver.cpp +++ b/src/firmware/usb/usb_output_driver.cpp @@ -36,7 +36,9 @@ namespace { AdapterUsbMode g_mode = AdapterUsbMode::kSwitch; +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE uint16_t g_string_descriptor[32]{}; +#endif bool xinput_selected() { #ifdef SWITCH_PICO_USB_OUTPUT_MODES @@ -55,9 +57,11 @@ bool generic_selected() { #endif } +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE bool switch_selected() { return !xinput_selected() && !generic_selected(); } +#endif } // namespace @@ -193,6 +197,9 @@ void usb_output_driver_set_rumble_callback( switch_pro_set_rumble_callback(instance, callback); } +// The bridge's probe main owns every descriptor and TinyUSB callback. Keep the +// legacy output APIs above available to the normal backend dependency graph. +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE extern "C" uint16_t tud_hid_get_report_cb( uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t* buffer, uint16_t requested_length) { @@ -454,3 +461,4 @@ extern "C" usbd_class_driver_t const* usbd_app_driver_get_cb( *driver_count = 0; return nullptr; } +#endif diff --git a/tests/switch2_mouse_bridge_native_stubs/pico/critical_section.h b/tests/switch2_mouse_bridge_native_stubs/pico/critical_section.h new file mode 100644 index 0000000..6d5ff5e --- /dev/null +++ b/tests/switch2_mouse_bridge_native_stubs/pico/critical_section.h @@ -0,0 +1,8 @@ +#pragma once +#include + +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(); } diff --git a/tests/switch2_mouse_bridge_native_stubs/pico/stdlib.h b/tests/switch2_mouse_bridge_native_stubs/pico/stdlib.h new file mode 100644 index 0000000..310759a --- /dev/null +++ b/tests/switch2_mouse_bridge_native_stubs/pico/stdlib.h @@ -0,0 +1,9 @@ +#pragma once +#include + +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); diff --git a/tests/switch2_mouse_bridge_test.cpp b/tests/switch2_mouse_bridge_test.cpp new file mode 100644 index 0000000..8ccc6be --- /dev/null +++ b/tests/switch2_mouse_bridge_test.cpp @@ -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 +#include +#include +#include + +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(value); } +bool time_reached(absolute_time_t deadline) { return now >= deadline; } +void sleep_ms(uint32_t milliseconds) { now += milliseconds; } + +using NativeReport = std::array; + +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((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(x); + report[10] = static_cast(x) >> 8; + report[11] = static_cast(y); + report[12] = static_cast(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(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(now)); +} + +static probe_controller_input poll(uint32_t timestamp = static_cast(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(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(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(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(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(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(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"); +} diff --git a/tests/switch2_usb_probe_protocol_test.c b/tests/switch2_usb_probe_protocol_test.c new file mode 100644 index 0000000..d8643fa --- /dev/null +++ b/tests/switch2_usb_probe_protocol_test.c @@ -0,0 +1,103 @@ +#include +#include +#include +#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; +} diff --git a/tests/test_switch2_mouse_bridge_native.py b/tests/test_switch2_mouse_bridge_native.py new file mode 100644 index 0000000..8b8a900 --- /dev/null +++ b/tests/test_switch2_mouse_bridge_native.py @@ -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) diff --git a/tests/test_switch2_sample_relay_native.py b/tests/test_switch2_sample_relay_native.py new file mode 100644 index 0000000..b55ef5a --- /dev/null +++ b/tests/test_switch2_sample_relay_native.py @@ -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) diff --git a/tests/test_switch2_usb_probe_protocol_native.py b/tests/test_switch2_usb_probe_protocol_native.py new file mode 100644 index 0000000..3335367 --- /dev/null +++ b/tests/test_switch2_usb_probe_protocol_native.py @@ -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) diff --git a/tools/switch2_usb_probe/CMakeLists.txt b/tools/switch2_usb_probe/CMakeLists.txt new file mode 100644 index 0000000..49bf8c5 --- /dev/null +++ b/tools/switch2_usb_probe/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.13) +set(PICO_BOARD pico2_w CACHE STRING "Target board") +include(${CMAKE_CURRENT_LIST_DIR}/../../pico_sdk_import.cmake) +project(switch2_usb_probe C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) +include(${CMAKE_CURRENT_LIST_DIR}/probe_build.cmake) +pico_sdk_init() +add_executable(switch2-usb-probe + ../../src/firmware/configuration/configuration_storage.cpp + ../../src/firmware/platform/pico/bootsel_pairing_button.cpp) +target_compile_definitions(switch2-usb-probe PRIVATE + PICO_FLASH_ASSUME_CORE1_SAFE=1 PICO_FLASH_ASSERT_ON_UNSAFE=0) +switch2_usb_probe_configure(switch2-usb-probe) +pico_add_extra_outputs(switch2-usb-probe) diff --git a/tools/switch2_usb_probe/button_test.c b/tools/switch2_usb_probe/button_test.c new file mode 100644 index 0000000..a2c05a4 --- /dev/null +++ b/tools/switch2_usb_probe/button_test.c @@ -0,0 +1,17 @@ +#include "button_test.h" + +bool probe_button_update(probe_button_state* state, int sample, bool ready) { + if (!ready || (sample != 0 && sample != 1)) { + state->armed = false; + state->pressed_samples = 0; + return false; + } + if (sample == 0) { + state->armed = true; + state->pressed_samples = 0; + return false; + } + if (!state->armed) return false; + if (state->pressed_samples < 2) ++state->pressed_samples; + return state->pressed_samples == 2; +} diff --git a/tools/switch2_usb_probe/button_test.h b/tools/switch2_usb_probe/button_test.h new file mode 100644 index 0000000..7e663e0 --- /dev/null +++ b/tools/switch2_usb_probe/button_test.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include + +typedef struct { + bool armed; + uint8_t pressed_samples; +} probe_button_state; + +// Sample at 10 ms intervals. Require a release after reset/unavailability, +// then two pressed samples. Release/error/not-ready clears output immediately. +bool probe_button_update(probe_button_state* state, int sample, bool ready); diff --git a/tools/switch2_usb_probe/controller_input.cpp b/tools/switch2_usb_probe/controller_input.cpp new file mode 100644 index 0000000..cbae87d --- /dev/null +++ b/tools/switch2_usb_probe/controller_input.cpp @@ -0,0 +1,149 @@ +#include "controller_input.h" + +#include + +#include "input/bluepad32_input_backend.h" +#include "input/switch2_mouse_capture.h" +#include "platform/pico/bootsel_pairing_button.h" +#include "platform/pico/system_clock.h" +#include "profile/controller_profile_runtime.h" +#include "pico/stdlib.h" + +#if !SWITCH_PICO_SWITCH2_USB_BRIDGE || !SWITCH_PICO_BLUEPAD32 || \ + !SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE || \ + !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE +#error "The controller bridge requires Bluepad32 BLE and native Switch 2 capture" +#endif + +namespace { +constexpr uint8_t kSourceAddress[] = {SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES}; +static_assert(sizeof(kSourceAddress) == 6, "Select one physical Bluetooth address"); +constexpr uint32_t kInputDeadlineMs = 500; +constexpr uint32_t kFlashCoordinationTimeoutMs = 1000; +// The backend publishes stage 2 only after Core 1's flash-safe registration; +// reaching Core 1 already required successful Core 0 registration in start(). +constexpr uint32_t kFlashCoordinationStage = 2; +bool g_initialized; +bool g_start_attempted; +bool g_flash_ready; +probe_controller_input g_input; +uint32_t g_received_ms; +} // namespace + +extern "C" void probe_controller_input_clock_init(void) { + system_clock_initialize(); +} + +extern "C" void probe_controller_input_init(void) { + if (g_initialized) return; + switch2_mouse_capture_init(); + switch2_mouse_capture_select_input(kSourceAddress); + // Prepare the existing storage services without initializing legacy USB. + // Core 1 loads their persisted state during the normal backend startup. + bluepad32_input_backend_init(); + controller_profile_runtime_reset(); + g_initialized = true; +} + +extern "C" bool probe_controller_input_start(void) { + if (!g_initialized) probe_controller_input_init(); + if (g_start_attempted) return g_flash_ready; + g_start_attempted = true; + bluepad32_input_backend_start(); + const absolute_time_t deadline = make_timeout_time_ms(kFlashCoordinationTimeoutMs); + do { + Bluepad32BackendDiagnostics diagnostics; + bluepad32_input_backend_diagnostics(&diagnostics); + if (diagnostics.initialization_stage >= kFlashCoordinationStage) { + g_flash_ready = true; + return true; + } + sleep_ms(1); + } while (!time_reached(deadline)); + // Do not reset Core 1 or retry a partially launched backend. It may still + // be running; a false return keeps USB and its flash writes fail-closed. + return false; +} + +extern "C" bool probe_controller_input_pairing_task(void) { + if (!g_flash_ready) return false; + // Use the shared sampler/hold policy, but deliberately do not route its + // kClearPairings event to recovery or any storage-clearing operation. + if (bootsel_pairing_button_task() != BootselPairingButtonEvent::kOpenPairing) + return false; + bluepad32_input_backend_open_pairing_window(); + return true; +} + +extern "C" void probe_controller_input_set_native_stream(bool enabled) { + switch2_mouse_capture_set_native_stream(g_flash_ready && enabled); +} + +extern "C" uint32_t probe_controller_input_peek_native_report( + uint32_t now_ms, uint8_t report[63]) { + if (!g_flash_ready) return 0; + return switch2_mouse_capture_peek_native_report(now_ms, report); +} + +extern "C" bool probe_controller_input_commit_native_report(uint32_t serial) { + if (!g_flash_ready) return false; + return switch2_mouse_capture_commit_native_report(serial); +} + +extern "C" bool probe_controller_input_play_sample(uint8_t sample_id, uint64_t* token) { + if (!g_flash_ready) { + if (token != nullptr) *token = 0; + return false; + } + return switch2_mouse_capture_request_sample( + sample_id, to_ms_since_boot(get_absolute_time()), token); +} + +extern "C" int probe_controller_input_sample_result(uint64_t token, uint32_t now_ms) { + if (!g_flash_ready) return -1; + return switch2_mouse_capture_sample_result(token, now_ms); +} + +extern "C" void probe_controller_input_cancel_sample(void) { + switch2_mouse_capture_cancel_sample(); +} + +extern "C" void probe_controller_input_poll(uint32_t now_ms, + probe_controller_input* out) { + if (out == nullptr) return; + if (!g_flash_ready) { + *out = {}; + return; + } + Switch2MouseCaptureInput sample; + if (switch2_mouse_capture_latest_input(g_input.serial, &sample)) { + g_input.serial = sample.serial; + g_input.active = sample.active; + g_received_ms = sample.received_ms; + // Preserve physical byte meaning: never route through the generic + // solo Joy-Con rotation/mapping. Teardown fields are already zeroed. + memcpy(g_input.buttons, sample.buttons, sizeof(g_input.buttons)); + memcpy(g_input.stick, sample.stick, sizeof(g_input.stick)); + g_input.native_status = sample.native_status; + g_input.mouse_epoch = sample.mouse_epoch; + g_input.mouse_total_x = sample.mouse_total_x; + g_input.mouse_total_y = sample.mouse_total_y; + g_input.mouse_surface = sample.mouse_surface; + } + // The producer can be a millisecond ahead of the caller's pre-poll clock. + // Signed elapsed time tolerates that race and ordinary uint32_t rollover. + // Expiration latches inactive until a newer capture serial arrives. + if (g_input.active && + static_cast(now_ms - g_received_ms) >= + static_cast(kInputDeadlineMs)) { + g_input.active = false; + memset(g_input.buttons, 0, sizeof(g_input.buttons)); + memset(g_input.stick, 0, sizeof(g_input.stick)); + g_input.native_status = 0; + g_input.mouse_epoch = 0; + g_input.mouse_total_x = 0; + g_input.mouse_total_y = 0; + g_input.mouse_surface = 0; + } + *out = g_input; +} diff --git a/tools/switch2_usb_probe/controller_input.h b/tools/switch2_usb_probe/controller_input.h new file mode 100644 index 0000000..689fbb0 --- /dev/null +++ b/tools/switch2_usb_probe/controller_input.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + bool active; + uint32_t serial; + uint8_t buttons[2]; + uint8_t stick[3]; + // Latest opaque native 08 byte 8. + uint8_t native_status; + // Cumulative signed relative totals within mouse_epoch, not per-poll + // deltas. Cached polls repeat these totals without consuming motion. + // Epoch changes on reconnect, including a teardown missed between polls. + uint32_t mouse_epoch; + int64_t mouse_total_x; + int64_t mouse_total_y; + // Latest opaque native 08 byte 13. + uint8_t mouse_surface; +} probe_controller_input; + +// Core 0, before stdio/peripheral initialization. +void probe_controller_input_clock_init(void); +// Core 0, after stdio and before protocol reset or USB startup. +void probe_controller_input_init(void); +// True means both cores are registered for flash coordination, not that the +// radio is ready or a controller is connected. Failure is latched: keep USB +// and flash-writing protocol operations disabled rather than retrying startup. +bool probe_controller_input_start(void); +// Core 0 after start(): polls the existing two-second BOOTSEL hold gesture. +// True means a Bluetooth pairing-window request was queued. Long holds NEVER +// clear pairings in this bridge, and this does not inject USB controller input. +bool probe_controller_input_pairing_task(void); +// Core 0 native 08 relay: disabled until explicitly enabled after flash-ready +// startup. Disable clears queued data, repeated enable preserves it. Resets +// must disable the stream; this never changes Bluetooth bonds or pairing. +// Only subsequent selected-source packets enter the separate 32-entry FIFO. +// Overflow drops queued history and retains only the arriving packet. +void probe_controller_input_set_native_stream(bool enabled); +// Copy a full opaque 63-byte payload (without report ID), oldest first. Returns +// its never-reused boot-lifetime serial; 0 leaves report untouched. Latest source +// or head >=500 ms old discards the FIFO; now_ms is the Pico boot-ms clock. +// Nondestructive until successful HID submission followed by commit. +uint32_t probe_controller_input_peek_native_report(uint32_t now_ms, uint8_t report[63]); +// Remove only the exact current head once. A stale/replaced token cannot pop a +// new stream's packet. Before flash-ready startup peek/commit return 0/false. +bool probe_controller_input_commit_native_report(uint32_t serial); +// Built-in vibration samples only; raw HD-rumble output is not forwarded. +// A nonzero token means queued, not acknowledged. Result: 0 pending, 1 real +// source ACK, -1 failed/stale. Reset cancels the request, never stored pairing. +bool probe_controller_input_play_sample(uint8_t sample_id, uint64_t* token); +int probe_controller_input_sample_result(uint64_t token, uint32_t now_ms); +void probe_controller_input_cancel_sample(void); +// Core 0 at 250 Hz; now_ms uses the Pico boot-millisecond clock. Only fresh +// native 08 buttons/stick and cumulative mouse totals are exposed. Inactive +// fields are zero except serial; the USB protocol must supply its calibrated +// stick center rather than forwarding inactive stick bytes. +void probe_controller_input_poll(uint32_t now_ms, probe_controller_input* out); + +#ifdef __cplusplus +} +#endif diff --git a/tools/switch2_usb_probe/descriptors.h b/tools/switch2_usb_probe/descriptors.h new file mode 100644 index 0000000..c2a11ea --- /dev/null +++ b/tools/switch2_usb_probe/descriptors.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +// Published Joy-Con 2 (R) USB descriptors, reproduced for enumeration capture. +// https://github.com/ndeadly/switch2_controller_research/blob/master/descriptors.md + +static const uint8_t probe_device_descriptor[] = { + 0x12, 0x01, 0x00, 0x02, 0xef, 0x02, 0x01, 0x40, 0x7e, 0x05, 0x66, 0x20, + 0x00, 0x01, 0x01, 0x02, 0x03, 0x01, +}; + +static const uint8_t probe_configuration_descriptor[] = { + 0x09, 0x02, 0x50, 0x00, 0x02, 0x01, 0x04, 0xc0, 0xfa, 0x08, 0x0b, 0x00, + 0x01, 0x03, 0x00, 0x00, 0x00, 0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00, + 0x00, 0x05, 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x64, 0x00, 0x07, + 0x05, 0x81, 0x03, 0x40, 0x00, 0x04, 0x07, 0x05, 0x01, 0x03, 0x40, 0x00, + 0x04, 0x08, 0x0b, 0x01, 0x01, 0xff, 0x00, 0x00, 0x00, 0x09, 0x04, 0x01, + 0x00, 0x02, 0xff, 0x00, 0x00, 0x06, 0x07, 0x05, 0x02, 0x02, 0x40, 0x00, + 0x00, 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00, +}; + +static const uint8_t probe_hid_report_descriptor[] = { + 0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x85, 0x05, 0x05, 0xff, 0x09, 0x01, + 0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x3f, 0x75, 0x08, 0x81, 0x02, 0x85, + 0x08, 0x09, 0x01, 0x95, 0x02, 0x81, 0x02, 0x05, 0x09, 0x19, 0x01, 0x29, + 0x10, 0x25, 0x01, 0x95, 0x10, 0x75, 0x01, 0x81, 0x02, 0x05, 0xff, 0x09, + 0x01, 0x26, 0xff, 0x00, 0x95, 0x01, 0x75, 0x08, 0x81, 0x02, 0x05, 0x01, + 0x09, 0x01, 0xa1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x26, 0xff, 0x0f, 0x95, + 0x02, 0x75, 0x0c, 0x81, 0x02, 0xc0, 0x05, 0xff, 0x09, 0x02, 0x26, 0xff, + 0x00, 0x95, 0x37, 0x75, 0x08, 0x81, 0x02, 0x85, 0x01, 0x09, 0x01, 0x95, + 0x3f, 0x91, 0x02, 0xc0, +}; diff --git a/tools/switch2_usb_probe/main.c b/tools/switch2_usb_probe/main.c new file mode 100644 index 0000000..390d122 --- /dev/null +++ b/tools/switch2_usb_probe/main.c @@ -0,0 +1,684 @@ +// Joy-Con 2 (R) USB instrument and optional Bluetooth controller/mouse bridge. +// Only documented/observed transactions are implemented. Built-in vibration +// cues wait for the source ACK; native sensor packets are relayed without decoding. +// Only virtual pairing storage is writable here. +#include +#include +#include +#include +#include + +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE +#include "controller_input.h" +#else +#include "platform/pico/bootsel_button_sample.h" +#include "button_test.h" +#endif +#include "pico/stdlib.h" +#include "hardware/sync.h" +#include "hardware/uart.h" +#include "tusb.h" +#include "descriptors.h" +#include "protocol.h" +#include "storage.h" +#ifdef SWITCH2_PROBE_MEMORY +#include "memory.h" +#endif +#ifdef SWITCH2_PROBE_IDENTITY_REPLY +#include "probe_identity.h" +_Static_assert(sizeof(probe_identity_reply) == 64, "factory identity response size"); +#endif +#ifdef SWITCH2_PROBE_VERSION_REPLY +#include "probe_version.h" +_Static_assert(sizeof(probe_version_reply) == 16, "version/address response size"); +#endif + +#define LOG_CAPACITY 8192u +static char log_bytes[LOG_CAPACITY]; +static uint32_t log_written, log_read, log_dropped; +static uint32_t bulk_packets, hid_packets; +static uint32_t identity_requests, version_requests, setup_completions; +static uint16_t string_descriptor[64]; +static uint32_t input_reports, command_drops; +#ifdef SWITCH2_PROBE_USB_INIT +#define REPLY_CAPACITY 4u +typedef struct { + uint8_t data[PROBE_REPLY_MAX_SIZE]; + uint8_t length; + uint64_t deferred_token; +} queued_reply; +static probe_protocol_state protocol; +static queued_reply replies[REPLY_CAPACITY]; +static uint8_t reply_head, reply_count; +static bool reply_inflight; +static uint16_t reply_remaining; +static uint8_t command_frame[PROBE_COMMAND_MAX_SIZE]; +static uint16_t command_used, command_expected = 8; +static uint32_t last_input_ms; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE +static uint32_t last_controller_poll_ms; +static uint16_t last_delivered_buttons; +static bool native_stream_ready; +static uint32_t last_hid_complete_ms; +static bool hid_completion_seen; +static uint32_t mouse_delivered_reports, mouse_logged_reports; +static int64_t mouse_delivered_x, mouse_delivered_y; +#endif +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE +static probe_button_state button_test; +static uint32_t last_button_ms; +static bool button_sample_error; +#endif +static uint8_t last_delivered_rails; +#endif + +_Static_assert(sizeof(probe_device_descriptor) == 18, "device descriptor size"); +_Static_assert(sizeof(probe_configuration_descriptor) == 80, "configuration descriptor size"); +_Static_assert(sizeof(probe_hid_report_descriptor) == 100, "HID descriptor size"); + +int probe_debug_printf(const char* format, ...) { + char message[512]; + va_list args; + va_start(args, format); + const int result = vsnprintf(message, sizeof(message), format, args); + va_end(args); + if (result <= 0) return result; + const size_t size = (size_t)result < sizeof(message) ? (size_t)result : sizeof(message) - 1; + const uint32_t interrupts = save_and_disable_interrupts(); + if (LOG_CAPACITY - (log_written - log_read) >= size) { + for (size_t i = 0; i < size; ++i) + log_bytes[(log_written + i) % LOG_CAPACITY] = message[i]; + log_written += (uint32_t)size; + log_dropped += (uint32_t)result - (uint32_t)size; + } else { + log_dropped += (uint32_t)result; + } + restore_interrupts(interrupts); + return result; +} + +static void drain_log(void) { + while (uart_is_writable(uart0)) { + const uint32_t interrupts = save_and_disable_interrupts(); + if (log_read == log_written) { + restore_interrupts(interrupts); + break; + } + const char value = log_bytes[log_read++ % LOG_CAPACITY]; + restore_interrupts(interrupts); + uart_putc_raw(uart0, value); + } +} + +static void log_packet(const char* kind, uint8_t instance, uint8_t report_id, + const uint8_t* data, uint16_t length) { + if (length >= 4 && data[0] == 0x15 && (data[3] == 0x02 || data[3] == 0x04)) { + probe_debug_printf("[PROBE] %s itf=%u command=15/%02x len=%u [pairing payload redacted]\n", + kind, instance, data[3], length); + return; + } + static const char hex[] = "0123456789abcdef"; + char message[288]; + size_t at = (size_t)snprintf(message, sizeof(message), + "[PROBE %" PRIu32 "] %s itf=%u report=%02x len=%u:", + to_ms_since_boot(get_absolute_time()), kind, instance, report_id, length); + const uint16_t count = length < 64 ? length : 64; + for (uint16_t i = 0; i < count && at + 4 < sizeof(message); ++i) { + message[at++] = ' '; + message[at++] = hex[data[i] >> 4]; + message[at++] = hex[data[i] & 15]; + } + message[at] = 0; + probe_debug_printf("%s%s\n", message, length > count ? " [truncated]" : ""); +} + +uint8_t const* tud_descriptor_device_cb(void) { + probe_debug_printf("[PROBE] DEVICE_DESCRIPTOR 057e:2066\n"); + return probe_device_descriptor; +} + +uint8_t const* tud_descriptor_configuration_cb(uint8_t index) { + probe_debug_printf("[PROBE] CONFIG_DESCRIPTOR index=%u\n", index); + return index == 0 ? probe_configuration_descriptor : NULL; +} + +uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) { + probe_debug_printf("[PROBE] HID_DESCRIPTOR itf=%u\n", instance); + return instance == 0 ? probe_hid_report_descriptor : NULL; +} + +uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) { + // Manufacturer/product/serial match the published reference. Remaining + // descriptor labels describe the probe; their genuine strings are unknown. + static const char* const strings[] = { + "", "Nintendo", "Joy-Con 2 (R)", "00", "USB configuration", "HID", "Commands"}; + probe_debug_printf("[PROBE] STRING_DESCRIPTOR index=%u lang=%04x\n", index, langid); + if (index == 0) { + string_descriptor[0] = (TUSB_DESC_STRING << 8) | 4; + string_descriptor[1] = 0x0409; + return string_descriptor; + } + if (index >= sizeof(strings) / sizeof(strings[0])) return NULL; + size_t count = strlen(strings[index]); + if (count > 63) count = 63; + string_descriptor[0] = (uint16_t)((TUSB_DESC_STRING << 8) | (2 + count * 2)); + for (size_t i = 0; i < count; ++i) + string_descriptor[i + 1] = (uint8_t)strings[index][i]; + return string_descriptor; +} + +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t* buffer, + uint16_t requested_length) { +#ifdef SWITCH2_PROBE_USB_INIT + uint8_t input[PROBE_INPUT_SIZE]; + if (instance == 0 && report_type == HID_REPORT_TYPE_INPUT && + probe_protocol_report(&protocol, report_id, input, sizeof(input))) { + const uint16_t size = requested_length < sizeof(input) ? requested_length : sizeof(input); + memcpy(buffer, input, size); + probe_debug_printf("[PROBE] GET_REPORT id=%02x diagnostic length=%u\n", report_id, size); + return size; + } +#else + (void)buffer; +#endif + probe_debug_printf("[PROBE] GET_REPORT itf=%u report=%02x type=%u length=%u -> STALL\n", + instance, report_id, report_type, requested_length); + return 0; +} + +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, const uint8_t* buffer, + uint16_t length) { + ++hid_packets; + probe_debug_printf("[PROBE] HID_REPORT_TYPE=%u\n", report_type); + log_packet("HID_OUT", instance, report_id, buffer, length); +} + +#ifdef SWITCH2_PROBE_USB_INIT +static bool save_pairing(const uint8_t* data, size_t length) { + const bool saved = probe_storage_save(data, length); + probe_debug_printf("[PROBE] Virtual pairing persistence %s\n", saved ? "verified" : "failed"); + return saved; +} + +static void reset_protocol(void) { +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + probe_controller_input_cancel_sample(); +#endif + probe_protocol_reset(&protocol); + memcpy(protocol.controller_address, probe_version_reply + 10, sizeof(protocol.controller_address)); + protocol.firmware_version = probe_firmware_version; + protocol.save_pairing = save_pairing; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + protocol.play_sample = probe_controller_input_play_sample; +#endif +#ifdef SWITCH2_PROBE_MEMORY + if (!probe_memory_right_stick_center(protocol.right_stick_center)) + panic("Invalid captured Joy-Con stick calibration"); + protocol.read_memory = probe_memory_read; +#endif + uint8_t pairing[PROBE_PAIRING_BLOB_SIZE]; + if (probe_storage_load(pairing, sizeof(pairing)) && + probe_protocol_restore_pairing(&protocol, pairing, sizeof(pairing))) { + probe_debug_printf("[PROBE] Restored own virtual pairing record\n"); + } + reply_head = reply_count = 0; + reply_inflight = false; + reply_remaining = 0; + command_used = 0; + command_expected = 8; + last_input_ms = 0; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + last_controller_poll_ms = 0; + last_delivered_buttons = 0; + probe_controller_input_set_native_stream(false); + native_stream_ready = false; + last_hid_complete_ms = 0; + hid_completion_seen = false; +#endif +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE + (void)probe_button_update(&button_test, -1, false); + last_button_ms = 0; + button_sample_error = false; +#endif + last_delivered_rails = 0; +} + +static void complete_command(void) { + // Log complete frames rather than fragments so key material can be redacted. + log_packet("BULK_OUT", 0, 0, command_frame, command_used); + if (reply_count == REPLY_CAPACITY) { + ++command_drops; + probe_debug_printf("[PROBE] Command captured but not executed: reply queue full\n"); + return; + } + queued_reply* reply = &replies[(reply_head + reply_count) % REPLY_CAPACITY]; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + const uint8_t previous_report_id = protocol.report_id; + const uint8_t previous_features = protocol.enabled_features; +#endif + const size_t length = probe_protocol_command( + &protocol, command_frame, command_used, reply->data, sizeof(reply->data), + &reply->deferred_token); + if (length) { +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + if (previous_report_id != protocol.report_id || + previous_features != protocol.enabled_features) { + probe_controller_input_set_native_stream(false); + native_stream_ready = false; + } +#endif + reply->length = (uint8_t)length; + ++reply_count; + if (reply->deferred_token) { + probe_debug_printf("[PROBE] Sample %u awaiting source ACK token=%" PRIu64 "\n", + command_frame[8], reply->deferred_token); + } else { + log_packet("BULK_REPLY_QUEUED", 0, 0, reply->data, reply->length); + } + if (command_frame[0] == 0x09) { + probe_debug_printf("[PROBE] Virtual player LEDs mask=%x flashing=%u\n", + protocol.player_leds, protocol.player_leds_flashing); + } + if (command_frame[0] == 0x0c) { + probe_debug_printf("[PROBE] Virtual features mask=%02x enabled=%02x\n", + protocol.feature_mask, protocol.enabled_features); + } + if (command_frame[0] == 0x0a && command_frame[3] == 8) + probe_debug_printf("[PROBE] Virtual vibration parameters stored; no motor output\n"); + if (command_frame[0] == 0x03 && command_frame[3] == 0x0c) + probe_debug_printf("[PROBE] Runtime 03/0C value=%u\n", protocol.runtime03_0c); + } else { + probe_debug_printf("[PROBE] Command %02x/%02x length=%u capture-only or malformed\n", + command_frame[0], command_frame[3], command_used); + } +} + +static void consume_bulk_packet(const uint8_t* buffer, uint16_t length) { + for (uint16_t i = 0; i < length; ++i) { + command_frame[command_used++] = buffer[i]; + if (command_used == 8) command_expected = (uint16_t)(8 + command_frame[5]); + if (command_used == command_expected) { + complete_command(); + command_used = 0; + command_expected = 8; + } + } + // A short USB packet/ZLP ends an OUT transfer. Never let a malformed + // truncated frame consume a subsequent independent host command. + if (length < CFG_TUD_VENDOR_EPSIZE && command_used) { + probe_debug_printf("[PROBE] Truncated command discarded: received=%u expected=%u\n", + command_used, command_expected); + command_used = 0; + command_expected = 8; + } +} + +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE +static void button_test_task(uint32_t now) { + const bool ready = tud_mounted() && !tud_suspended() && + protocol.initialized && (protocol.enabled_features & 1); + bool pressed; + if (!ready) { + pressed = probe_button_update(&button_test, -1, false); + last_button_ms = now; + } else { + if (now - last_button_ms < 10) return; + last_button_ms = now; + const int sample = bootsel_button_sample(); + if (sample < 0 && !button_sample_error) + probe_debug_printf("[PROBE] BOOTSEL sampling unavailable; test buttons released and disarmed\n"); + button_sample_error = sample < 0; + pressed = probe_button_update(&button_test, sample, true); + } + if (protocol.test_rail_buttons != pressed) { + protocol.test_rail_buttons = pressed; + probe_debug_printf("[PROBE] TEST_BUTTON SL+SR %s\n", pressed ? "pressed" : "released"); + } +} +#endif + +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE +static void controller_input_task(uint32_t now) { + if (now - last_controller_poll_ms < 4) return; + last_controller_poll_ms = now; + probe_controller_input source = {0}; + probe_controller_input_poll(now, &source); + const bool output_active = source.active && tud_mounted() && !tud_suspended(); + if (protocol.controller_active != output_active) + probe_debug_printf("[PROBE] Controller input %s\n", output_active ? "active" : "neutral (disconnected/stale)"); + protocol.controller_active = output_active; + if (output_active) { + memcpy(protocol.controller_buttons, source.buttons, sizeof(source.buttons)); + memcpy(protocol.controller_stick, source.stick, sizeof(source.stick)); + } else { + memset(protocol.controller_buttons, 0, sizeof(protocol.controller_buttons)); + } + // Bootstrap with diagnostic reports until the host polls HID. Then consume + // complete source packets once, including opaque packed motion samples. + native_stream_ready = tud_mounted() && !tud_suspended() && protocol.initialized && + protocol.report_id == 0x08 && hid_completion_seen && + (uint32_t)(now - last_hid_complete_ms) < 500; + probe_controller_input_set_native_stream(native_stream_ready); +} + +static void gate_native_report(uint8_t input[PROBE_INPUT_SIZE]) { + if (!(protocol.enabled_features & 1)) memset(input + 2, 0, 2); + if (!(protocol.enabled_features & 2)) + memcpy(input + 5, protocol.right_stick_center, sizeof(protocol.right_stick_center)); + if (!(protocol.enabled_features & 0x10)) memset(input + 9, 0, 5); + if (!(protocol.enabled_features & 4)) memset(input + 15, 0, 41); +} +#endif + +static void protocol_task(uint32_t now) { + if (!tud_mounted() || tud_suspended()) return; + if (reply_count && !reply_inflight) { + queued_reply* reply = &replies[reply_head]; + bool ready = reply->deferred_token == 0; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + if (!ready) { + const int result = probe_controller_input_sample_result(reply->deferred_token, now); + if (result > 0) { + probe_debug_printf("[PROBE] Source sample ACK token=%" PRIu64 "\n", + reply->deferred_token); + reply->deferred_token = 0; + ready = true; + log_packet("BULK_REPLY_QUEUED", 0, 0, reply->data, reply->length); + } else if (result < 0) { + probe_debug_printf("[PROBE] Source sample failed or expired token=%" PRIu64 + "; no USB ACK\n", reply->deferred_token); + reply_head = (uint8_t)((reply_head + 1) % REPLY_CAPACITY); + --reply_count; + ++command_drops; + } + } +#endif + if (ready && tud_vendor_n_write_available(0) >= reply->length) { + if (tud_vendor_n_write(0, reply->data, reply->length) == reply->length) { + reply_inflight = true; + reply_remaining = reply->length; + tud_vendor_n_write_flush(0); + reply_head = (uint8_t)((reply_head + 1) % REPLY_CAPACITY); + --reply_count; + } + } + } + if (protocol.initialized && now - last_input_ms >= 4 && tud_hid_n_ready(0)) { + uint8_t input[PROBE_INPUT_SIZE]; + size_t length = 0; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + uint32_t native_serial = 0; + if (protocol.report_id == 0x08 && protocol.controller_active && native_stream_ready) { + native_serial = probe_controller_input_peek_native_report(now, input); + if (!native_serial) return; // Never replay a packet's mouse or motion samples. + length = sizeof(input); + gate_native_report(input); + } +#endif + if (!length) { + length = probe_protocol_report(&protocol, protocol.report_id, input, sizeof(input)); + } + if (length && tud_hid_n_report(0, protocol.report_id, input, (uint16_t)length)) { +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + if (native_serial) { + if (!probe_controller_input_commit_native_report(native_serial)) + probe_debug_printf("[PROBE] Native stream changed during HID submission\n"); + protocol.report_counter = input[0]; + } +#endif + ++protocol.report_counter; + ++input_reports; + last_input_ms = now; + } + } +} +#endif + +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE +static int32_t signed_mouse_delta(const uint8_t* data) { + const uint16_t raw = (uint16_t)(data[0] | ((uint16_t)data[1] << 8)); + return raw >= 0x8000 ? (int32_t)raw - 0x10000 : raw; +} + +void tud_hid_report_failed_cb(uint8_t instance, hid_report_type_t report_type, + const uint8_t* report, uint16_t length) { + (void)report; + (void)length; + if (instance == 0 && report_type == HID_REPORT_TYPE_INPUT) { + probe_controller_input_set_native_stream(false); + native_stream_ready = false; + hid_completion_seen = false; + probe_debug_printf("[PROBE] HID input transfer failed; pending native packets discarded\n"); + } +} +#endif + +void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* report, uint16_t length) { +#ifdef SWITCH2_PROBE_USB_INIT + if (instance != 0 || length != PROBE_INPUT_SIZE + 1) return; + uint8_t rails; + if (report[0] == 0x08) rails = (report[4] >> 6) & 3; + else if (report[0] == 0x05) rails = (report[5] >> 4) & 3; + else return; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + last_hid_complete_ms = to_ms_since_boot(get_absolute_time()); + hid_completion_seen = true; + if (report[0] == 0x08) { + const int32_t dx = signed_mouse_delta(report + 10); + const int32_t dy = signed_mouse_delta(report + 12); + if (dx || dy) { + ++mouse_delivered_reports; + mouse_delivered_x += dx; + mouse_delivered_y += dy; + } + } + const uint16_t buttons = report[0] == 0x08 ? + (uint16_t)(report[3] | ((uint16_t)report[4] << 8)) : + (uint16_t)(report[5] | ((uint16_t)report[6] << 8)); + if (buttons != last_delivered_buttons) { + last_delivered_buttons = buttons; + probe_debug_printf("[PROBE] CONTROLLER_REPORT delivered id=%02x buttons=%04x\n", report[0], buttons); + } +#endif + if (rails != last_delivered_rails) { + last_delivered_rails = rails; + probe_debug_printf("[PROBE] TEST_REPORT delivered id=%02x SL=%u SR=%u\n", + report[0], (rails >> 1) & 1, rails & 1); + } +#else + (void)instance; + (void)report; + (void)length; +#endif +} + +void tud_vendor_rx_cb(uint8_t instance, const uint8_t* buffer, uint16_t length) { + ++bulk_packets; +#ifdef SWITCH2_PROBE_USB_INIT + if (instance == 0) consume_bulk_packet(buffer, length); +#else + log_packet("BULK_OUT", instance, 0, buffer, length); +#endif + // Drain TinyUSB's receive FIFO; raw packet data above is consumed once. + uint8_t discarded[64]; + while (tud_vendor_n_available(instance)) { + if (!tud_vendor_n_read(instance, discarded, sizeof(discarded))) break; + } +} + +void tud_vendor_tx_cb(uint8_t instance, uint32_t length) { +#ifdef SWITCH2_PROBE_USB_INIT + if (instance == 0 && reply_inflight) { + if (length <= reply_remaining) { + reply_remaining -= (uint16_t)length; + // TinyUSB calls this per packet. Exact packet multiples finish only + // after its automatic ZLP, not after the last full-size packet. + if (reply_remaining == 0 && length < CFG_TUD_VENDOR_EPSIZE) + reply_inflight = false; + } else { + probe_debug_printf("[PROBE] Unexpected bulk completion; pending=%u\n", reply_remaining); + } + } +#endif + probe_debug_printf("[PROBE] BULK_TX_COMPLETE itf=%u length=%" PRIu32 "\n", instance, length); +} + +bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, + const tusb_control_request_t* request) { + if (stage == CONTROL_STAGE_SETUP) + log_packet("VENDOR_CONTROL", rhport, 0, (const uint8_t*)request, sizeof(*request)); +#ifdef SWITCH2_PROBE_IDENTITY_REPLY + if (request->bmRequestType == 0xc0 && request->bRequest == 0x03 && + request->wValue == 0 && request->wIndex == 0) { + if (stage == CONTROL_STAGE_SETUP) { + ++identity_requests; + log_packet("IDENTITY_REPLY", 0, 3, probe_identity_reply, sizeof(probe_identity_reply)); + return tud_control_xfer(rhport, request, (void*)probe_identity_reply, + sizeof(probe_identity_reply)); + } + return true; + } +#endif +#ifdef SWITCH2_PROBE_VERSION_REPLY + if (request->bmRequestType == 0xc0 && request->bRequest == 0x02 && + request->wValue == 0 && request->wIndex == 0) { + if (stage == CONTROL_STAGE_SETUP) { + ++version_requests; + log_packet("VERSION_REPLY", 0, 2, probe_version_reply, sizeof(probe_version_reply)); + return tud_control_xfer(rhport, request, (void*)probe_version_reply, + sizeof(probe_version_reply)); + } + return true; + } +#endif +#ifdef SWITCH2_PROBE_ACK_SETUP04 + // Exact control-transfer contract observed on the console and on two + // genuine controllers. The meaning of 0x0276 is unresolved: do not infer + // UART baud, USB speed, or any flash operation from it. + if (request->bmRequestType == 0x40 && request->bRequest == 0x04 && + request->wValue == 0x0276 && request->wIndex == 0 && request->wLength == 0) { + if (stage == CONTROL_STAGE_SETUP) + return tud_control_status(rhport, request); + if (stage == CONTROL_STAGE_ACK) { + ++setup_completions; + probe_debug_printf("[PROBE] SETUP04 acknowledged value=%04x (parameter semantics unresolved)\n", + request->wValue); + } + return true; + } +#endif + return false; +} + +void tud_mount_cb(void) { +#ifdef SWITCH2_PROBE_USB_INIT + reset_protocol(); +#endif + probe_debug_printf("[PROBE] MOUNT\n"); +} +void tud_umount_cb(void) { +#ifdef SWITCH2_PROBE_USB_INIT + reset_protocol(); +#endif + probe_debug_printf("[PROBE] UNMOUNT\n"); +} +void tud_suspend_cb(bool remote_wakeup_en) { + probe_debug_printf("[PROBE] SUSPEND wake=%u\n", remote_wakeup_en); +#ifdef SWITCH2_PROBE_USB_INIT +#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE + (void)probe_button_update(&button_test, -1, false); +#endif + protocol.test_rail_buttons = false; + protocol.controller_active = false; +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + probe_controller_input_set_native_stream(false); + native_stream_ready = false; + hid_completion_seen = false; +#endif +#endif +} +void tud_resume_cb(void) { probe_debug_printf("[PROBE] RESUME\n"); } + +int main(void) { +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + probe_controller_input_clock_init(); +#endif + stdio_init_all(); +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + probe_debug_printf("\n[PROBE] Joy-Con 2 (R) Bluetooth-to-USB controller/native mouse bridge\n"); +#else + probe_debug_printf("\n[PROBE] Joy-Con 2 (R) USB enumeration recorder\n"); +#endif +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + probe_controller_input_init(); + probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; selected right Joy-Con Bluetooth source enabled\n"); +#else + probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; Bluetooth disabled\n"); +#endif +#ifdef SWITCH2_PROBE_IDENTITY_REPLY + probe_debug_printf("[PROBE] Configured factory-format identity reply enabled for vendor read 03\n"); +#else + probe_debug_printf("[PROBE] Factory identity reply disabled\n"); +#endif +#ifdef SWITCH2_PROBE_VERSION_REPLY + probe_debug_printf("[PROBE] Captured firmware version with configured controller address enabled\n"); +#endif +#ifdef SWITCH2_PROBE_ACK_SETUP04 + probe_debug_printf("[PROBE] Observed vendor-04 setup acknowledgement enabled\n"); +#endif +#ifdef SWITCH2_PROBE_USB_INIT + reset_protocol(); + probe_debug_printf("[PROBE] Own virtual pairing storage offset=%08" PRIx32 "\n", + probe_storage_offset()); +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + probe_debug_printf("[PROBE] Live right Joy-Con buttons/stick/native mouse; hold BOOTSEL 2s for Bluetooth pairing (never clears pairings)\n"); +#else + probe_debug_printf("[PROBE] Manual input test: hold BOOTSEL for SL+SR, release for neutral; no controller forwarding\n"); +#endif +#else + probe_debug_printf("[PROBE] Bulk commands are capture-only; no input reports\n"); +#endif +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + if (!probe_controller_input_start()) + panic("Bluetooth source could not establish multicore flash coordination"); +#endif + tud_init(0); + uint32_t last_heartbeat = 0; + while (true) { + tud_task(); + drain_log(); + const uint32_t now = to_ms_since_boot(get_absolute_time()); +#ifdef SWITCH2_PROBE_USB_INIT +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + if (probe_controller_input_pairing_task()) + probe_debug_printf("[PROBE] Bluetooth pairing window requested; put the right Joy-Con in SYNC pairing mode\n"); + controller_input_task(now); +#else + button_test_task(now); +#endif + protocol_task(now); +#endif + if (now - last_heartbeat >= 1000) { + last_heartbeat = now; + probe_debug_printf("[PROBE %" PRIu32 "] alive mounted=%u bulk=%" PRIu32 + " hid=%" PRIu32 " identity=%" PRIu32 + " version=%" PRIu32 " setup=%" PRIu32 + " inputs=%" PRIu32 " command_drops=%" PRIu32 + " log_dropped_bytes=%" PRIu32 "\n", + now, tud_mounted(), bulk_packets, hid_packets, + identity_requests, version_requests, setup_completions, + input_reports, command_drops, log_dropped); +#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE + if (mouse_delivered_reports != mouse_logged_reports) { + mouse_logged_reports = mouse_delivered_reports; + probe_debug_printf("[PROBE] MOUSE_REPORT delivered packets=%" PRIu32 + " total_x=%" PRId64 " total_y=%" PRId64 "\n", + mouse_delivered_reports, mouse_delivered_x, mouse_delivered_y); + } +#endif + } + sleep_us(100); + } +} diff --git a/tools/switch2_usb_probe/mbedtls_config.h b/tools/switch2_usb_probe/mbedtls_config.h new file mode 100644 index 0000000..b87c995 --- /dev/null +++ b/tools/switch2_usb_probe/mbedtls_config.h @@ -0,0 +1,5 @@ +#pragma once + +// Pairing uses one AES-128 ECB block; no TLS, entropy service, or heap-backed cipher API. +#define MBEDTLS_AES_C +#define MBEDTLS_AES_ROM_TABLES diff --git a/tools/switch2_usb_probe/memory.c b/tools/switch2_usb_probe/memory.c new file mode 100644 index 0000000..4a1794b --- /dev/null +++ b/tools/switch2_usb_probe/memory.c @@ -0,0 +1,57 @@ +#include "memory.h" +#include "probe_memory_data.h" +#include + +_Static_assert(sizeof(probe_factory_memory) == 8192, "factory capture size"); +_Static_assert(sizeof(probe_user_calibration) == 4096, "user calibration capture size"); + +bool probe_memory_read(uint32_t address, uint8_t* output, size_t length) { + if (!output) return false; + const uint8_t* source; + size_t offset, available; + if (address >= 0x13000 && address < 0x15000) { + offset = address - 0x13000; + source = probe_factory_memory; + available = sizeof(probe_factory_memory) - offset; + } else if (address >= 0x1fc000 && address < 0x1fd000) { + offset = address - 0x1fc000; + source = probe_user_calibration; + available = sizeof(probe_user_calibration) - offset; + } else { + return false; // No fabricated erased bytes, pairing keys, or firmware reads. + } + if (length > available) return false; + memcpy(output, source + offset, length); + return true; +} + +static void unpack_pair(const uint8_t* data, uint16_t values[2]) { + values[0] = data[0] | ((uint16_t)(data[1] & 15) << 8); + values[1] = (data[1] >> 4) | ((uint16_t)data[2] << 4); +} + +static bool valid_calibration(const uint8_t* data) { + uint16_t center[2], positive[2], negative[2]; + unpack_pair(data, center); + unpack_pair(data + 3, positive); + unpack_pair(data + 6, negative); + // Same bounds as the existing Bluepad32 Switch 2 calibration decoder. + for (unsigned i = 0; i < 2; ++i) { + if (!center[i] || center[i] == 4095 || !positive[i] || !negative[i] || + positive[i] > 4095 - center[i] || negative[i] > center[i]) return false; + } + return true; +} + +bool probe_memory_right_stick_center(uint8_t output[3]) { + if (!output) return false; + // A solo Joy-Con uses the primary calibration record, even for the right + // controller. User magic precedes its 9-byte record; factory has no magic. + const uint8_t* selected = probe_factory_memory + 0xa8; + const uint8_t* user = probe_user_calibration + 0x40; + if (user[0] == 0xb2 && user[1] == 0xa1 && valid_calibration(user + 2)) + selected = user + 2; + if (!valid_calibration(selected)) return false; + memcpy(output, selected, 3); + return true; +} diff --git a/tools/switch2_usb_probe/memory.h b/tools/switch2_usb_probe/memory.h new file mode 100644 index 0000000..f2d5882 --- /dev/null +++ b/tools/switch2_usb_probe/memory.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include +#include + +bool probe_memory_read(uint32_t address, uint8_t* output, size_t length); +bool probe_memory_right_stick_center(uint8_t output[3]); diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake new file mode 100644 index 0000000..47b7358 --- /dev/null +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -0,0 +1,139 @@ +# Shared by the standalone USB probe and the Bluetooth-backed root target. +# Include before pico_sdk_init() so every mbedTLS source uses the same config. +set(SWITCH2_USB_PROBE_DIR "${CMAKE_CURRENT_LIST_DIR}") +set(PICO_MBEDTLS_CONFIG_FILE "${SWITCH2_USB_PROBE_DIR}/mbedtls_config.h") + +function(switch2_usb_probe_configure target) + set(probe_sources + ${SWITCH2_USB_PROBE_DIR}/main.c + ${SWITCH2_USB_PROBE_DIR}/protocol.c + ${SWITCH2_USB_PROBE_DIR}/storage.cpp + ${SWITCH2_USB_PROBE_DIR}/button_test.c) + target_compile_features(${target} PRIVATE c_std_11 cxx_std_17) + target_include_directories(${target} PRIVATE + ${SWITCH2_USB_PROBE_DIR} + ${SWITCH2_USB_PROBE_DIR}/../../src/firmware + ${CMAKE_CURRENT_BINARY_DIR} + ${PICO_SDK_PATH}/src/rp2_common/pico_btstack/include + ${PICO_SDK_PATH}/lib/btstack/platform/embedded) + target_compile_definitions(${target} PRIVATE + CFG_TUSB_CONFIG_FILE="${SWITCH2_USB_PROBE_DIR}/tusb_config.h") + + set(SWITCH2_PROBE_IDENTITY_FILE "" CACHE FILEPATH "64-byte Joy-Con 2 (R) factory-format identity block") + if(SWITCH2_PROBE_IDENTITY_FILE) + file(READ "${SWITCH2_PROBE_IDENTITY_FILE}" identity_hex LIMIT 65 HEX) + string(LENGTH "${identity_hex}" identity_length) + if(NOT identity_length EQUAL 128) + message(FATAL_ERROR "Identity capture must contain exactly 64 bytes") + endif() + string(TOLOWER "${identity_hex}" identity_hex) + string(SUBSTRING "${identity_hex}" 36 8 identity_vid_pid) + if(NOT identity_vid_pid STREQUAL "7e056620") + message(FATAL_ERROR "Identity capture must match Joy-Con 2 (R), 057e:2066") + endif() + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," identity_bytes "${identity_hex}") + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/probe_identity.h" + "// Generated from a private, read-only controller capture; do not commit.\n#include \nstatic const uint8_t probe_identity_reply[64] = {${identity_bytes}};\n") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SWITCH2_PROBE_IDENTITY_FILE}") + target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_IDENTITY_REPLY=1) + endif() + set(SWITCH2_PROBE_VERSION_FILE "" CACHE FILEPATH "Captured 12-byte Joy-Con 2 (R) firmware-version reply") + set(SWITCH2_PROBE_CONTROLLER_ADDRESS "" CACHE STRING "Advertised controller address (captured or distinct virtual identity)") + if(SWITCH2_PROBE_VERSION_FILE) + if(NOT SWITCH2_PROBE_IDENTITY_FILE) + message(FATAL_ERROR "Version response requires the matching identity capture") + endif() + file(READ "${SWITCH2_PROBE_VERSION_FILE}" version_hex LIMIT 13 HEX) + string(LENGTH "${version_hex}" version_length) + if(NOT version_length EQUAL 24) + message(FATAL_ERROR "Firmware version capture must contain exactly 12 bytes") + endif() + string(TOLOWER "${version_hex}" version_hex) + string(SUBSTRING "${version_hex}" 6 2 firmware_type) + if(NOT firmware_type STREQUAL "01") + message(FATAL_ERROR "Firmware version capture must describe Joy-Con 2 (R)") + endif() + string(REPLACE ":" "" address_hex "${SWITCH2_PROBE_CONTROLLER_ADDRESS}") + string(TOLOWER "${address_hex}" address_hex) + string(LENGTH "${address_hex}" address_length) + if(NOT address_length EQUAL 12 OR NOT address_hex MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR "Provide a six-byte advertised controller Bluetooth address") + endif() + set(address_reversed "") + foreach(byte RANGE 0 5) + math(EXPR position "10 - 2 * ${byte}") + string(SUBSTRING "${address_hex}" ${position} 2 octet) + string(APPEND address_reversed "${octet}") + endforeach() + string(SUBSTRING "${version_hex}" 0 6 main_version) + string(SUBSTRING "${version_hex}" 8 6 bluetooth_version) + # Layout corroborated against two genuine USB vendor-02 responses and their + # matching command-10 version and command-15 address responses. + set(status_hex "${main_version}000000${bluetooth_version}00${address_reversed}") + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," status_bytes "${status_hex}") + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," firmware_bytes "${version_hex}") + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/probe_version.h" + "// Generated from private controller captures; do not commit.\n#include \nstatic const uint8_t probe_version_reply[16] = {${status_bytes}};\nstatic const uint8_t probe_firmware_version[12] = {${firmware_bytes}};\n") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SWITCH2_PROBE_VERSION_FILE}") + target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_VERSION_REPLY=1) + endif() + option(SWITCH2_PROBE_ACK_SETUP04 "Acknowledge the observed vendor-04 setup transaction" OFF) + if(SWITCH2_PROBE_ACK_SETUP04) + if(NOT SWITCH2_PROBE_IDENTITY_FILE OR NOT SWITCH2_PROBE_VERSION_FILE) + message(FATAL_ERROR "Setup acknowledgement requires both verified controller replies") + endif() + target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_ACK_SETUP04=1) + endif() + option(SWITCH2_PROBE_USB_INIT "Implement documented USB init and stream neutral diagnostic reports" OFF) + if(SWITCH2_PROBE_USB_INIT) + if(NOT SWITCH2_PROBE_ACK_SETUP04) + message(FATAL_ERROR "USB initialization probe requires the verified setup acknowledgement") + endif() + target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_USB_INIT=1) + endif() + set(SWITCH2_PROBE_FACTORY_FILE "" CACHE FILEPATH "8192-byte captured factory region with configured virtual identity") + set(SWITCH2_PROBE_USER_CALIBRATION_FILE "" CACHE FILEPATH "4096-byte captured user calibration region") + if(SWITCH2_PROBE_FACTORY_FILE OR SWITCH2_PROBE_USER_CALIBRATION_FILE) + if(NOT SWITCH2_PROBE_USB_INIT OR NOT SWITCH2_PROBE_FACTORY_FILE OR NOT SWITCH2_PROBE_USER_CALIBRATION_FILE) + message(FATAL_ERROR "Memory replies require initialized USB and both calibration captures") + endif() + file(READ "${SWITCH2_PROBE_FACTORY_FILE}" factory_hex LIMIT 8193 HEX) + file(READ "${SWITCH2_PROBE_USER_CALIBRATION_FILE}" user_calibration_hex LIMIT 4097 HEX) + string(LENGTH "${factory_hex}" factory_length) + string(LENGTH "${user_calibration_hex}" user_calibration_length) + if(NOT factory_length EQUAL 16384 OR NOT user_calibration_length EQUAL 8192) + message(FATAL_ERROR "Factory/user captures must contain exactly 8192/4096 bytes") + endif() + string(SUBSTRING "${factory_hex}" 0 128 factory_identity_hex) + if(NOT factory_identity_hex STREQUAL identity_hex) + message(FATAL_ERROR "Factory memory identity must match the vendor-control identity") + endif() + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," factory_bytes "${factory_hex}") + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," user_calibration_bytes "${user_calibration_hex}") + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/probe_memory_data.h" + "// Generated from private calibration captures; do not commit.\n#include \nstatic const uint8_t probe_factory_memory[8192] = {${factory_bytes}};\nstatic const uint8_t probe_user_calibration[4096] = {${user_calibration_bytes}};\n") + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${SWITCH2_PROBE_FACTORY_FILE}" "${SWITCH2_PROBE_USER_CALIBRATION_FILE}") + list(APPEND probe_sources ${SWITCH2_USB_PROBE_DIR}/memory.c) + target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_MEMORY=1) + endif() + target_sources(${target} PRIVATE ${probe_sources}) + set(probe_compile_options -Wall -Wextra -Werror) + if(SWITCH_PICO_SWITCH2_USB_BRIDGE) + # Preserve the root firmware's warning policy outside the probe sources. + set_source_files_properties(${probe_sources} PROPERTIES + COMPILE_OPTIONS "${probe_compile_options}") + else() + target_compile_options(${target} PRIVATE ${probe_compile_options}) + endif() + target_link_libraries(${target} PRIVATE pico_stdlib hardware_uart hardware_sync + hardware_flash pico_flash pico_mbedtls_crypto pico_mbedtls_headers tinyusb_device) + pico_enable_stdio_usb(${target} 0) + pico_enable_stdio_uart(${target} 1) + if(SWITCH_PICO_SWITCH2_USB_BRIDGE) + pico_set_program_name(${target} "Switch 2 right Joy-Con Bluetooth bridge") + else() + pico_set_program_name(${target} "Switch 2 USB initialization capture") + endif() + pico_set_program_version(${target} "0.24") +endfunction() diff --git a/tools/switch2_usb_probe/protocol.c b/tools/switch2_usb_probe/protocol.c new file mode 100644 index 0000000..211804d --- /dev/null +++ b/tools/switch2_usb_probe/protocol.c @@ -0,0 +1,374 @@ +#include "protocol.h" +#include "mbedtls/aes.h" +#include + +// Wire contracts: ndeadly/switch2_controller_research commands.md (03/0D, +// 03/0A, 07/01, 09/01-08, 16/01, 15/01-04) and hid_reports.md (05/08). USB reply headers +// and status payloads match captures/usb/rumble-procon-gccon.pcapng.gz. +// This public component is not a pairing key. The host supplies the other half. +static const uint8_t device_key_component[16] = { + 0x5c, 0xf6, 0xee, 0x79, 0x2c, 0xdf, 0x05, 0xe1, + 0xba, 0x2b, 0x63, 0x25, 0xc4, 0x1a, 0x5f, 0x10, +}; + +// Identical 11/03 payload in five genuine Joy-Con BLE captures; also present +// in the GameCube USB capture. Full semantics remain undocumented. +static const uint8_t joycon_info11_03[] = { + 0x01, 0x20, 0x03, 0x00, 0x00, 0x0a, 0xe8, 0x1c, + 0x3b, 0x79, 0x7d, 0x8b, 0x3a, 0x0a, 0xe8, 0x9c, + 0x42, 0x58, 0xa0, 0x0b, 0x42, 0x0a, 0xe8, 0x9c, + 0x41, 0x58, 0xa0, 0x0b, 0x41, +}; + +static void clear_pending_pairing(probe_protocol_state* state) { + state->pending_host_count = 0; + memset(state->pending_host_addresses, 0, sizeof(state->pending_host_addresses)); + state->pending_key_valid = false; + memset(state->pending_key, 0, sizeof(state->pending_key)); + state->challenge_confirmed = false; +} + +static const uint8_t* pairing_key_for_context(const probe_protocol_state* state) { + if (!state->pending_host_count) return NULL; + if (state->pending_key_valid) return state->pending_key; + // Host addresses are key associations: order and subset size may change, + // but no new host may borrow the committed key without its own exchange. + for (unsigned i = 0; i < state->pending_host_count; ++i) { + bool stored = false; + for (unsigned j = 0; j < state->committed_host_count; ++j) { + if (memcmp(state->pending_host_addresses[i], + state->committed_host_addresses[j], 6) == 0) { + stored = true; + break; + } + } + if (!stored) return NULL; + } + return state->committed_key; +} + +static bool challenge_response(const uint8_t* key, const uint8_t* wire_challenge, + uint8_t* response) { + uint8_t challenge[16]; + for (unsigned i = 0; i < sizeof(challenge); ++i) { + challenge[i] = wire_challenge[sizeof(challenge) - 1u - i]; + } + mbedtls_aes_context aes; + mbedtls_aes_init(&aes); + int result = mbedtls_aes_setkey_enc(&aes, key, 128); + if (result == 0) { + // Genuine challenge capture: reverse key/input, but NOT ciphertext. + result = mbedtls_aes_crypt_ecb(&aes, MBEDTLS_AES_ENCRYPT, challenge, response); + } + mbedtls_aes_free(&aes); + return result == 0; +} + +static bool finalize_pairing(probe_protocol_state* state, const uint8_t* key) { + uint8_t blob[PROBE_PAIRING_BLOB_SIZE] = {0}; + memcpy(blob, state->controller_address, sizeof(state->controller_address)); + blob[6] = state->pending_host_count; + memcpy(blob + 7, state->pending_host_addresses, 6u * state->pending_host_count); + memcpy(blob + sizeof(blob) - 16u, key, 16); + // Preserve both the old committed key and pending retry on any save failure. + if (!state->save_pairing(blob, sizeof(blob))) return false; + state->committed_host_count = blob[6]; + memcpy(state->committed_host_addresses, blob + 7, sizeof(state->committed_host_addresses)); + memcpy(state->committed_key, blob + sizeof(blob) - 16u, sizeof(state->committed_key)); + state->pending_key_valid = false; + memset(state->pending_key, 0, sizeof(state->pending_key)); + // Keep this context confirmed so a repeated finalize can safely retry its ACK. + return true; +} + +void probe_protocol_reset(probe_protocol_state* state) { + memset(state, 0, sizeof(*state)); + state->report_id = 0x08; + state->right_stick_center[1] = 0x08; + state->right_stick_center[2] = 0x80; +} + +bool probe_protocol_restore_pairing(probe_protocol_state* state, + const uint8_t* blob, size_t length) { + if (!state || !blob || length != PROBE_PAIRING_BLOB_SIZE || + memcmp(state->controller_address, blob, sizeof(state->controller_address)) != 0 || + blob[6] == 0 || blob[6] > PROBE_HOST_MAX_ADDRESSES) return false; + for (size_t i = 7u + 6u * blob[6]; i < length - 16u; ++i) { + if (blob[i] != 0) return false; + } + // Validate the whole record before changing either committed or pending state. + state->committed_host_count = blob[6]; + memcpy(state->committed_host_addresses, blob + 7, sizeof(state->committed_host_addresses)); + memcpy(state->committed_key, blob + length - 16u, sizeof(state->committed_key)); + clear_pending_pairing(state); + return true; +} + +size_t probe_protocol_command(probe_protocol_state* state, const uint8_t* command, + size_t length, uint8_t* reply, size_t capacity, + uint64_t* deferred_token) { + if (deferred_token) *deferred_token = 0; + if (!state || !command || !reply || length < 8 || + command[1] != 0x91 || command[2] != 0 || + command[4] != 0 || command[6] != 0 || command[7] != 0 || + length != 8u + command[5]) return 0; + const bool initialize = command[0] == 0x03 && command[3] == 0x0d; + const bool select_report = command[0] == 0x03 && command[3] == 0x0a; + const bool status_query = (command[0] == 0x07 || command[0] == 0x16) && command[3] == 1; + const bool exchange_addresses = command[0] == 0x15 && command[3] == 1; + const bool confirm_key = command[0] == 0x15 && command[3] == 2; + const bool finalize = command[0] == 0x15 && command[3] == 3; + const bool exchange_keys = command[0] == 0x15 && command[3] == 4; + const bool power07 = command[0] == 0x0b && command[3] == 0x07; + const bool player_leds = command[0] == 0x09 && command[3] >= 1 && command[3] <= 8; + const bool features = command[0] == 0x0c && command[3] >= 1 && command[3] <= 5; + const bool memory_read = command[0] == 0x02 && (command[3] == 1 || command[3] == 4); + const bool info11_03 = command[0] == 0x11 && command[3] == 3; + const bool info11_01 = command[0] == 0x11 && command[3] == 1; + const bool vibration_setup = command[0] == 0x0a && command[3] == 8; + const bool vibration_sample = command[0] == 0x0a && command[3] == 2; + const bool joycon_query = command[0] == 0x13 && command[3] >= 1 && command[3] <= 3; + const bool firmware_info = command[0] == 0x10 && command[3] == 1; + const bool nfc_info = command[0] == 0x01 && command[3] == 0x0c; + const bool runtime_toggle = command[0] == 0x03 && command[3] == 0x0c; + uint32_t memory_address = 0; + uint8_t memory_length = 0; + size_t reply_length; + const uint8_t* pairing_key = NULL; + if (initialize) { + if (length != 16 || command[8] != 1 || command[9] != 0) return 0; + reply_length = 12; + } else if (select_report) { + if (length != 12) return 0; + reply_length = 8; + } else if (status_query) { + if (length != 8) return 0; + reply_length = command[0] == 0x07 ? 9 : 32; + } else if (exchange_addresses) { + if (length != 8 && + (length < 10 || command[8] != 0 || + command[9] > PROBE_HOST_MAX_ADDRESSES || + length != 10u + 6u * command[9])) return 0; + reply_length = 17; + } else if (exchange_keys) { + if (length != 25 || command[8] != 0 || !state->pending_host_count) return 0; + reply_length = 25; + } else if (confirm_key) { + if (length != 25 || command[8] != 0) return 0; + pairing_key = pairing_key_for_context(state); + if (!pairing_key) return 0; + reply_length = 25; + } else if (finalize) { + if (length != 9 || command[8] != 0 || !state->challenge_confirmed || + !state->save_pairing) return 0; + pairing_key = pairing_key_for_context(state); + if (!pairing_key) return 0; + reply_length = 9; + } else if (power07) { + // Donor BLE capture: 0b 01 01 07 10 78 00 00. Apply the previously + // corroborated USB header mapping. Semantics remain unknown: only the + // console's observed four-zero argument has been queried on the donor. + if (length != 12 || command[8] || command[9] || command[10] || command[11]) return 0; + reply_length = 8; + } else if (player_leds) { + if (command[3] <= 6) { + if (length != 8) return 0; + } else { + // Current console sends four payload bytes; published captures use + // eight. Only the first byte carries the mask/flashing setting. + if (length != 12 && length != 16) return 0; + if (command[3] == 8 && command[8] > 1) return 0; + } + reply_length = 8; + } else if (features) { + if (length != 12) return 0; + reply_length = command[3] == 1 ? 20 : 12; + } else if (memory_read) { + if (length != 16 || !state->read_memory || command[10] || command[11]) return 0; + if (command[3] == 1) { + if (command[8] || command[9]) return 0; + memory_length = 64; + } else { + if (command[9] != 0x7e || command[8] > 80) return 0; + memory_length = command[8]; + } + for (unsigned i = 0; i < 4; ++i) + memory_address |= (uint32_t)command[12 + i] << (8 * i); + reply_length = 16u + memory_length; + } else if (info11_03 || info11_01) { + if (length != 8) return 0; + reply_length = info11_03 ? 8 + sizeof(joycon_info11_03) : 12; + } else if (vibration_setup) { + // Five genuine Joy-Con traces acknowledge this 20-byte parameter block + // with no response payload. Its first byte is consistently 1. + if (length != 28 || command[8] != 1) return 0; + reply_length = 8; + } else if (vibration_sample) { + if (length != 12 || command[8] > 7 || + command[9] || command[10] || command[11] || + !state->play_sample || !deferred_token) return 0; + reply_length = 8; + } else if (joycon_query) { + if (length != 8) return 0; + reply_length = command[3] == 1 ? 12 : 16; + } else if (firmware_info) { + if (length != 8 || !state->firmware_version) return 0; + reply_length = 20; + } else if (nfc_info) { + if (length != 8) return 0; + reply_length = 12; + } else if (runtime_toggle) { + if (length != 12 || command[8] > 1 || command[9] || command[10] || command[11]) return 0; + reply_length = 8; + } else { + return 0; + } + if (capacity < reply_length) return 0; + if (vibration_sample) { + uint64_t token = 0; + if (!state->play_sample(command[8], &token) || !token) return 0; + *deferred_token = token; + } + uint8_t encrypted_challenge[16]; + if (confirm_key && !challenge_response(pairing_key, command + 9, encrypted_challenge)) return 0; + if (finalize && !finalize_pairing(state, pairing_key)) return 0; + if (memory_read && + !state->read_memory(memory_address, reply + 16, memory_length)) return 0; + const uint8_t header[] = {command[0], 0x01, 0, command[3], 0, 0xf8, 0, 0}; + memcpy(reply, header, sizeof(header)); + if (info11_03) { + memcpy(reply + 8, joycon_info11_03, sizeof(joycon_info11_03)); + } else if (firmware_info) { + memcpy(reply + 8, state->firmware_version, 12); + } else if (nfc_info) { + // Identical in five Joy-Con captures; Pro's last byte differs. + const uint8_t info[] = {0x61, 0x12, 0x50, 0x0d}; + memcpy(reply + 8, info, sizeof(info)); + } else { + // Memory payload was supplied directly into the reply; initialize only its metadata. + memset(reply + 8, 0, memory_read ? 8 : reply_length - 8); + } + if (initialize) { + memcpy(state->host_address, command + 10, sizeof(state->host_address)); + state->initialized = true; + // Retransmission is idempotent: do not reset an already-running counter. + reply[8] = 1; + } else if (select_report) { + // The real controller acknowledges but ignores unsupported report IDs. + if (command[8] == 0x05 || command[8] == 0x08) state->report_id = command[8]; + } else if (exchange_addresses) { + if (length != 8) { + clear_pending_pairing(state); + state->pending_host_count = command[9]; + memcpy(state->pending_host_addresses, command + 10, 6u * command[9]); + } + reply[8] = 1; + reply[9] = 4; // Observed address-response field; semantics unresolved. + reply[10] = 1; + memcpy(reply + 11, state->controller_address, sizeof(state->controller_address)); + } else if (exchange_keys) { + for (unsigned i = 0; i < sizeof(state->pending_key); ++i) { + const unsigned wire_index = sizeof(state->pending_key) - 1u - i; + state->pending_key[i] = command[9 + wire_index] ^ device_key_component[wire_index]; + } + state->pending_key_valid = true; + state->challenge_confirmed = false; + reply[8] = 1; + memcpy(reply + 9, device_key_component, sizeof(device_key_component)); + } else if (confirm_key) { + reply[8] = 1; + memcpy(reply + 9, encrypted_challenge, sizeof(encrypted_challenge)); + state->challenge_confirmed = true; + } else if (finalize) { + reply[8] = 1; + } else if (player_leds) { + if (command[3] <= 4) { + state->player_leds = (uint8_t)(1u << (command[3] - 1)); + } else if (command[3] == 5) { + state->player_leds = 0x0f; + } else if (command[3] == 6) { + state->player_leds = 0; + } else if (command[3] == 7) { + state->player_leds = command[8] & 0x0f; + } else { + state->player_leds_flashing = command[8] != 0; + } + } else if (features) { + const uint8_t flags = command[8] & 0xb7; // Bits 3 and 6 are unused. + if (command[3] == 1) { + // Published Joy-Con-specific feature-info encoding; first four + // response bytes and final two feature-info bytes remain zero. + reply[12] = flags & 0x01 ? 7 : 0; + reply[13] = flags & 0x02 ? 7 : 0; + reply[14] = flags & 0x04 ? 3 : 0; + reply[15] = flags & 0x80 ? 3 : 0; + reply[16] = flags & 0x10 ? 3 : 0; + reply[17] = flags & 0x20 ? 3 : 0; + } else if (command[3] == 2) { + state->feature_mask = flags; + state->enabled_features &= flags; + } else if (command[3] == 3) { + state->feature_mask = state->enabled_features = 0; + } else if (command[3] == 4) { + state->enabled_features |= flags & state->feature_mask; + } else { + state->enabled_features &= (uint8_t)~(flags & state->feature_mask); + } + } else if (memory_read) { + reply[8] = memory_length; + for (unsigned i = 0; i < 4; ++i) + reply[12 + i] = (uint8_t)(memory_address >> (8 * i)); + } else if (vibration_setup) { + memcpy(state->vibration_parameters, command + 8, sizeof(state->vibration_parameters)); + state->vibration_parameters_set = true; + } else if (info11_01) { + // Identical 01 00 00 00 payload in all five genuine Joy-Con captures. + reply[8] = 1; + } else if (joycon_query) { + // Published 13/01-03 replies: leading 1, then reserved zero bytes. + // These queries' full semantics are still undocumented. + reply[8] = 1; + } else if (runtime_toggle) { + state->runtime03_0c = command[8] != 0; + } + return reply_length; +} + +size_t probe_protocol_report(const probe_protocol_state* state, uint8_t report_id, + uint8_t* output, size_t capacity) { + if (!state || !state->initialized || !output || capacity < PROBE_INPUT_SIZE || + (report_id != 0x05 && report_id != 0x08)) return 0; + memset(output, 0, PROBE_INPUT_SIZE); + const bool buttons_enabled = (state->enabled_features & 1) != 0; + const uint8_t buttons0 = state->controller_active && buttons_enabled ? state->controller_buttons[0] : 0; + const uint8_t buttons1 = state->controller_active && buttons_enabled ? state->controller_buttons[1] & 0xd1 : 0; + const uint8_t* stick = state->controller_active && (state->enabled_features & 2) ? + state->controller_stick : state->right_stick_center; + if (report_id == 0x08) { + output[0] = (uint8_t)state->report_counter; + output[1] = 0x25; // Virtual full battery, external USB power. + output[2] = buttons0; + output[3] = buttons1; + if (state->test_rail_buttons && (state->enabled_features & 1)) + output[3] |= 0xc0; // Joy-Con R native SL + SR. + output[4] = 0x07; + memcpy(output + 5, stick, 3); + // Diagnostic snapshot only; complete live native packets bypass this generator. + } else { + for (unsigned i = 0; i < 4; ++i) output[i] = (uint8_t)(state->report_counter >> (8 * i)); + output[4] = (uint8_t)(((buttons0 & 0x03) << 2) | ((buttons0 & 0x0c) >> 2) | + ((buttons0 & 0x30) << 2) | ((buttons1 & 0xc0) >> 2)); + output[5] = (uint8_t)(((buttons0 & 0xc0) >> 5) | ((buttons1 & 0x01) << 4) | + ((buttons1 & 0x10) << 2)); + if (state->test_rail_buttons && (state->enabled_features & 1)) + output[4] |= 0x30; // Common report: right SL + SR. + output[11] = 0x08; + output[12] = 0x80; + memcpy(output + 13, stick, 3); + output[31] = 0xa0; + output[32] = 0x0f; // Virtual battery voltage 4000mV. + output[33] = 0x20; + output[41] = 1; + } + return PROBE_INPUT_SIZE; +} diff --git a/tools/switch2_usb_probe/protocol.h b/tools/switch2_usb_probe/protocol.h new file mode 100644 index 0000000..a0f5b0f --- /dev/null +++ b/tools/switch2_usb_probe/protocol.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include + +#define PROBE_COMMAND_MAX_SIZE 263u +#define PROBE_REPLY_MAX_SIZE 96u +#define PROBE_HOST_MAX_ADDRESSES ((255u - 2u) / 6u) +#define PROBE_PAIRING_BLOB_SIZE (6u + 1u + PROBE_HOST_MAX_ADDRESSES * 6u + 16u) +#define PROBE_INPUT_SIZE 63u + +typedef struct { + bool initialized; + uint8_t report_id; + bool test_rail_buttons; + bool runtime03_0c; // Observed USB toggle; full semantics remain unknown. + uint8_t right_stick_center[3]; + bool controller_active; + uint8_t controller_buttons[2]; // Native right Joy-Con button ordering. + uint8_t controller_stick[3]; // Raw packed 12-bit axes from the selected donor. + uint8_t player_leds; // Virtual four-LED mask, exposed through UART diagnostics. + bool player_leds_flashing; + // Negotiated virtual features; relative mouse events belong to the USB sender. + uint8_t feature_mask; + uint8_t enabled_features; + bool vibration_parameters_set; + uint8_t vibration_parameters[20]; // Captured 0A/08 format; no motor output. + uint8_t host_address[6]; + uint8_t controller_address[6]; // Own virtual identity, wire byte order (LE). + const uint8_t* firmware_version; // Twelve captured bytes; caller retains their lifetime. + // Every payload-bearing address exchange starts a fresh pending context. + uint8_t pending_host_count; + uint8_t pending_host_addresses[PROBE_HOST_MAX_ADDRESSES][6]; + bool pending_key_valid; + uint8_t pending_key[16]; // Standard AES byte order, not wire byte order. + bool challenge_confirmed; + // A zero count means no committed pairing record. + uint8_t committed_host_count; + uint8_t committed_host_addresses[PROBE_HOST_MAX_ADDRESSES][6]; + uint8_t committed_key[16]; // Standard AES byte order. + // Synchronous durable save; NULL disables successful finalization. + bool (*save_pairing)(const uint8_t* blob, size_t length); + bool (*read_memory)(uint32_t address, uint8_t* output, size_t length); + // Queue a physical sample, returning true only with a nonzero completion token. + // Acceptance is not a Bluetooth application ACK. + bool (*play_sample)(uint8_t sample_id, uint64_t* token); + uint32_t report_counter; +} probe_protocol_state; + +void probe_protocol_reset(probe_protocol_state* state); +// Blob: own address[6], count[1], zero-padded host addresses[42][6], AES key[16]. +// Rejects other identities, invalid counts/padding/lengths without mutation. +// A successful restore replaces the committed record and clears pending state. +bool probe_protocol_restore_pairing(probe_protocol_state* state, + const uint8_t* blob, size_t length); +// Complete command frames only. Unsupported/malformed commands return zero +// and do not mutate state. Pairing finalization requires a successful save. +// When non-NULL, deferred_token is cleared before validation. Synchronous replies +// leave it zero. Sample 0A/02 requires this output and play_sample; acceptance +// prepares an 8-byte reply and returns its nonzero source completion token. +// Those bytes MUST NOT be transmitted until that token has a genuine positive +// Bluetooth application ACK. Failure, cancellation or expiry must discard them. +size_t probe_protocol_command(probe_protocol_state* state, const uint8_t* command, + size_t length, uint8_t* reply, size_t capacity, + uint64_t* deferred_token); +// Button/stick snapshot without relative mouse events; safe for GET_REPORT. +size_t probe_protocol_report(const probe_protocol_state* state, uint8_t report_id, + uint8_t* output, size_t capacity); diff --git a/tools/switch2_usb_probe/storage.cpp b/tools/switch2_usb_probe/storage.cpp new file mode 100644 index 0000000..117298a --- /dev/null +++ b/tools/switch2_usb_probe/storage.cpp @@ -0,0 +1,373 @@ +#include "storage.h" + +#include + +#include "configuration/configuration_storage.h" +#include "hardware/flash.h" +#include "pico/btstack_flash_bank.h" +#include "pico/flash.h" +#include "pico/platform.h" +#include "profile/profile_storage.h" + +extern "C" char __flash_binary_end; + +namespace { + +constexpr size_t kSlotCount = 2; +constexpr size_t kMaximumPayloadSize = 512; +constexpr size_t kStorageSize = kSlotCount * FLASH_SECTOR_SIZE; +constexpr size_t kConfigurationStorageSize = + CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE; +constexpr size_t kConfigurationStorageOffset = + PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize; +constexpr size_t kProfileStorageOffset = + kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE; +constexpr uint32_t kStorageOffset = kProfileStorageOffset - kStorageSize; +constexpr uint32_t kFlashSafeTimeoutMs = 5000; +constexpr uint32_t kFormatVersion = 1; + +// Each sector owns one record. Integers are little-endian; all padding is ff. +// Page 0: magic[16], version:u32, absolute sector offset:u32, maximum payload:u32, +// page size:u32, sector size:u32, CRC32(bytes 0..35):u32, padding. +// Page 1 starts the body: magic[8], generation:u32, ~generation:u32, +// length:u32, payload CRC32:u32, header size:u32, header CRC32:u32, +// payload[length], padding to the fixed body-area boundary. +// The separate commit page is programmed LAST: magic[16], generation:u32, +// header CRC32:u32, payload CRC32:u32, length:u32, sector offset:u32, +// CRC32(bytes 0..35):u32, padding. The rest of the sector stays erased. +// CRC32 is the project's IEEE CRC32 (also compatible with zlib.crc32). +constexpr uint8_t kOwnerMagic[16] = { + 'S', '2', 'P', 'R', 'O', 'B', 'E', '-', + 'P', 'A', 'I', 'R', 'I', 'N', 'G', 0, +}; +constexpr uint8_t kBodyMagic[8] = {'S', '2', 'P', 'A', 'I', 'R', '0', '1'}; +constexpr uint8_t kCommitMagic[16] = { + 'S', '2', 'P', 'A', 'I', 'R', '-', 'C', + 'O', 'M', 'M', 'I', 'T', 'T', 'E', 'D', +}; +constexpr size_t kDescriptorCrcOffset = 36; +constexpr size_t kDescriptorSize = kDescriptorCrcOffset + sizeof(uint32_t); +constexpr size_t kBodyOffset = FLASH_PAGE_SIZE; +constexpr size_t kBodyHeaderCrcOffset = 28; +constexpr size_t kBodyHeaderSize = kBodyHeaderCrcOffset + sizeof(uint32_t); +constexpr size_t kBodySize = + ((kBodyHeaderSize + kMaximumPayloadSize + FLASH_PAGE_SIZE - 1) / + FLASH_PAGE_SIZE) * FLASH_PAGE_SIZE; +constexpr size_t kPayloadOffset = kBodyOffset + kBodyHeaderSize; +constexpr size_t kCommitOffset = kBodyOffset + kBodySize; +constexpr size_t kRecordFootprint = kCommitOffset + FLASH_PAGE_SIZE; + +static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE); +static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE); +static_assert(FLASH_SECTOR_SIZE % FLASH_PAGE_SIZE == 0); +static_assert(kDescriptorSize <= FLASH_PAGE_SIZE); +static_assert(kRecordFootprint <= FLASH_SECTOR_SIZE); +static_assert(PROFILE_STORAGE_TOTAL_SIZE % FLASH_SECTOR_SIZE == 0); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET % FLASH_SECTOR_SIZE == 0); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= + kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE + + kStorageSize, + "pairing storage offset underflows flash"); +static_assert(kStorageOffset + kStorageSize == kProfileStorageOffset); +static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE == + kConfigurationStorageOffset); +static_assert(kConfigurationStorageOffset + kConfigurationStorageSize == + PICO_FLASH_BANK_STORAGE_OFFSET); +static_assert(PICO_FLASH_BANK_STORAGE_OFFSET <= PICO_FLASH_SIZE_BYTES); +static_assert(PICO_FLASH_BANK_TOTAL_SIZE <= + PICO_FLASH_SIZE_BYTES - PICO_FLASH_BANK_STORAGE_OFFSET, + "BTstack storage exceeds flash"); + +// Avoid a large USB callback stack frame. All program sources, including any +// input originally backed by XIP, are staged before the first flash mutation. +alignas(FLASH_PAGE_SIZE) uint8_t staging[kRecordFootprint]; + +enum class SlotKind { Erased, Unknown, OwnedIncomplete, Committed }; + +struct Slot { + SlotKind kind; + const uint8_t *bytes; + uint32_t generation; + size_t size; +}; + +struct FlashMutation { + uint32_t offset; + const uint8_t *page; // Null means erase one sector. +}; + +uint32_t read_u32(const uint8_t *input) { + return static_cast(input[0]) | + (static_cast(input[1]) << 8) | + (static_cast(input[2]) << 16) | + (static_cast(input[3]) << 24); +} + +void write_u32(uint8_t *output, uint32_t value) { + output[0] = static_cast(value); + output[1] = static_cast(value >> 8); + output[2] = static_cast(value >> 16); + output[3] = static_cast(value >> 24); +} + +bool is_erased(const uint8_t *bytes, size_t size) { + for (size_t index = 0; index < size; ++index) { + if (bytes[index] != 0xff) { + return false; + } + } + return true; +} + +bool storage_region_available() { + const uintptr_t binary_end = reinterpret_cast(&__flash_binary_end); + return binary_end >= XIP_BASE && + binary_end - XIP_BASE <= kStorageOffset && + kStorageOffset % FLASH_SECTOR_SIZE == 0 && + kStorageOffset <= PICO_FLASH_SIZE_BYTES && + kStorageSize <= PICO_FLASH_SIZE_BYTES - kStorageOffset && + kStorageOffset + kStorageSize == kProfileStorageOffset; +} + +uint32_t slot_offset(size_t slot) { + return static_cast(kStorageOffset + slot * FLASH_SECTOR_SIZE); +} + +const uint8_t *slot_bytes(size_t slot) { + return reinterpret_cast(XIP_BASE + slot_offset(slot)); +} + +bool owner_valid(const uint8_t *bytes, uint32_t offset) { + return memcmp(bytes, kOwnerMagic, sizeof(kOwnerMagic)) == 0 && + read_u32(bytes + 16) == kFormatVersion && + read_u32(bytes + 20) == offset && + read_u32(bytes + 24) == kMaximumPayloadSize && + read_u32(bytes + 28) == FLASH_PAGE_SIZE && + read_u32(bytes + 32) == FLASH_SECTOR_SIZE && + read_u32(bytes + kDescriptorCrcOffset) == + configuration_crc32(bytes, kDescriptorCrcOffset) && + is_erased(bytes + kDescriptorSize, + FLASH_PAGE_SIZE - kDescriptorSize); +} + +bool body_valid(const uint8_t *bytes) { + const uint8_t *body = bytes + kBodyOffset; + const size_t size = read_u32(body + 16); + return memcmp(body, kBodyMagic, sizeof(kBodyMagic)) == 0 && + read_u32(body + 12) == ~read_u32(body + 8) && + size != 0 && size <= kMaximumPayloadSize && + read_u32(body + 24) == kBodyHeaderSize && + read_u32(body + kBodyHeaderCrcOffset) == + configuration_crc32(body, kBodyHeaderCrcOffset) && + read_u32(body + 20) == + configuration_crc32(bytes + kPayloadOffset, size) && + is_erased(bytes + kPayloadOffset + size, + kBodySize - kBodyHeaderSize - size); +} + +bool commit_valid(const uint8_t *bytes, uint32_t offset) { + const uint8_t *body = bytes + kBodyOffset; + const uint8_t *commit = bytes + kCommitOffset; + return memcmp(commit, kCommitMagic, sizeof(kCommitMagic)) == 0 && + read_u32(commit + 16) == read_u32(body + 8) && + read_u32(commit + 20) == read_u32(body + kBodyHeaderCrcOffset) && + read_u32(commit + 24) == read_u32(body + 20) && + read_u32(commit + 28) == read_u32(body + 16) && + read_u32(commit + 32) == offset && + read_u32(commit + kDescriptorCrcOffset) == + configuration_crc32(commit, kDescriptorCrcOffset) && + is_erased(commit + kDescriptorSize, + FLASH_PAGE_SIZE - kDescriptorSize); +} + +Slot inspect_slot(size_t index) { + const uint8_t *bytes = slot_bytes(index); + Slot slot{SlotKind::Unknown, bytes, 0, 0}; + if (!owner_valid(bytes, slot_offset(index))) { + if (is_erased(bytes, FLASH_SECTOR_SIZE)) { + slot.kind = SlotKind::Erased; + } + // A torn ownership page or erase is deliberately NOT guessed to be + // ours. Recovery may need an externally verified backup in that case. + return slot; + } + if (!is_erased(bytes + kRecordFootprint, + FLASH_SECTOR_SIZE - kRecordFootprint)) { + return slot; + } + // A complete ownership page plus an erased tail proves ownership of the + // bounded body/commit area, even if either subsequent write was interrupted. + slot.kind = SlotKind::OwnedIncomplete; + if (body_valid(bytes) && commit_valid(bytes, slot_offset(index))) { + slot.kind = SlotKind::Committed; + slot.generation = read_u32(bytes + kBodyOffset + 8); + slot.size = read_u32(bytes + kBodyOffset + 16); + } + return slot; +} + +bool newest_slot(const Slot (&slots)[kSlotCount], int *index) { + *index = -1; + for (size_t candidate = 0; candidate < kSlotCount; ++candidate) { + if (slots[candidate].kind != SlotKind::Committed) { + continue; + } + if (*index < 0) { + *index = static_cast(candidate); + continue; + } + const Slot ¤t = slots[*index]; + const Slot &next = slots[candidate]; + const uint32_t difference = next.generation - current.generation; + if (difference == 0) { + if (next.size != current.size || + memcmp(next.bytes + kPayloadOffset, + current.bytes + kPayloadOffset, next.size) != 0) { + return false; // Conflicting records with no ordering. + } + } else if (difference == 0x80000000u) { + return false; // Exactly half a generation cycle is ambiguous. + } else if (difference < 0x80000000u) { + *index = static_cast(candidate); + } + } + return true; +} + +void perform_flash_mutation(void *context) { + const auto *mutation = static_cast(context); + if (mutation->page == nullptr) { + flash_range_erase(mutation->offset, FLASH_SECTOR_SIZE); + } else { + flash_range_program(mutation->offset, mutation->page, FLASH_PAGE_SIZE); + } +} + +// The caller has classified BOTH sectors before permitting any erase. Only +// the inactive, explicitly owned sector is passed here; the active one survives. +bool erase_slot(size_t index) { + if (index >= kSlotCount || !storage_region_available()) { + return false; + } + FlashMutation mutation{slot_offset(index), nullptr}; + return flash_safe_execute(perform_flash_mutation, &mutation, + kFlashSafeTimeoutMs) == PICO_OK && + is_erased(slot_bytes(index), FLASH_SECTOR_SIZE); +} + +bool program_page(size_t index, size_t offset, const uint8_t *page) { + if (index >= kSlotCount || offset % FLASH_PAGE_SIZE != 0 || + offset > kRecordFootprint - FLASH_PAGE_SIZE || + !storage_region_available() || + !is_erased(slot_bytes(index) + offset, FLASH_PAGE_SIZE)) { + return false; + } + FlashMutation mutation{ + static_cast(slot_offset(index) + offset), page, + }; + return flash_safe_execute(perform_flash_mutation, &mutation, + kFlashSafeTimeoutMs) == PICO_OK && + memcmp(slot_bytes(index) + offset, page, FLASH_PAGE_SIZE) == 0; +} + +void prepare_record(size_t target, uint32_t generation, + const uint8_t *data, size_t size) { + memset(staging, 0xff, sizeof(staging)); + memcpy(staging, kOwnerMagic, sizeof(kOwnerMagic)); + write_u32(staging + 16, kFormatVersion); + write_u32(staging + 20, slot_offset(target)); + write_u32(staging + 24, kMaximumPayloadSize); + write_u32(staging + 28, FLASH_PAGE_SIZE); + write_u32(staging + 32, FLASH_SECTOR_SIZE); + write_u32(staging + kDescriptorCrcOffset, + configuration_crc32(staging, kDescriptorCrcOffset)); + + uint8_t *body = staging + kBodyOffset; + memcpy(body, kBodyMagic, sizeof(kBodyMagic)); + write_u32(body + 8, generation); + write_u32(body + 12, ~generation); + write_u32(body + 16, static_cast(size)); + memcpy(staging + kPayloadOffset, data, size); + const uint32_t payload_crc = configuration_crc32(staging + kPayloadOffset, size); + write_u32(body + 20, payload_crc); + write_u32(body + 24, kBodyHeaderSize); + const uint32_t header_crc = configuration_crc32(body, kBodyHeaderCrcOffset); + write_u32(body + kBodyHeaderCrcOffset, header_crc); + + uint8_t *commit = staging + kCommitOffset; + memcpy(commit, kCommitMagic, sizeof(kCommitMagic)); + write_u32(commit + 16, generation); + write_u32(commit + 20, header_crc); + write_u32(commit + 24, payload_crc); + write_u32(commit + 28, static_cast(size)); + write_u32(commit + 32, slot_offset(target)); + write_u32(commit + kDescriptorCrcOffset, + configuration_crc32(commit, kDescriptorCrcOffset)); +} + +} // namespace + +bool probe_storage_load(uint8_t *output, size_t size) { + if (output == nullptr || size == 0 || size > kMaximumPayloadSize || + !storage_region_available()) { + return false; + } + const Slot slots[kSlotCount] = {inspect_slot(0), inspect_slot(1)}; + int active; + if (!newest_slot(slots, &active) || active < 0 || slots[active].size != size) { + return false; + } + memcpy(output, slots[active].bytes + kPayloadOffset, size); + return true; +} + +bool probe_storage_save(const uint8_t *data, size_t size) { + if (data == nullptr || size == 0 || size > kMaximumPayloadSize || + !storage_region_available()) { + return false; + } + const Slot slots[kSlotCount] = {inspect_slot(0), inspect_slot(1)}; + if (slots[0].kind == SlotKind::Unknown || slots[1].kind == SlotKind::Unknown) { + return false; // Never erase through an unrecognized region. + } + int active; + if (!newest_slot(slots, &active)) { + return false; + } + if (active >= 0 && slots[active].size == size && + memcmp(slots[active].bytes + kPayloadOffset, data, size) == 0) { + return true; + } + const size_t target = active >= 0 + ? static_cast(active) ^ 1u + : (slots[0].kind == SlotKind::Erased ? 0u : 1u); + const uint32_t generation = active >= 0 ? slots[active].generation + 1u : 1u; + prepare_record(target, generation, data, size); + + if (slots[target].kind != SlotKind::Erased && !erase_slot(target)) { + return false; + } + if (!program_page(target, 0, staging)) { + return false; + } + for (size_t offset = kBodyOffset; offset < kCommitOffset; + offset += FLASH_PAGE_SIZE) { + if (!is_erased(staging + offset, FLASH_PAGE_SIZE) && + !program_page(target, offset, staging + offset)) { + return false; + } + } + if (!body_valid(slot_bytes(target)) || + !program_page(target, kCommitOffset, staging + kCommitOffset)) { + return false; + } + const Slot committed = inspect_slot(target); + return committed.kind == SlotKind::Committed && + committed.generation == generation && committed.size == size && + memcmp(committed.bytes + kPayloadOffset, + staging + kPayloadOffset, size) == 0; +} + +uint32_t probe_storage_offset(void) { + return kStorageOffset; +} diff --git a/tools/switch2_usb_probe/storage.h b/tools/switch2_usb_probe/storage.h new file mode 100644 index 0000000..f33422b --- /dev/null +++ b/tools/switch2_usb_probe/storage.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Synchronous, main-loop-only API for the single-core probe. Serialize calls. +// Blobs are opaque, nonempty, and at most 512 bytes. Load requires an exact +// length match and leaves output unchanged on failure; it never writes flash. +bool probe_storage_load(uint8_t *output, size_t size); + +// Success means an identical blob was already committed, or a replacement was +// committed and read back. Failure never authorizes a protocol acknowledgement. +bool probe_storage_save(const uint8_t *data, size_t size); + +// Flash-relative offset of the two sectors immediately below profile storage. +uint32_t probe_storage_offset(void); + +#ifdef __cplusplus +} +#endif diff --git a/tools/switch2_usb_probe/tusb_config.h b/tools/switch2_usb_probe/tusb_config.h new file mode 100644 index 0000000..e087f20 --- /dev/null +++ b/tools/switch2_usb_probe/tusb_config.h @@ -0,0 +1,31 @@ +#pragma once + +#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED) +#ifndef CFG_TUSB_OS +#define CFG_TUSB_OS OPT_OS_NONE +#endif +#define CFG_TUD_ENDPOINT0_SIZE 64 +#define CFG_TUD_HID 1 +#define CFG_TUD_HID_EP_BUFSIZE 64 +#define CFG_TUD_CDC 0 +#define CFG_TUD_MSC 0 +#define CFG_TUD_MIDI 0 +#define CFG_TUD_VENDOR 1 +#define CFG_TUD_VENDOR_EPSIZE 64 +#define CFG_TUD_VENDOR_RX_BUFSIZE 256 +#define CFG_TUD_VENDOR_TX_BUFSIZE 256 +#ifdef CFG_TUSB_DEBUG +#undef CFG_TUSB_DEBUG +#endif +// Packet-level SDK logging would overflow 115200 baud while streaming input. +// Requests, command payloads, errors and aggregate counters are logged explicitly. +#define CFG_TUSB_DEBUG 1 +#define CFG_TUSB_DEBUG_PRINTF probe_debug_printf + +#ifdef __cplusplus +extern "C" { +#endif +int probe_debug_printf(const char* format, ...); +#ifdef __cplusplus +} +#endif