diff --git a/CMakeLists.txt b/CMakeLists.txt index 4df380a..961bb33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,11 +94,14 @@ endif() add_executable(switch-pico switch-pico.cpp switch_pro_driver.cpp + usb_output_driver.cpp switch_haptics.cpp ) if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") target_sources(switch-pico PRIVATE bluepad32_input_backend.cpp + adapter_host_probe.cpp + adapter_mode_controller.cpp controller_identity.cpp controller_profile.cpp controller_profile_transform.cpp @@ -114,19 +117,13 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") configuration_service.cpp pico_configuration_storage.cpp usb_configuration_management.cpp + xinput_driver.cpp + generic_hid_driver.cpp ) - if(SWITCH_PICO_ADAPTER_FEASIBILITY) - target_sources(switch-pico PRIVATE - adapter_host_probe.cpp - xinput_feasibility_driver.cpp - ) - target_compile_definitions(switch-pico PRIVATE - SWITCH_PICO_ADAPTER_FEASIBILITY=1 - ) - endif() target_compile_definitions(switch-pico PRIVATE SWITCH_PICO_BLUEPAD32=1 SWITCH_PICO_HID_INSTANCE_COUNT=4 + SWITCH_PICO_USB_OUTPUT_MODES=1 PICO_FLASH_ASSUME_CORE1_SAFE=0 ) else() @@ -161,9 +158,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") pico_multicore pico_flash ) - if(SWITCH_PICO_ADAPTER_FEASIBILITY) - target_link_libraries(switch-pico hardware_watchdog) - endif() + target_link_libraries(switch-pico hardware_watchdog) endif() if (SWITCH_PICO_LOG) diff --git a/README.md b/README.md index c730339..9e0dfa2 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,14 @@ To reproduce the validation: 6. **Verify rumble per slot**: Send rumble to interface 0 and confirm only the slot 0 controller vibrates. Send rumble to interface 1 and confirm only the slot 1 controller vibrates. 7. **Verify motion**: Enable gyro/accel on both controllers. Rotate each controller independently and confirm that motion is per-slot (rotating controller 0 does not affect controller 1's IMU output). +On macOS, inspect the firmware's raw Game Pad values before GameController or browser remapping with: + +```sh +swift tools/Test-SwitchPicoMac.swift +``` + +The diagnostic matches only `CAFE:4021`, identifies each of the four interfaces by interface and location, and prints changed axes, hats, and buttons with their HID usage and logical range. Move each analog trigger slowly and confirm that `Ry`/`Rz` report intermediate values across `0...65535`, rather than only the endpoints. It continues through hot-plug events until Ctrl-C. If opening a device fails, allow the terminal (or the app launching Swift) under **System Settings → Privacy & Security → Input Monitoring**, then rerun it. + On the tested Linux host, all four HID interfaces enumerated, but `hid-nintendo` timed out (`-110`) while requesting controller information from the composite device and removed the transient hidraw nodes. This is an observed, undiagnosed composite interoperability limitation; its root cause has not been established. The timeout was not observed on the Switch, so successful `hid-nintendo` binding is not the release criterion for the four-interface AIO firmware. The pairing CLI uses vendor control transfers on endpoint 0 and does not depend on those hidraw nodes. Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry Pi's BTstack license. diff --git a/adapter_configuration.cpp b/adapter_configuration.cpp index dc0aa61..f0aa7c2 100644 --- a/adapter_configuration.cpp +++ b/adapter_configuration.cpp @@ -1,43 +1,112 @@ #include "adapter_configuration.h" +namespace { + +bool pairing_window_valid(uint16_t pairing_window_seconds) { + return pairing_window_seconds >= ADAPTER_PAIRING_WINDOW_SECONDS_MIN && + pairing_window_seconds <= ADAPTER_PAIRING_WINDOW_SECONDS_MAX; +} + +} // namespace + AdapterConfiguration adapter_configuration_default() { return {}; } +bool adapter_requested_mode_valid(AdapterRequestedMode requested_mode) { + switch (requested_mode) { + case AdapterRequestedMode::kAuto: + case AdapterRequestedMode::kSwitch: + case AdapterRequestedMode::kXInput: + case AdapterRequestedMode::kDInput: + case AdapterRequestedMode::kMac: + return true; + } + return false; +} + +bool adapter_requested_mode_available( + AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability) { + switch (requested_mode) { + case AdapterRequestedMode::kAuto: + return true; + case AdapterRequestedMode::kSwitch: + return availability.switch_mode; + case AdapterRequestedMode::kXInput: + return availability.xinput_mode; + case AdapterRequestedMode::kDInput: + return availability.dinput_mode; + case AdapterRequestedMode::kMac: + return availability.mac_mode; + } + return false; +} + bool adapter_configuration_encode(const AdapterConfiguration& configuration, uint8_t* output, size_t output_size) { - if (output == nullptr || output_size < ADAPTER_CONFIGURATION_ENCODED_SIZE || - configuration.pairing_window_seconds < - ADAPTER_PAIRING_WINDOW_SECONDS_MIN || - configuration.pairing_window_seconds > - ADAPTER_PAIRING_WINDOW_SECONDS_MAX) { + if (output == nullptr || + output_size != ADAPTER_CONFIGURATION_ENCODED_SIZE || + !pairing_window_valid(configuration.pairing_window_seconds) || + !adapter_requested_mode_valid(configuration.requested_mode)) { return false; } output[0] = static_cast(configuration.pairing_window_seconds); output[1] = static_cast(configuration.pairing_window_seconds >> 8); - output[2] = 0; + output[2] = static_cast(configuration.requested_mode); output[3] = 0; + output[4] = 0; + output[5] = 0; + output[6] = 0; + output[7] = 0; + return true; +} + +bool adapter_configuration_decode(uint16_t schema_version, + const uint8_t* payload, + size_t payload_size, + AdapterConfiguration* output) { + if (payload == nullptr || output == nullptr) { + return false; + } + + AdapterConfiguration decoded{}; + if (schema_version == ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION) { + if (payload_size != ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE || + payload[2] != 0 || payload[3] != 0) { + return false; + } + decoded.requested_mode = AdapterRequestedMode::kAuto; + } else if (schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION) { + if (payload_size != ADAPTER_CONFIGURATION_ENCODED_SIZE || + payload[3] != 0 || payload[4] != 0 || payload[5] != 0 || + payload[6] != 0 || payload[7] != 0) { + return false; + } + decoded.requested_mode = + static_cast(payload[2]); + if (!adapter_requested_mode_valid(decoded.requested_mode)) { + return false; + } + } else { + return false; + } + + decoded.pairing_window_seconds = + static_cast(payload[0]) | + static_cast(payload[1] << 8); + if (!pairing_window_valid(decoded.pairing_window_seconds)) { + return false; + } + + *output = decoded; return true; } bool adapter_configuration_decode(const uint8_t* payload, size_t payload_size, AdapterConfiguration* output) { - if (payload == nullptr || output == nullptr || - payload_size != ADAPTER_CONFIGURATION_ENCODED_SIZE || - payload[2] != 0 || payload[3] != 0) { - return false; - } - - const uint16_t pairing_window_seconds = - static_cast(payload[0]) | - static_cast(payload[1] << 8); - if (pairing_window_seconds < ADAPTER_PAIRING_WINDOW_SECONDS_MIN || - pairing_window_seconds > ADAPTER_PAIRING_WINDOW_SECONDS_MAX) { - return false; - } - - output->pairing_window_seconds = pairing_window_seconds; - return true; + return adapter_configuration_decode(ADAPTER_CONFIGURATION_SCHEMA_VERSION, + payload, payload_size, output); } diff --git a/adapter_configuration.h b/adapter_configuration.h index 9839af6..9026731 100644 --- a/adapter_configuration.h +++ b/adapter_configuration.h @@ -3,8 +3,12 @@ #include #include -constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 1; -constexpr size_t ADAPTER_CONFIGURATION_ENCODED_SIZE = 4; +#include "adapter_usb_mode.h" + +constexpr uint16_t ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION = 1; +constexpr uint16_t ADAPTER_CONFIGURATION_SCHEMA_VERSION = 2; +constexpr size_t ADAPTER_CONFIGURATION_LEGACY_ENCODED_SIZE = 4; +constexpr size_t ADAPTER_CONFIGURATION_ENCODED_SIZE = 8; constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MIN = 10; constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_MAX = 300; constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT = 60; @@ -12,10 +16,26 @@ constexpr uint16_t ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT = 60; struct AdapterConfiguration { uint16_t pairing_window_seconds = ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT; + AdapterRequestedMode requested_mode = AdapterRequestedMode::kAuto; +}; + +struct AdapterModeAvailability { + bool switch_mode = true; + bool xinput_mode = true; + bool dinput_mode = false; + bool mac_mode = false; }; AdapterConfiguration adapter_configuration_default(); +bool adapter_requested_mode_valid(AdapterRequestedMode requested_mode); +bool adapter_requested_mode_available( + AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability); bool adapter_configuration_encode(const AdapterConfiguration& configuration, uint8_t* output, size_t output_size); +bool adapter_configuration_decode(uint16_t schema_version, + const uint8_t* payload, + size_t payload_size, + AdapterConfiguration* output); bool adapter_configuration_decode(const uint8_t* payload, size_t payload_size, AdapterConfiguration* output); diff --git a/adapter_host_probe.cpp b/adapter_host_probe.cpp index daabb4f..438b124 100644 --- a/adapter_host_probe.cpp +++ b/adapter_host_probe.cpp @@ -9,10 +9,11 @@ #endif #include "adapter_host_probe_state.h" +#include "controller_profile_runtime.h" #include "hardware/structs/watchdog.h" #include "hardware/watchdog.h" #include "pico/time.h" -#include "xinput_feasibility_descriptors.h" +#include "xinput_descriptors.h" namespace { @@ -22,7 +23,7 @@ constexpr uint8_t kStatusRequest = 0x21; constexpr uint16_t kStatusIndex = 0x0005; uint8_t g_status_response[4]{}; -AdapterUsbMode g_mode = AdapterUsbMode::kSwitchProbe; +AdapterUsbMode g_mode = AdapterUsbMode::kSwitch; AdapterHostProbeState g_probe; alarm_id_t g_reboot_alarm = 0; @@ -32,6 +33,7 @@ uint32_t now_ms() { int64_t reboot_to_xinput(alarm_id_t alarm_id, void *user_data) { (void)alarm_id; (void)user_data; + controller_profile_runtime_reset(); watchdog_hw->scratch[kModeScratchRegister] = kXInputBootMagic; watchdog_reboot(0, 0, 0); return 0; @@ -39,16 +41,53 @@ int64_t reboot_to_xinput(alarm_id_t alarm_id, void *user_data) { } // namespace -void adapter_host_probe_init() { - if (watchdog_hw->scratch[kModeScratchRegister] == kXInputBootMagic) { - watchdog_hw->scratch[kModeScratchRegister] = 0; - g_mode = AdapterUsbMode::kXInput; - } else { - g_mode = AdapterUsbMode::kSwitchProbe; +void adapter_host_probe_init(AdapterRequestedMode requested_mode) { + const uint32_t scratch = watchdog_hw->scratch[kModeScratchRegister]; + // Scratch is a one-boot transition token. Always consume it, including + // stale tokens left behind when a persistent manual mode bypasses auto. + watchdog_hw->scratch[kModeScratchRegister] = 0; + + switch (requested_mode) { + case AdapterRequestedMode::kSwitch: + g_mode = AdapterUsbMode::kSwitch; + break; + case AdapterRequestedMode::kXInput: + g_mode = AdapterUsbMode::kXInput; + break; + case AdapterRequestedMode::kDInput: + g_mode = AdapterUsbMode::kDInput; + break; + case AdapterRequestedMode::kMac: + g_mode = AdapterUsbMode::kMac; + break; + case AdapterRequestedMode::kAuto: + g_mode = scratch == kXInputBootMagic + ? AdapterUsbMode::kXInput + : AdapterUsbMode::kSwitchProbe; + break; } g_probe = {}; - PROBE_LOG("[HOST PROBE] boot mode=%s\n", - g_mode == AdapterUsbMode::kXInput ? "XInput" : "Switch probe"); + g_reboot_alarm = 0; +#ifdef SWITCH_PICO_LOG + const char* mode_name = "Switch probe"; + switch (g_mode) { + case AdapterUsbMode::kSwitch: + mode_name = "Switch"; + break; + case AdapterUsbMode::kSwitchProbe: + break; + case AdapterUsbMode::kXInput: + mode_name = "XInput"; + break; + case AdapterUsbMode::kDInput: + mode_name = "DInput"; + break; + case AdapterUsbMode::kMac: + mode_name = "Mac"; + break; + } + PROBE_LOG("[HOST PROBE] boot mode=%s\n", mode_name); +#endif } AdapterUsbMode adapter_host_probe_mode() { return g_mode; } @@ -82,8 +121,8 @@ bool adapter_host_probe_vendor_control(uint8_t rhport, uint8_t stage, sizeof(g_status_response)); } - if (request->bRequest != XInputFeasibility::kMsVendorRequest || - request->wIndex != XInputFeasibility::kMsCompatIdIndex) { + if (request->bRequest != XInput::kMsVendorRequest || + request->wIndex != XInput::kMsCompatIdIndex) { return false; } @@ -93,8 +132,8 @@ bool adapter_host_probe_vendor_control(uint8_t rhport, uint8_t stage, const bool queued = tud_control_xfer( rhport, request, const_cast( - XInputFeasibility::kProbeMsCompatIdDescriptor), - sizeof(XInputFeasibility::kProbeMsCompatIdDescriptor)); + XInput::kProbeMsCompatIdDescriptor), + sizeof(XInput::kProbeMsCompatIdDescriptor)); if (queued && g_reboot_alarm == 0) { g_reboot_alarm = add_alarm_in_ms( AdapterHostProbeState::kRebootDelayMs, @@ -105,8 +144,11 @@ bool adapter_host_probe_vendor_control(uint8_t rhport, uint8_t stage, return queued; } - return tud_control_xfer( - rhport, request, - const_cast(XInputFeasibility::kMsCompatIdDescriptor), - sizeof(XInputFeasibility::kMsCompatIdDescriptor)); + if (g_mode == AdapterUsbMode::kXInput) { + return tud_control_xfer( + rhport, request, + const_cast(XInput::kMsCompatIdDescriptor), + sizeof(XInput::kMsCompatIdDescriptor)); + } + return false; } diff --git a/adapter_host_probe.h b/adapter_host_probe.h index c01f0a2..8ea8ed2 100644 --- a/adapter_host_probe.h +++ b/adapter_host_probe.h @@ -5,8 +5,10 @@ #include "adapter_usb_mode.h" #include "tusb.h" - -void adapter_host_probe_init(); +// Consume watchdog scratch and freeze the active mode. Call exactly once +// before usb_output_driver_init() and tusb_init(). +void adapter_host_probe_init(AdapterRequestedMode requested_mode); +AdapterUsbMode adapter_host_probe_mode(); void adapter_host_probe_note_string_descriptor(uint8_t index); bool adapter_host_probe_vendor_control(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request); diff --git a/adapter_mode_controller.cpp b/adapter_mode_controller.cpp new file mode 100644 index 0000000..5d04dd4 --- /dev/null +++ b/adapter_mode_controller.cpp @@ -0,0 +1,444 @@ +#include "adapter_mode_controller.h" +#include "adapter_reboot.h" + +#include "adapter_configuration.h" +#include "adapter_host_probe.h" +#include "bluepad32_input_backend.h" +#include "configuration_service.h" +#include "controller_profile.h" +#include "controller_profile_runtime.h" +#include "hardware/watchdog.h" +#include "tusb.h" +#include "usb_output_driver.h" + +namespace { + +constexpr uint32_t kInternalTransactionBit = + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK; +constexpr uint32_t kInternalTransactionValueMask = + ~kInternalTransactionBit; +constexpr uint32_t kFeedbackPhaseMs = 75; +constexpr uint32_t kFeedbackGuardMs = 75; +static_assert( + ADAPTER_MODE_CHORD_BUTTON_MASK == + static_cast( + (1u << static_cast( + ControllerProfileLogicalButton::kLeftShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kRightShoulder)) | + (1u << static_cast( + ControllerProfileLogicalButton::kSelect)) | + (1u << static_cast( + ControllerProfileLogicalButton::kStart)) | + (1u << static_cast( + ControllerProfileLogicalButton::kSystem))), + "raw mode chord must track the logical pre-hotkey mask"); + +struct ModeChordSlot { + uint32_t connection_generation; + uint32_t hold_started_ms; + bool generation_valid; + bool holding; + bool triggered; +}; + +enum class ModeOperationKind : uint8_t { + kNone, + kChord, + kRecovery, +}; + +enum class ModeOperationPhase : uint8_t { + kSubmit, + kWaitForCommit, + kAcknowledge, +}; + +struct ModeOperation { + ModeOperationKind kind; + ModeOperationPhase phase; + uint32_t transaction_id; + AdapterRequestedMode target_mode; + uint32_t feedback_deadline_ms; + uint32_t connection_generation; + uint8_t slot; +}; + +ModeChordSlot g_chord_slots[ADAPTER_MODE_CONTROLLER_SLOT_COUNT]{}; +ModeOperation g_operation{}; +uint32_t g_reboot_transaction_id = 0; +bool g_reboot_scheduled = false; +bool g_correlated_reboot_scheduled = false; +AdapterRequestedMode g_requested_mode = AdapterRequestedMode::kAuto; +uint32_t g_next_internal_transaction_value = 1; +bool g_recovery_requested = false; +bool g_recovery_failed = false; +uint32_t g_recovery_clear_pairings_token = 0; + +bool deadline_reached(uint32_t now_ms, uint32_t deadline_ms) { + return static_cast(now_ms - deadline_ms) >= 0; +} + + +bool successful_status(ConfigurationTransactionStatus status) { + return status == ConfigurationTransactionStatus::kCommitted || + status == ConfigurationTransactionStatus::kUnchanged; +} + +bool pending_status(ConfigurationTransactionStatus status) { + return status == ConfigurationTransactionStatus::kReceiving || + status == ConfigurationTransactionStatus::kPending; +} + + +uint32_t next_internal_transaction_id() { + const uint32_t transaction_id = + kInternalTransactionBit | g_next_internal_transaction_value; + if (g_next_internal_transaction_value == + kInternalTransactionValueMask) { + g_next_internal_transaction_value = 1; + } else { + ++g_next_internal_transaction_value; + } + return transaction_id; +} + +AdapterRequestedMode next_mode(AdapterRequestedMode mode) { + switch (mode) { + case AdapterRequestedMode::kAuto: + return AdapterRequestedMode::kSwitch; + case AdapterRequestedMode::kSwitch: + return AdapterRequestedMode::kXInput; + case AdapterRequestedMode::kXInput: + return AdapterRequestedMode::kDInput; + case AdapterRequestedMode::kDInput: + return AdapterRequestedMode::kMac; + case AdapterRequestedMode::kMac: + return AdapterRequestedMode::kAuto; + } + return AdapterRequestedMode::kAuto; +} + +// Profile numbers select both a bounded pulse count and the existing color. +uint8_t feedback_profile_number(AdapterRequestedMode mode) { + switch (mode) { + case AdapterRequestedMode::kAuto: + return 1; + case AdapterRequestedMode::kSwitch: + return 2; + case AdapterRequestedMode::kXInput: + return 3; + case AdapterRequestedMode::kDInput: + case AdapterRequestedMode::kMac: + return 4; + } + return 1; +} + +// Mac reuses the fourth bounded pulse/color identity but is LED-only, making +// its confirmation tuple distinct from DInput without exceeding profile bounds. +ControllerProfileConfirmationPolicy feedback_policy( + AdapterRequestedMode mode) { + return mode == AdapterRequestedMode::kMac + ? ControllerProfileConfirmationPolicy::kLed + : ControllerProfileConfirmationPolicy::kRumbleAndLed; +} + +void clear_mode_chord(ControllerState* state) { + if (state == nullptr) { + return; + } + state->button_left_shoulder = false; + state->button_right_shoulder = false; + state->button_select = false; + state->button_start = false; + state->button_system = false; +} + +void begin_operation(ModeOperationKind kind, AdapterRequestedMode target_mode, + uint8_t slot = 0, + uint32_t connection_generation = 0) { + g_operation = {}; + g_operation.kind = kind; + g_operation.phase = ModeOperationPhase::kSubmit; + g_operation.transaction_id = next_internal_transaction_id(); + g_operation.target_mode = target_mode; + g_operation.slot = slot; + g_operation.connection_generation = connection_generation; +} + +void begin_recovery_operation_if_ready() { + if (!g_recovery_requested || g_recovery_failed || + g_operation.kind != ModeOperationKind::kNone) { + return; + } + + Bluepad32PairingSnapshot pairing{}; + bluepad32_input_backend_pairing_snapshot(&pairing); + if (!bluepad32_input_backend_clear_pairings_completed( + pairing, g_recovery_clear_pairings_token)) { + return; + } + + begin_operation(ModeOperationKind::kRecovery, + AdapterRequestedMode::kAuto); +} + +void reboot_now(); + +void finish_failed_operation() { + const bool recovery = + g_operation.kind == ModeOperationKind::kRecovery; + g_operation = {}; + if (recovery) { + g_recovery_failed = true; + } else { + begin_recovery_operation_if_ready(); + } +} + +void finish_successful_mode_write(uint32_t now_ms) { + g_requested_mode = g_operation.target_mode; + + if (g_recovery_requested || + g_operation.kind == ModeOperationKind::kRecovery) { + if (g_operation.kind != ModeOperationKind::kRecovery) { + g_operation = {}; + begin_recovery_operation_if_ready(); + return; + } + if (configuration_service_mode_transaction_reboot_ready( + g_operation.transaction_id)) { + reboot_now(); + return; + } + + // Reboot only while this remains the latest accepted mode mutation. + // Retry Auto if its correlation was displaced before Core 0 observed + // the terminal result. + begin_operation(ModeOperationKind::kRecovery, + AdapterRequestedMode::kAuto); + return; + } + + controller_profile_runtime_reset(); + const uint8_t feedback_profile = + feedback_profile_number(g_operation.target_mode); + bluepad32_input_backend_queue_profile_feedback( + g_operation.slot, g_operation.connection_generation, + feedback_profile, feedback_policy(g_operation.target_mode)); + g_operation.feedback_deadline_ms = + now_ms + static_cast(feedback_profile) * 2u * + kFeedbackPhaseMs + + kFeedbackGuardMs; + g_operation.phase = ModeOperationPhase::kAcknowledge; +} + +void reboot_now() { + if (g_reboot_scheduled) { + return; + } + g_reboot_scheduled = true; + controller_profile_runtime_reset(); + watchdog_reboot(0, 0, 0); +} + +void advance_mode_write(uint32_t now_ms) { + if (g_operation.phase == ModeOperationPhase::kSubmit) { + const ConfigurationTransactionStatus status = + configuration_service_set_mode_internal( + g_operation.transaction_id, g_operation.target_mode, + adapter_usb_mode_availability()); + if (status == ConfigurationTransactionStatus::kBusy) { + return; + } + if (successful_status(status)) { + finish_successful_mode_write(now_ms); + return; + } + if (pending_status(status)) { + g_operation.phase = ModeOperationPhase::kWaitForCommit; + return; + } + finish_failed_operation(); + return; + } + + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kIdle; + if (!configuration_service_mode_transaction_status( + g_operation.transaction_id, &status)) { + finish_failed_operation(); + return; + } + if (successful_status(status)) { + finish_successful_mode_write(now_ms); + } else if (!pending_status(status)) { + finish_failed_operation(); + } +} + +} // namespace + +const AdapterModeAvailability& adapter_usb_mode_availability() { + static constexpr AdapterModeAvailability kAvailability{ + true, true, true, true}; + return kAvailability; +} + +void adapter_mode_controller_initialize_usb() { + for (ModeChordSlot& slot : g_chord_slots) { + slot = {}; + } + g_operation = {}; + g_reboot_scheduled = false; + g_reboot_transaction_id = 0; + g_next_internal_transaction_value = 1; + g_recovery_requested = false; + g_recovery_failed = false; + g_recovery_clear_pairings_token = 0; + g_correlated_reboot_scheduled = false; + + configuration_service_initialize_pre_usb(); + ConfigurationServiceSnapshot snapshot{}; + configuration_service_snapshot(&snapshot); + g_requested_mode = snapshot.configuration.requested_mode; + adapter_host_probe_init(g_requested_mode); + usb_output_driver_init(adapter_host_probe_mode()); + tusb_init(); +} + +AdapterRequestedMode adapter_mode_controller_requested_mode() { + return g_requested_mode; +} + +void adapter_mode_controller_process_input( + uint8_t slot_index, bool active, uint32_t connection_generation, + uint16_t* pre_hotkey_button_mask, uint32_t now_ms, + ControllerState* state) { + if (slot_index >= ADAPTER_MODE_CONTROLLER_SLOT_COUNT) { + return; + } + + ModeChordSlot& slot = g_chord_slots[slot_index]; + if (!active) { + slot = {}; + return; + } + if (!slot.generation_valid || + slot.connection_generation != connection_generation) { + slot = {}; + slot.connection_generation = connection_generation; + slot.generation_valid = true; + } + + const uint16_t raw_button_mask = + pre_hotkey_button_mask == nullptr ? 0 : *pre_hotkey_button_mask; + const bool chord_held = + (raw_button_mask & ADAPTER_MODE_CHORD_BUTTON_MASK) == + ADAPTER_MODE_CHORD_BUTTON_MASK; + if (!chord_held) { + slot.holding = false; + slot.triggered = false; + return; + } + + if (pre_hotkey_button_mask != nullptr) { + *pre_hotkey_button_mask = static_cast( + *pre_hotkey_button_mask & + ~ADAPTER_MODE_CHORD_BUTTON_MASK); + } + clear_mode_chord(state); + if (!slot.holding) { + slot.holding = true; + slot.hold_started_ms = now_ms; + return; + } + if (slot.triggered || + !deadline_reached(now_ms, + slot.hold_started_ms + + ADAPTER_MODE_CHORD_HOLD_MS)) { + return; + } + + // Latch once per continuous hold even if another slot or recovery already + // owns the single serialized internal mutation. + slot.triggered = true; + if (g_operation.kind == ModeOperationKind::kNone && + !g_recovery_requested) { + ConfigurationServiceSnapshot snapshot{}; + configuration_service_snapshot(&snapshot); + g_requested_mode = snapshot.configuration.requested_mode; + begin_operation(ModeOperationKind::kChord, + next_mode(g_requested_mode), slot_index, + connection_generation); + } +} + +void adapter_mode_controller_task(uint32_t now_ms) { + if (g_reboot_scheduled) { + return; + } + begin_recovery_operation_if_ready(); + if (g_operation.kind == ModeOperationKind::kNone) { + return; + } + + if (g_operation.phase == ModeOperationPhase::kSubmit || + g_operation.phase == ModeOperationPhase::kWaitForCommit) { + advance_mode_write(now_ms); + return; + } + + if (g_operation.phase == ModeOperationPhase::kAcknowledge) { + if (g_recovery_requested) { + g_operation = {}; + begin_recovery_operation_if_ready(); + return; + } + if (deadline_reached(now_ms, + g_operation.feedback_deadline_ms)) { + if (configuration_service_mode_transaction_reboot_ready( + g_operation.transaction_id)) { + reboot_now(); + } else { + finish_failed_operation(); + } + } + } +} + +void adapter_mode_controller_begin_recovery() { + if (g_recovery_requested) { + return; + } + + configuration_service_reserve_for_recovery(); + g_recovery_requested = true; + g_recovery_clear_pairings_token = + bluepad32_input_backend_clear_pairings(); + g_recovery_failed = + g_recovery_clear_pairings_token == 0; + g_operation = {}; +} + +bool adapter_reboot_for_mode_transaction(uint32_t transaction_id) { + if (g_reboot_scheduled) { + return g_correlated_reboot_scheduled && + g_reboot_transaction_id == transaction_id; + } + if ((transaction_id & kInternalTransactionBit) != 0) { + return false; + } + if (g_recovery_requested) { + return false; + } + if (!configuration_service_mode_transaction_reboot_ready( + transaction_id)) { + return false; + } + g_correlated_reboot_scheduled = true; + g_reboot_transaction_id = transaction_id; + reboot_now(); + return true; +} diff --git a/adapter_mode_controller.h b/adapter_mode_controller.h new file mode 100644 index 0000000..43540f6 --- /dev/null +++ b/adapter_mode_controller.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "adapter_usb_mode.h" +#include "controller_state.h" + +constexpr uint8_t ADAPTER_MODE_CONTROLLER_SLOT_COUNT = 4; +constexpr uint32_t ADAPTER_MODE_CHORD_HOLD_MS = 3000; +constexpr uint16_t ADAPTER_MODE_CHORD_BUTTON_MASK = + static_cast((1u << 4) | (1u << 5) | (1u << 6) | + (1u << 7) | (1u << 8)); + +// Loads persistent configuration on Core 0, consumes watchdog scratch, +// freezes the output implementation, then starts TinyUSB in that exact order. +void adapter_mode_controller_initialize_usb(); +AdapterRequestedMode adapter_mode_controller_requested_mode(); + +// Observes the physical pre-hotkey mask and removes the mode chord from both +// that mask and the state before profile processing. Slot state is isolated by +// connection generation and all time comparisons are uint32-wrap safe. +void adapter_mode_controller_process_input( + uint8_t slot, bool active, uint32_t connection_generation, + uint16_t* pre_hotkey_button_mask, uint32_t now_ms, + ControllerState* state); + +// Advances serialized internal mode writes, acknowledgement, recovery, and +// reboot work. Call once per Core-0 loop after processing every input slot. +void adapter_mode_controller_task(uint32_t now_ms); + +// Starts the physical ten-second recovery operation: clear only Bluetooth +// pairings, then restore requested mode Auto as the final persisted mutation +// and reboot immediately after that transaction is still current. +void adapter_mode_controller_begin_recovery(); diff --git a/adapter_reboot.h b/adapter_reboot.h new file mode 100644 index 0000000..82fbfad --- /dev/null +++ b/adapter_reboot.h @@ -0,0 +1,7 @@ +#pragma once + +#include + +// Correlated normal reboot entry point for management opcode 0x03. Only a +// successful host mode transaction can reset profile runtime and reboot. +bool adapter_reboot_for_mode_transaction(uint32_t transaction_id); diff --git a/adapter_usb_mode.h b/adapter_usb_mode.h index f64e442..d5b5ed7 100644 --- a/adapter_usb_mode.h +++ b/adapter_usb_mode.h @@ -2,9 +2,29 @@ #include -enum class AdapterUsbMode : uint8_t { - kSwitchProbe, - kXInput, +struct AdapterModeAvailability; + +// Persisted user selection. Numeric values are part of adapter configuration +// schema v2 and the USB management protocol. +enum class AdapterRequestedMode : uint8_t { + kAuto = 0, + kSwitch = 1, + kXInput = 2, + kDInput = 3, + kMac = 4, }; +// The immutable USB implementation selected before tusb_init(). Auto is a +// requested mode, not an active USB mode. +enum class AdapterUsbMode : uint8_t { + kSwitch = 0, + kSwitchProbe = 1, + kXInput = 2, + kDInput = 3, + kMac = 4, +}; + +// Availability of USB mode implementations in this firmware build. All mode +// selection paths consume this single value. +const AdapterModeAvailability& adapter_usb_mode_availability(); AdapterUsbMode adapter_host_probe_mode(); diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index beab8e9..1914f55 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -14,7 +14,7 @@ #include #include #include -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES #include "adapter_usb_mode.h" #endif @@ -170,12 +170,14 @@ critical_section_t g_state_lock; BackendSlot g_slots[kSlotCount]; BleIdentityMapping g_ble_identity_mappings[kSlotCount]{}; -// These acknowledgement generations and the pairing request producer are only -// used by Core 0. The request is transferred under the cross-core state lock. +// These acknowledgement generations and request producers are only used by +// Core 0. Requests are transferred under the cross-core state lock. uint32_t g_consumed_generation[kSlotCount]{}; uint32_t g_last_snapshot_generation[kSlotCount]{}; bool g_pairing_window_requested = false; -bool g_clear_pairings_requested = false; +uint32_t g_clear_pairings_requested_token = 0; +uint32_t g_clear_pairings_in_progress_token = 0; +uint32_t g_next_clear_pairings_request_token = 1; bool g_pairing_snapshot_requested = false; bool g_initialized = false; bool g_started = false; @@ -198,7 +200,7 @@ bool g_status_led_on = false; Bluepad32PairingSnapshot g_pairing_snapshot{}; uint16_t host_rumble_duration_ms() { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { return kXInputHostRumbleDurationMs; } @@ -1074,6 +1076,8 @@ void refresh_pairing_snapshot() { critical_section_enter_blocking(&g_state_lock); snapshot.generation = g_pairing_snapshot.generation + 1; + snapshot.completed_clear_pairings_token = + g_pairing_snapshot.completed_clear_pairings_token; g_pairing_snapshot = snapshot; g_pairing_snapshot_requested = false; critical_section_exit(&g_state_lock); @@ -1093,9 +1097,13 @@ void apply_connection_policy(); void process_clear_pairings(uint32_t now_ms) { uni_hid_device_t* devices[kSlotCount]{}; critical_section_enter_blocking(&g_state_lock); - const bool requested = g_clear_pairings_requested; - g_clear_pairings_requested = false; - if (requested) { + const uint32_t request_token = + g_clear_pairings_requested_token; + if (request_token != 0) { + g_clear_pairings_requested_token = 0; + g_clear_pairings_in_progress_token = request_token; + } + if (request_token != 0) { g_pairing_window_requested = false; for (uint8_t slot_index = 0; slot_index < kSlotCount; ++slot_index) { BackendSlot& slot = g_slots[slot_index]; @@ -1113,7 +1121,7 @@ void process_clear_pairings(uint32_t now_ms) { } } critical_section_exit(&g_state_lock); - if (!requested) { + if (request_token == 0) { return; } for (BleIdentityMapping& mapping : g_ble_identity_mappings) { @@ -1136,6 +1144,11 @@ void process_clear_pairings(uint32_t now_ms) { g_pairing_reset_feedback_deadline_ms = now_ms + kPairingResetFeedbackDurationMs; apply_connection_policy(); + critical_section_enter_blocking(&g_state_lock); + g_pairing_snapshot.completed_clear_pairings_token = + request_token; + g_clear_pairings_in_progress_token = 0; + critical_section_exit(&g_state_lock); } @@ -1734,7 +1747,9 @@ void bluepad32_input_backend_init() { g_pairing_snapshot = {}; g_pairing_snapshot.status = Bluepad32PairingSnapshotStatus::kPending; - g_clear_pairings_requested = false; + g_clear_pairings_requested_token = 0; + g_clear_pairings_in_progress_token = 0; + g_next_clear_pairings_request_token = 1; g_connection_status = ConnectionStatus::Initializing; g_connection_policy_state = ConnectionPolicyState::Uninitialized; g_pairing_window_deadline_ms = 0; @@ -1773,16 +1788,26 @@ void bluepad32_input_backend_open_pairing_window() { g_pairing_window_requested = true; critical_section_exit(&g_state_lock); } -void bluepad32_input_backend_clear_pairings() { +uint32_t bluepad32_input_backend_clear_pairings() { if (!g_initialized) { bluepad32_input_backend_init(); } critical_section_enter_blocking(&g_state_lock); - g_clear_pairings_requested = true; - g_pairing_snapshot.status = - Bluepad32PairingSnapshotStatus::kPending; + uint32_t request_token = g_clear_pairings_requested_token; + if (request_token == 0) { + request_token = g_clear_pairings_in_progress_token; + } + if (request_token == 0) { + request_token = g_next_clear_pairings_request_token; + g_next_clear_pairings_request_token = + request_token == UINT32_MAX ? 1 : request_token + 1; + g_clear_pairings_requested_token = request_token; + g_pairing_snapshot.status = + Bluepad32PairingSnapshotStatus::kPending; + } critical_section_exit(&g_state_lock); + return request_token; } void bluepad32_input_backend_request_pairing_snapshot() { diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index 36ce5ae..6d8067e 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -29,11 +29,29 @@ struct Bluepad32PairingRecord { struct Bluepad32PairingSnapshot { uint32_t generation; + // Unchanged by ordinary refreshes; published only after all clear work. + uint32_t completed_clear_pairings_token; Bluepad32PairingSnapshotStatus status; uint8_t record_count; bool overflow; Bluepad32PairingRecord records[BLUEPAD32_PAIRING_RECORD_CAPACITY]; }; +// Clear tokens form a bounded serial number space over every nonzero uint32_t. +// A completion at most half that space ahead of a request also acknowledges +// the request, including across the UINT32_MAX-to-1 wrap. +constexpr bool bluepad32_input_backend_clear_pairings_completed( + const Bluepad32PairingSnapshot& snapshot, uint32_t request_token) { + const uint32_t completed_token = + snapshot.completed_clear_pairings_token; + if (request_token == 0 || completed_token == 0) { + return false; + } + const uint32_t forward_distance = + completed_token >= request_token + ? completed_token - request_token + : (UINT32_MAX - request_token) + completed_token; + return forward_distance <= UINT32_MAX / 2u; +} struct Bluepad32SlotSnapshot { bool active; uint32_t connection_generation; @@ -49,7 +67,9 @@ struct Bluepad32SlotSnapshot { void bluepad32_input_backend_init(); void bluepad32_input_backend_start(); void bluepad32_input_backend_open_pairing_window(); -void bluepad32_input_backend_clear_pairings(); +// Repeated calls coalesce until Core 1 completes the operation and return the +// same nonzero token. +uint32_t bluepad32_input_backend_clear_pairings(); void bluepad32_input_backend_snapshot(uint8_t slot, Bluepad32SlotSnapshot* out); void bluepad32_input_backend_request_pairing_snapshot(); diff --git a/configuration_service.cpp b/configuration_service.cpp index b71a1e0..b450ad7 100644 --- a/configuration_service.cpp +++ b/configuration_service.cpp @@ -9,28 +9,83 @@ namespace { constexpr uint32_t kMinimumCommitIntervalMs = 1000; +enum class PendingWriteOwner : uint8_t { + kNone, + kHost, + kInternal, + kMigration, +}; + critical_section_t g_lock; bool g_prepared = false; +bool g_pre_usb_initialized = false; +bool g_storage_initialized = false; +bool g_storage_core_adopted = false; +bool g_migration_needed = false; +bool g_migration_pending = false; ConfigurationStorage g_storage; ConfigurationTransaction g_transaction; ConfigurationServiceSnapshot g_snapshot; +ConfigurationModeTransactionSnapshot g_host_mode_transaction; +ConfigurationModeTransactionSnapshot g_internal_mode_transaction; +uint8_t g_internal_payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; +uint8_t g_migration_payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; uint32_t g_published_reset_generation = 0; bool g_has_committed = false; uint32_t g_last_commit_ms = 0; +uint64_t g_latest_mode_transaction_serial = 0; +bool g_recovery_reserved = false; -void publish_storage_snapshot(ConfigurationServiceState state) { +bool transaction_active(ConfigurationTransactionStatus status) { + return status == ConfigurationTransactionStatus::kReceiving || + status == ConfigurationTransactionStatus::kPending; +} + +bool internal_write_pending() { + return g_migration_pending || + g_internal_mode_transaction.status == + ConfigurationTransactionStatus::kPending; +} + +uint64_t advance_mode_transaction_serial() { + ++g_latest_mode_transaction_serial; + if (g_latest_mode_transaction_serial == 0) { + ++g_latest_mode_transaction_serial; + } + return g_latest_mode_transaction_serial; +} + +bool decode_storage_configuration( + const ConfigurationStorageSnapshot& stored, + AdapterConfiguration* configuration, + bool* migration_needed) { + *configuration = adapter_configuration_default(); + *migration_needed = false; + if (!stored.valid) { + return true; + } + if (!adapter_configuration_decode(stored.schema_version, stored.payload, + stored.payload_size, configuration)) { + return false; + } + *migration_needed = + stored.schema_version == ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION; + return true; +} + +void publish_storage_snapshot(bool initialized) { const ConfigurationStorageSnapshot& stored = g_storage.snapshot(); AdapterConfiguration configuration = adapter_configuration_default(); - if (stored.valid && - (stored.schema_version != ADAPTER_CONFIGURATION_SCHEMA_VERSION || - !adapter_configuration_decode(stored.payload, - stored.payload_size, - &configuration))) { - state = ConfigurationServiceState::kStorageError; - } + bool migration_needed = false; + const bool decoded = initialized && + decode_storage_configuration( + stored, &configuration, &migration_needed); critical_section_enter_blocking(&g_lock); - g_snapshot.state = state; + g_storage_initialized = initialized; + g_migration_needed = decoded && migration_needed; + g_snapshot.state = decoded ? ConfigurationServiceState::kReady + : ConfigurationServiceState::kStorageError; g_snapshot.configuration = configuration; g_snapshot.generation = stored.valid ? stored.generation : 0; g_snapshot.payload_crc = stored.valid ? stored.payload_crc : 0; @@ -38,6 +93,54 @@ void publish_storage_snapshot(ConfigurationServiceState state) { critical_section_exit(&g_lock); } + +void publish_mode_transaction( + const ConfigurationModeTransactionSnapshot& transaction) { + g_snapshot.mode_transaction = transaction; +} +const ConfigurationModeTransactionSnapshot& mode_transaction_for_id( + uint32_t transaction_id) { + return (transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0 + ? g_internal_mode_transaction + : g_host_mode_transaction; +} + +bool mode_transaction_succeeded( + const ConfigurationModeTransactionSnapshot& transaction) { + return transaction.status == ConfigurationTransactionStatus::kCommitted || + transaction.status == ConfigurationTransactionStatus::kUnchanged; +} + +ConfigurationTransactionStatus validate_mode_request( + AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability) { + if (!adapter_requested_mode_valid(requested_mode)) { + return ConfigurationTransactionStatus::kMalformed; + } + if (!adapter_requested_mode_available(requested_mode, availability)) { + return ConfigurationTransactionStatus::kUnsupportedSchema; + } + return ConfigurationTransactionStatus::kIdle; +} + +void update_snapshot_from_storage(ConfigurationStorageResult result) { + const ConfigurationStorageSnapshot& stored = g_storage.snapshot(); + AdapterConfiguration configuration = adapter_configuration_default(); + bool migration_needed = false; + const bool decoded = decode_storage_configuration( + stored, &configuration, &migration_needed); + g_snapshot.state = decoded ? ConfigurationServiceState::kReady + : ConfigurationServiceState::kStorageError; + if (!stored.valid && result == ConfigurationStorageResult::kIoError) { + g_snapshot.state = ConfigurationServiceState::kStorageError; + } + g_snapshot.configuration = configuration; + g_snapshot.generation = stored.valid ? stored.generation : 0; + g_snapshot.payload_crc = stored.valid ? stored.payload_crc : 0; + g_migration_needed = decoded && migration_needed; +} + } // namespace void configuration_service_prepare() { @@ -46,87 +149,184 @@ void configuration_service_prepare() { } critical_section_init(&g_lock); g_snapshot = {}; - __atomic_store_n(&g_published_reset_generation, 0, __ATOMIC_RELAXED); g_snapshot.configuration = adapter_configuration_default(); g_transaction.clear(); + g_host_mode_transaction = {}; + g_internal_mode_transaction = {}; + g_latest_mode_transaction_serial = 0; + g_recovery_reserved = false; + __atomic_store_n(&g_published_reset_generation, 0, __ATOMIC_RELAXED); g_prepared = true; } +void configuration_service_initialize_pre_usb() { + if (!g_prepared) { + configuration_service_prepare(); + } + + critical_section_enter_blocking(&g_lock); + const bool already_initialized = g_pre_usb_initialized; + if (!already_initialized) { + g_pre_usb_initialized = true; + } + critical_section_exit(&g_lock); + if (already_initialized) { + return; + } + + const bool initialized = + g_storage.initialize(pico_configuration_storage_io()); + publish_storage_snapshot(initialized); +} + void configuration_service_initialize_on_storage_core() { if (!g_prepared) { configuration_service_prepare(); } - const bool initialized = - g_storage.initialize(pico_configuration_storage_io()); - publish_storage_snapshot(initialized - ? ConfigurationServiceState::kReady - : ConfigurationServiceState::kStorageError); + critical_section_enter_blocking(&g_lock); + const bool pre_usb_initialized = g_pre_usb_initialized; + critical_section_exit(&g_lock); + if (!pre_usb_initialized) { + configuration_service_initialize_pre_usb(); + } + + critical_section_enter_blocking(&g_lock); + if (g_storage_core_adopted) { + critical_section_exit(&g_lock); + return; + } + g_storage_core_adopted = g_storage_initialized; + if (g_storage_core_adopted && g_migration_needed) { + const AdapterConfiguration configuration = g_snapshot.configuration; + if (adapter_configuration_encode(configuration, g_migration_payload, + sizeof(g_migration_payload))) { + g_migration_pending = true; + } else { + g_snapshot.state = ConfigurationServiceState::kStorageError; + } + } + critical_section_exit(&g_lock); } void configuration_service_task_on_storage_core(uint32_t now_ms) { uint8_t payload[CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE]{}; uint16_t payload_size = 0; - uint16_t schema_version = 0; + uint16_t schema_version = ADAPTER_CONFIGURATION_SCHEMA_VERSION; + uint32_t transaction_id = 0; + PendingWriteOwner owner = PendingWriteOwner::kNone; critical_section_enter_blocking(&g_lock); - const ConfigurationTransactionSnapshot transaction = - g_transaction.snapshot(); - if (transaction.status == ConfigurationTransactionStatus::kPending && - (!g_has_committed || - static_cast(now_ms - g_last_commit_ms) >= - kMinimumCommitIntervalMs)) { - payload_size = transaction.expected_size; - schema_version = g_transaction.schema_version(); - memcpy(payload, g_transaction.payload(), payload_size); + const bool rate_limited = + g_has_committed && + static_cast(now_ms - g_last_commit_ms) < + kMinimumCommitIntervalMs; + if (g_storage_core_adopted && !rate_limited) { + if (g_migration_pending) { + owner = PendingWriteOwner::kMigration; + payload_size = sizeof(g_migration_payload); + memcpy(payload, g_migration_payload, payload_size); + } else if (g_internal_mode_transaction.status == + ConfigurationTransactionStatus::kPending) { + owner = PendingWriteOwner::kInternal; + transaction_id = g_internal_mode_transaction.transaction_id; + payload_size = sizeof(g_internal_payload); + memcpy(payload, g_internal_payload, payload_size); + } else { + const ConfigurationTransactionSnapshot transaction = + g_transaction.snapshot(); + if (transaction.status == + ConfigurationTransactionStatus::kPending) { + owner = PendingWriteOwner::kHost; + transaction_id = transaction.transaction_id; + payload_size = transaction.expected_size; + schema_version = g_transaction.schema_version(); + memcpy(payload, g_transaction.payload(), payload_size); + } + } } critical_section_exit(&g_lock); - if (payload_size == 0) { + if (owner == PendingWriteOwner::kNone) { return; } const ConfigurationStorageResult result = g_storage.commit(schema_version, payload, payload_size); - const ConfigurationStorageSnapshot& stored = g_storage.snapshot(); - ConfigurationTransactionStatus transaction_status = + ConfigurationTransactionStatus status = ConfigurationTransactionStatus::kStorageError; if (result == ConfigurationStorageResult::kOk) { - transaction_status = ConfigurationTransactionStatus::kCommitted; + status = ConfigurationTransactionStatus::kCommitted; g_has_committed = true; g_last_commit_ms = now_ms; } else if (result == ConfigurationStorageResult::kUnchanged) { - transaction_status = ConfigurationTransactionStatus::kUnchanged; - } - - AdapterConfiguration configuration{}; - ConfigurationServiceState service_state = - ConfigurationServiceState::kStorageError; - if (stored.valid && - stored.schema_version == ADAPTER_CONFIGURATION_SCHEMA_VERSION && - adapter_configuration_decode(stored.payload, stored.payload_size, - &configuration)) { - service_state = ConfigurationServiceState::kReady; - } else if (!stored.valid) { - configuration = adapter_configuration_default(); + status = ConfigurationTransactionStatus::kUnchanged; } critical_section_enter_blocking(&g_lock); - g_transaction.set_result(transaction_status, - stored.valid ? stored.generation : 0, - stored.valid ? stored.payload_crc : 0); - g_snapshot.state = service_state; - g_snapshot.configuration = configuration; - g_snapshot.generation = stored.valid ? stored.generation : 0; - g_snapshot.payload_crc = stored.valid ? stored.payload_crc : 0; - g_snapshot.transaction = g_transaction.snapshot(); + update_snapshot_from_storage(result); + if (owner == PendingWriteOwner::kHost) { + const ConfigurationTransactionSnapshot transaction = + g_transaction.snapshot(); + if (transaction.transaction_id == transaction_id && + transaction.status == + ConfigurationTransactionStatus::kPending) { + g_transaction.set_result(status, g_snapshot.generation, + g_snapshot.payload_crc); + if (g_host_mode_transaction.transaction_id == transaction_id && + g_host_mode_transaction.status == + ConfigurationTransactionStatus::kPending) { + g_host_mode_transaction.status = status; + g_host_mode_transaction.stored_generation = + g_snapshot.generation; + if (!g_snapshot.mode_transaction.internal && + g_snapshot.mode_transaction.transaction_id == + transaction_id) { + publish_mode_transaction(g_host_mode_transaction); + } + } + } + g_snapshot.transaction = g_transaction.snapshot(); + } else if (owner == PendingWriteOwner::kInternal) { + if (g_internal_mode_transaction.transaction_id == transaction_id && + g_internal_mode_transaction.status == + ConfigurationTransactionStatus::kPending) { + g_internal_mode_transaction.status = status; + g_internal_mode_transaction.stored_generation = + g_snapshot.generation; + if (g_snapshot.mode_transaction.internal && + g_snapshot.mode_transaction.transaction_id == + transaction_id) { + publish_mode_transaction(g_internal_mode_transaction); + } + } + } else { + g_migration_pending = false; + if (status == ConfigurationTransactionStatus::kCommitted || + status == ConfigurationTransactionStatus::kUnchanged) { + g_migration_needed = false; + } + } critical_section_exit(&g_lock); } ConfigurationTransactionStatus configuration_service_begin( uint32_t transaction_id, uint16_t schema_version, size_t payload_size, uint32_t payload_crc) { + if ((transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + critical_section_enter_blocking(&g_lock); + if (!g_storage_core_adopted || g_recovery_reserved || + internal_write_pending()) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } const ConfigurationTransactionStatus status = g_transaction.begin( transaction_id, schema_version, payload_size, payload_crc); + if (status == ConfigurationTransactionStatus::kReceiving) { + advance_mode_transaction_serial(); + } g_snapshot.transaction = g_transaction.snapshot(); critical_section_exit(&g_lock); return status; @@ -135,7 +335,16 @@ ConfigurationTransactionStatus configuration_service_begin( ConfigurationTransactionStatus configuration_service_append( uint32_t transaction_id, size_t offset, const uint8_t* data, size_t size) { + if ((transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + critical_section_enter_blocking(&g_lock); + if (g_recovery_reserved || internal_write_pending()) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } const ConfigurationTransactionStatus status = g_transaction.append(transaction_id, offset, data, size); g_snapshot.transaction = g_transaction.snapshot(); @@ -145,7 +354,16 @@ ConfigurationTransactionStatus configuration_service_append( ConfigurationTransactionStatus configuration_service_commit( uint32_t transaction_id) { + if ((transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + critical_section_enter_blocking(&g_lock); + if (g_recovery_reserved || internal_write_pending()) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } const ConfigurationTransactionStatus status = g_transaction.finish(transaction_id); g_snapshot.transaction = g_transaction.snapshot(); @@ -155,6 +373,12 @@ ConfigurationTransactionStatus configuration_service_commit( ConfigurationTransactionStatus configuration_service_reset( uint32_t transaction_id) { + if (transaction_id == 0 || + (transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; const AdapterConfiguration defaults = adapter_configuration_default(); if (!adapter_configuration_encode(defaults, payload, sizeof(payload))) { @@ -163,6 +387,11 @@ ConfigurationTransactionStatus configuration_service_reset( const uint32_t crc = configuration_crc32(payload, sizeof(payload)); critical_section_enter_blocking(&g_lock); + if (!g_storage_core_adopted || g_recovery_reserved || + internal_write_pending()) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } ConfigurationTransactionStatus status = g_transaction.begin( transaction_id, ADAPTER_CONFIGURATION_SCHEMA_VERSION, sizeof(payload), crc); @@ -173,6 +402,9 @@ ConfigurationTransactionStatus configuration_service_reset( if (status == ConfigurationTransactionStatus::kReceiving) { status = g_transaction.finish(transaction_id); } + if (status == ConfigurationTransactionStatus::kPending) { + advance_mode_transaction_serial(); + } if (status == ConfigurationTransactionStatus::kPending && g_snapshot.reset_generation != UINT32_MAX) { ++g_snapshot.reset_generation; @@ -186,6 +418,204 @@ ConfigurationTransactionStatus configuration_service_reset( return status; } +ConfigurationTransactionStatus configuration_service_set_mode( + uint32_t transaction_id, AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability) { + if (transaction_id == 0 || + (transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } + + critical_section_enter_blocking(&g_lock); + if (g_recovery_reserved) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + const ConfigurationTransactionStatus validation = + validate_mode_request(requested_mode, availability); + if (validation != ConfigurationTransactionStatus::kIdle) { + critical_section_exit(&g_lock); + return validation; + } + if (g_host_mode_transaction.transaction_id == transaction_id) { + const ConfigurationTransactionStatus status = + g_host_mode_transaction.requested_mode == requested_mode + ? g_host_mode_transaction.status + : ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return status; + } + if (!g_storage_core_adopted || internal_write_pending() || + transaction_active(g_transaction.snapshot().status)) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + if (g_snapshot.state != ConfigurationServiceState::kReady) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kStorageError; + } + + AdapterConfiguration configuration = g_snapshot.configuration; + configuration.requested_mode = requested_mode; + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + if (!adapter_configuration_encode(configuration, payload, + sizeof(payload))) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + const uint32_t crc = configuration_crc32(payload, sizeof(payload)); + ConfigurationTransactionStatus status = g_transaction.begin( + transaction_id, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc); + if (status == ConfigurationTransactionStatus::kReceiving) { + status = g_transaction.append(transaction_id, 0, payload, + sizeof(payload)); + } + if (status == ConfigurationTransactionStatus::kReceiving) { + status = g_transaction.finish(transaction_id); + } + if (status == ConfigurationTransactionStatus::kPending) { + g_host_mode_transaction = { + transaction_id, + requested_mode, + 0, + advance_mode_transaction_serial(), + status, + false, + }; + publish_mode_transaction(g_host_mode_transaction); + } + g_snapshot.transaction = g_transaction.snapshot(); + critical_section_exit(&g_lock); + return status; +} + +ConfigurationTransactionStatus configuration_service_set_mode_internal( + uint32_t transaction_id, AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability) { + if ((transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) == 0) { + return ConfigurationTransactionStatus::kMalformed; + } + + critical_section_enter_blocking(&g_lock); + if (g_recovery_reserved && + requested_mode != AdapterRequestedMode::kAuto) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + const ConfigurationTransactionStatus validation = + validate_mode_request(requested_mode, availability); + if (validation != ConfigurationTransactionStatus::kIdle) { + critical_section_exit(&g_lock); + return validation; + } + if (g_internal_mode_transaction.transaction_id == transaction_id) { + const ConfigurationTransactionStatus status = + g_internal_mode_transaction.requested_mode == requested_mode + ? g_internal_mode_transaction.status + : ConfigurationTransactionStatus::kMalformed; + critical_section_exit(&g_lock); + return status; + } + if (!g_storage_core_adopted || g_migration_pending || + g_internal_mode_transaction.status == + ConfigurationTransactionStatus::kPending || + transaction_active(g_transaction.snapshot().status)) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kBusy; + } + if (g_snapshot.state != ConfigurationServiceState::kReady) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kStorageError; + } + + AdapterConfiguration configuration = g_snapshot.configuration; + configuration.requested_mode = requested_mode; + if (!adapter_configuration_encode(configuration, g_internal_payload, + sizeof(g_internal_payload))) { + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kMalformed; + } + g_internal_mode_transaction = { + transaction_id, + requested_mode, + 0, + advance_mode_transaction_serial(), + ConfigurationTransactionStatus::kPending, + true, + }; + publish_mode_transaction(g_internal_mode_transaction); + critical_section_exit(&g_lock); + return ConfigurationTransactionStatus::kPending; +} + +void configuration_service_reserve_for_recovery() { + critical_section_enter_blocking(&g_lock); + if (!g_recovery_reserved) { + g_recovery_reserved = true; + advance_mode_transaction_serial(); + } + + const ConfigurationTransactionSnapshot transaction = + g_transaction.snapshot(); + if (transaction_active(transaction.status)) { + g_transaction.set_result(ConfigurationTransactionStatus::kBusy, 0, 0); + if (g_host_mode_transaction.transaction_id == + transaction.transaction_id && + transaction_active(g_host_mode_transaction.status)) { + g_host_mode_transaction.status = + ConfigurationTransactionStatus::kBusy; + g_host_mode_transaction.stored_generation = 0; + if (!g_snapshot.mode_transaction.internal && + g_snapshot.mode_transaction.transaction_id == + transaction.transaction_id) { + publish_mode_transaction(g_host_mode_transaction); + } + } + g_snapshot.transaction = g_transaction.snapshot(); + } + critical_section_exit(&g_lock); +} + +bool configuration_service_mode_transaction_status( + uint32_t transaction_id, ConfigurationTransactionStatus* output) { + if (transaction_id == 0 || output == nullptr) { + return false; + } + + critical_section_enter_blocking(&g_lock); + const ConfigurationModeTransactionSnapshot& transaction = + mode_transaction_for_id(transaction_id); + const bool found = transaction.transaction_id == transaction_id; + if (found) { + *output = transaction.status; + } + critical_section_exit(&g_lock); + return found; +} + +bool configuration_service_mode_transaction_reboot_ready( + uint32_t transaction_id) { + if (transaction_id == 0) { + return false; + } + + critical_section_enter_blocking(&g_lock); + const ConfigurationModeTransactionSnapshot& transaction = + mode_transaction_for_id(transaction_id); + const bool ready = + transaction.transaction_id == transaction_id && + mode_transaction_succeeded(transaction) && + transaction.accepted_serial == + g_latest_mode_transaction_serial && + transaction.stored_generation == g_snapshot.generation && + transaction.requested_mode == g_snapshot.configuration.requested_mode; + critical_section_exit(&g_lock); + return ready; +} + void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { if (output == nullptr) { return; diff --git a/configuration_service.h b/configuration_service.h index e540c26..ab453be 100644 --- a/configuration_service.h +++ b/configuration_service.h @@ -6,12 +6,25 @@ #include "adapter_configuration.h" #include "configuration_transaction.h" +constexpr uint32_t CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK = + 0x80000000u; + enum class ConfigurationServiceState : uint8_t { kLoading = 0, kReady = 1, kStorageError = 2, }; +struct ConfigurationModeTransactionSnapshot { + uint32_t transaction_id = 0; + AdapterRequestedMode requested_mode = AdapterRequestedMode::kAuto; + uint32_t stored_generation = 0; + uint64_t accepted_serial = 0; + ConfigurationTransactionStatus status = + ConfigurationTransactionStatus::kIdle; + bool internal = false; +}; + struct ConfigurationServiceSnapshot { ConfigurationServiceState state = ConfigurationServiceState::kLoading; AdapterConfiguration configuration{}; @@ -19,9 +32,11 @@ struct ConfigurationServiceSnapshot { uint32_t payload_crc = 0; uint32_t reset_generation = 0; ConfigurationTransactionSnapshot transaction{}; + ConfigurationModeTransactionSnapshot mode_transaction{}; }; void configuration_service_prepare(); +void configuration_service_initialize_pre_usb(); void configuration_service_initialize_on_storage_core(); void configuration_service_task_on_storage_core(uint32_t now_ms); @@ -35,6 +50,19 @@ ConfigurationTransactionStatus configuration_service_commit( uint32_t transaction_id); ConfigurationTransactionStatus configuration_service_reset( uint32_t transaction_id); +ConfigurationTransactionStatus configuration_service_set_mode( + uint32_t transaction_id, AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability); +ConfigurationTransactionStatus configuration_service_set_mode_internal( + uint32_t transaction_id, AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability); +// Permanently reserves configuration writes for physical recovery. Active +// host work is canceled; only an internal Auto mode transaction remains legal. +void configuration_service_reserve_for_recovery(); +bool configuration_service_mode_transaction_status( + uint32_t transaction_id, ConfigurationTransactionStatus* output); +bool configuration_service_mode_transaction_reboot_ready( + uint32_t transaction_id); void configuration_service_snapshot(ConfigurationServiceSnapshot* output); // Lock-free publication for the report path. The value changes as soon as a diff --git a/generic_hid_descriptors.h b/generic_hid_descriptors.h new file mode 100644 index 0000000..7293735 --- /dev/null +++ b/generic_hid_descriptors.h @@ -0,0 +1,295 @@ +#pragma once + +#include +#include + +#include "controller_state.h" + +#ifndef SWITCH_PICO_HID_INSTANCE_COUNT +#define SWITCH_PICO_HID_INSTANCE_COUNT 1 +#endif + +#if SWITCH_PICO_HID_INSTANCE_COUNT < 1 || SWITCH_PICO_HID_INSTANCE_COUNT > 4 +#error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4" +#endif + +namespace GenericHid { + +constexpr uint16_t kDevelopmentVendorId = 0xcafe; +constexpr uint16_t kDInputDevelopmentProductId = 0x4020; +constexpr uint16_t kMacDevelopmentProductId = 0x4021; +constexpr uint16_t kDevelopmentDeviceRevision = 0x0100; + +constexpr uint8_t kReportSize = 15; +constexpr uint8_t kEndpointSize = kReportSize; +constexpr uint8_t kEndpointIntervalMs = 1; +constexpr uint8_t kInterfaceDescriptorSize = 25; +constexpr uint16_t kConfigurationDescriptorSize = + 9 + SWITCH_PICO_HID_INSTANCE_COUNT * kInterfaceDescriptorSize; + +constexpr uint8_t kHatUp = 0; +constexpr uint8_t kHatUpRight = 1; +constexpr uint8_t kHatRight = 2; +constexpr uint8_t kHatDownRight = 3; +constexpr uint8_t kHatDown = 4; +constexpr uint8_t kHatDownLeft = 5; +constexpr uint8_t kHatLeft = 6; +constexpr uint8_t kHatUpLeft = 7; +constexpr uint8_t kHatCenter = 8; + +constexpr uint16_t kButtonSouth = 1u << 0u; +constexpr uint16_t kButtonEast = 1u << 1u; +constexpr uint16_t kButtonWest = 1u << 2u; +constexpr uint16_t kButtonNorth = 1u << 3u; +constexpr uint16_t kButtonLeftShoulder = 1u << 4u; +constexpr uint16_t kButtonRightShoulder = 1u << 5u; +constexpr uint16_t kButtonSelect = 1u << 6u; +constexpr uint16_t kButtonStart = 1u << 7u; +constexpr uint16_t kButtonLeftStick = 1u << 8u; +constexpr uint16_t kButtonRightStick = 1u << 9u; +constexpr uint16_t kButtonSystem = 1u << 10u; +constexpr uint16_t kButtonCapture = 1u << 11u; +constexpr uint16_t kDefinedButtonMask = 0x0fffu; + +struct InputReport { + uint8_t data[kReportSize]; +}; + +static_assert(sizeof(InputReport) == kReportSize); + +constexpr void write_u16_le(uint8_t* destination, uint16_t value) { + destination[0] = static_cast(value); + destination[1] = static_cast(value >> 8u); +} + +constexpr uint8_t build_hat(const ControllerState& state) { + const bool up = state.dpad_up && !state.dpad_down; + const bool down = state.dpad_down && !state.dpad_up; + const bool left = state.dpad_left && !state.dpad_right; + const bool right = state.dpad_right && !state.dpad_left; + + if (up) { + if (right) { + return kHatUpRight; + } + if (left) { + return kHatUpLeft; + } + return kHatUp; + } + if (down) { + if (right) { + return kHatDownRight; + } + if (left) { + return kHatDownLeft; + } + return kHatDown; + } + if (right) { + return kHatRight; + } + if (left) { + return kHatLeft; + } + return kHatCenter; +} + +constexpr uint16_t build_buttons(const ControllerState& state) { + return static_cast( + (state.button_south ? kButtonSouth : 0u) | + (state.button_east ? kButtonEast : 0u) | + (state.button_west ? kButtonWest : 0u) | + (state.button_north ? kButtonNorth : 0u) | + (state.button_left_shoulder ? kButtonLeftShoulder : 0u) | + (state.button_right_shoulder ? kButtonRightShoulder : 0u) | + (state.button_select ? kButtonSelect : 0u) | + (state.button_start ? kButtonStart : 0u) | + (state.button_left_stick ? kButtonLeftStick : 0u) | + (state.button_right_stick ? kButtonRightStick : 0u) | + (state.button_system ? kButtonSystem : 0u) | + (state.button_capture ? kButtonCapture : 0u)); +} + +constexpr InputReport build_input_report(const ControllerState& state) { + InputReport report{}; + write_u16_le(report.data + 0, + static_cast(state.left_stick_x)); + write_u16_le(report.data + 2, + static_cast(state.left_stick_y)); + write_u16_le(report.data + 4, + static_cast(state.right_stick_x)); + write_u16_le(report.data + 6, + static_cast(state.right_stick_y)); + write_u16_le(report.data + 8, state.left_trigger); + write_u16_le(report.data + 10, state.right_trigger); + report.data[12] = build_hat(state); + write_u16_le(report.data + 13, build_buttons(state)); + return report; +} + +inline constexpr char kManufacturerString[] = "Switch Pico"; +inline constexpr char kDInputProductString[] = "DInput Development"; +inline constexpr char kDInputSerialString[] = "DINPUT-DEV-4020"; +inline constexpr char kMacProductString[] = "Mac HID Development"; +inline constexpr char kMacSerialString[] = "MAC-HID-DEV-4021"; + +inline constexpr uint8_t kDInputDeviceDescriptor[] = { + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, + static_cast(kDevelopmentVendorId), + static_cast(kDevelopmentVendorId >> 8u), + static_cast(kDInputDevelopmentProductId), + static_cast(kDInputDevelopmentProductId >> 8u), + static_cast(kDevelopmentDeviceRevision), + static_cast(kDevelopmentDeviceRevision >> 8u), + 0x01, 0x02, 0x03, 0x01, +}; + +inline constexpr uint8_t kMacDeviceDescriptor[] = { + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, + static_cast(kDevelopmentVendorId), + static_cast(kDevelopmentVendorId >> 8u), + static_cast(kMacDevelopmentProductId), + static_cast(kMacDevelopmentProductId >> 8u), + static_cast(kDevelopmentDeviceRevision), + static_cast(kDevelopmentDeviceRevision >> 8u), + 0x01, 0x02, 0x03, 0x01, +}; + +// One report per interface, so no Report ID item is needed. +inline constexpr uint8_t kDInputReportDescriptor[] = { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x05, // Usage (Game Pad) + 0xa1, 0x01, // Collection (Application) + + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xff, 0x7f, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x04, // Report Count (4) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x09, 0x33, // Usage (Rx) + 0x09, 0x34, // Usage (Ry) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0x15, 0x00, // Logical Minimum (0) + 0x27, 0xff, 0xff, 0x00, 0x00, // Logical Maximum (65535) + 0x95, 0x02, // Report Count (2) + 0x09, 0x32, // Usage (Z) + 0x09, 0x35, // Usage (Rz) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x07, // Logical Maximum (7) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3b, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (English Rotation, Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x09, 0x39, // Usage (Hat Switch) + 0x81, 0x42, // Input (Data, Variable, Absolute, Null State) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Constant, Variable, Absolute) + + 0x05, 0x09, // Usage Page (Button) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x35, 0x00, // Physical Minimum (0) + 0x45, 0x00, // Physical Maximum (0) + 0x65, 0x00, // Unit (None) + 0x19, 0x01, // Usage Minimum (Button 1) + 0x29, 0x10, // Usage Maximum (Button 16) + 0x75, 0x01, // Report Size (1) + 0x95, 0x10, // Report Count (16) + 0x81, 0x02, // Input (Data, Variable, Absolute) + 0xc0, // End Collection +}; + +inline constexpr uint8_t kMacReportDescriptor[] = { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x05, // Usage (Game Pad) + 0xa1, 0x01, // Collection (Application) + + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xff, 0x7f, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x04, // Report Count (4) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x09, 0x32, // Usage (Z) + 0x09, 0x33, // Usage (Rx) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0x15, 0x00, // Logical Minimum (0) + 0x27, 0xff, 0xff, 0x00, 0x00, // Logical Maximum (65535) + 0x95, 0x02, // Report Count (2) + 0x09, 0x34, // Usage (Ry) + 0x09, 0x35, // Usage (Rz) + 0x81, 0x02, // Input (Data, Variable, Absolute) + + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x07, // Logical Maximum (7) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3b, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (English Rotation, Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x09, 0x39, // Usage (Hat Switch) + 0x81, 0x42, // Input (Data, Variable, Absolute, Null State) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Constant, Variable, Absolute) + + 0x05, 0x09, // Usage Page (Button) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x35, 0x00, // Physical Minimum (0) + 0x45, 0x00, // Physical Maximum (0) + 0x65, 0x00, // Unit (None) + 0x19, 0x01, // Usage Minimum (Button 1) + 0x29, 0x10, // Usage Maximum (Button 16) + 0x75, 0x01, // Report Size (1) + 0x95, 0x10, // Report Count (16) + 0x81, 0x02, // Input (Data, Variable, Absolute) + 0xc0, // End Collection +}; + +static_assert(sizeof(kDInputReportDescriptor) == + sizeof(kMacReportDescriptor)); + +#define GENERIC_HID_INTERFACE(number, endpoint) \ + 0x09, 0x04, number, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, \ + 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, \ + static_cast(sizeof(kDInputReportDescriptor)), \ + static_cast(sizeof(kDInputReportDescriptor) >> 8u), \ + 0x07, 0x05, static_cast(0x80u | endpoint), 0x03, \ + kEndpointSize, 0x00, kEndpointIntervalMs + +inline constexpr uint8_t kConfigurationDescriptor[] = { + 0x09, 0x02, + static_cast(kConfigurationDescriptorSize), + static_cast(kConfigurationDescriptorSize >> 8u), + SWITCH_PICO_HID_INSTANCE_COUNT, + 0x01, 0x00, 0x80, 0xfa, + GENERIC_HID_INTERFACE(0x00, 0x01), +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 2 + GENERIC_HID_INTERFACE(0x01, 0x02), +#endif +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 3 + GENERIC_HID_INTERFACE(0x02, 0x03), +#endif +#if SWITCH_PICO_HID_INSTANCE_COUNT >= 4 + GENERIC_HID_INTERFACE(0x03, 0x04), +#endif +}; + +#undef GENERIC_HID_INTERFACE + +static_assert(sizeof(kDInputDeviceDescriptor) == 18); +static_assert(sizeof(kMacDeviceDescriptor) == 18); +static_assert(sizeof(kConfigurationDescriptor) == kConfigurationDescriptorSize); + +} // namespace GenericHid diff --git a/generic_hid_driver.cpp b/generic_hid_driver.cpp new file mode 100644 index 0000000..6b733a6 --- /dev/null +++ b/generic_hid_driver.cpp @@ -0,0 +1,85 @@ +#include "generic_hid_driver.h" + +#include + +#include "generic_hid_descriptors.h" + +namespace { + +struct GenericHidContext { + ControllerState input{}; + GenericHid::InputReport input_report{}; +}; + +GenericHidContext g_contexts[SWITCH_PICO_HID_INSTANCE_COUNT]{}; + +GenericHidContext* context_for(uint8_t instance) { + if (instance >= SWITCH_PICO_HID_INSTANCE_COUNT) { + return nullptr; + } + return &g_contexts[instance]; +} + + +void reset_context(GenericHidContext& context) { + context = {}; + context.input_report = GenericHid::build_input_report(context.input); +} + +} // namespace + +void generic_hid_init(uint8_t instance) { + GenericHidContext* context = context_for(instance); + if (context != nullptr) { + reset_context(*context); + } +} + +void generic_hid_set_input(uint8_t instance, const ControllerState& state) { + GenericHidContext* context = context_for(instance); + if (context == nullptr) { + return; + } + context->input = state; + context->input_report = GenericHid::build_input_report(state); +} + +bool generic_hid_task(uint8_t instance) { + GenericHidContext* context = context_for(instance); + return context != nullptr && tud_hid_n_ready(instance) && + tud_hid_n_report(instance, 0, &context->input_report, + sizeof(context->input_report)); +} + +bool generic_hid_is_ready(uint8_t instance) { + return context_for(instance) != nullptr && + tud_hid_n_ready(instance); +} + +uint16_t generic_hid_get_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t* buffer, + uint16_t requested_length) { + const GenericHidContext* context = context_for(instance); + if (context == nullptr || report_id != 0 || + report_type != HID_REPORT_TYPE_INPUT || buffer == nullptr || + requested_length == 0) { + return 0; + } + + uint16_t report_size = sizeof(context->input_report); + if (requested_length < report_size) { + report_size = requested_length; + } + memcpy(buffer, &context->input_report, report_size); + return report_size; +} + +const uint8_t* generic_hid_report_descriptor( + uint8_t instance, GenericHid::ReportDescriptorVariant variant) { + if (context_for(instance) == nullptr) { + return nullptr; + } + return variant == GenericHid::ReportDescriptorVariant::kMac + ? GenericHid::kMacReportDescriptor + : GenericHid::kDInputReportDescriptor; +} diff --git a/generic_hid_driver.h b/generic_hid_driver.h new file mode 100644 index 0000000..1c9d6e5 --- /dev/null +++ b/generic_hid_driver.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "controller_state.h" +#include "tusb.h" +namespace GenericHid { + +enum class ReportDescriptorVariant : uint8_t { + kDInput, + kMac, +}; + +} // namespace GenericHid + + +void generic_hid_init(uint8_t instance); +void generic_hid_set_input(uint8_t instance, const ControllerState& state); +bool generic_hid_task(uint8_t instance); +bool generic_hid_is_ready(uint8_t instance); + +uint16_t generic_hid_get_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t* buffer, + uint16_t requested_length); +const uint8_t* generic_hid_report_descriptor( + uint8_t instance, GenericHid::ReportDescriptorVariant variant); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index aa71e66..cbe4405 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Manage switch-pico persistent configuration, profiles, and pairings.""" +"""Manage switch-pico USB modes, configuration, profiles, and pairings.""" from __future__ import annotations @@ -17,7 +17,12 @@ from typing import Any, Protocol, cast import usb.core -USB_IDENTITIES = ((0x057E, 0x2009), (0xCAFE, 0x4010)) +USB_IDENTITIES = ( + (0x057E, 0x2009), + (0xCAFE, 0x4010), + (0xCAFE, 0x4020), + (0xCAFE, 0x4021), +) REQUEST_VALUE = 0x5350 REQUEST_INDEX = 0x0001 PROTOCOL_VERSION = 1 @@ -28,8 +33,11 @@ MAXIMUM_RESPONSE_SIZE = 293 MAXIMUM_CHUNK_SIZE = 40 USB_TIMEOUT_MS = 1000 DEFAULT_OPERATION_TIMEOUT_SECONDS = 15.0 +HOST_TRANSACTION_ID_MASK = 0x7FFFFFFF OP_INFO = 0x01 +OP_MODE_SET = 0x02 +OP_REBOOT = 0x03 OP_CONFIGURATION_READ = 0x10 OP_CONFIGURATION_BEGIN = 0x11 OP_CONFIGURATION_CHUNK = 0x12 @@ -61,10 +69,28 @@ STATUS_NAMES = { 8: "storage failure", } -CONFIGURATION_SCHEMA_VERSION = 1 -CONFIGURATION_SIZE = 4 +CONFIGURATION_SCHEMA_VERSION = 2 +CONFIGURATION_SIZE = 8 PAIRING_WINDOW_SECONDS_MIN = 10 PAIRING_WINDOW_SECONDS_MAX = 300 +REQUESTED_MODE_AUTO = 0 +REQUESTED_MODE_SWITCH = 1 +REQUESTED_MODE_XINPUT = 2 +REQUESTED_MODE_DINPUT = 3 +REQUESTED_MODE_MAC = 4 +REQUESTED_MODE_NAMES = ("auto", "switch", "xinput", "dinput", "mac") +SELECTABLE_MODE_NAMES = REQUESTED_MODE_NAMES +ACTIVE_MODE_SWITCH = 0 +ACTIVE_MODE_SWITCH_PROBE = 1 +ACTIVE_MODE_XINPUT = 2 +ACTIVE_MODE_DINPUT = 3 +ACTIVE_MODE_MAC = 4 +ACTIVE_MODE_NAMES = ("Switch", "Switch probe", "XInput", "DInput", "Mac") +# USB management info byte 5 capability flags. +CAPABILITY_INPUT = 1 << 0 +CAPABILITY_RUMBLE = 1 << 1 +CAPABILITY_MOTION = 1 << 2 +CAPABILITY_MASK = CAPABILITY_INPUT | CAPABILITY_RUMBLE | CAPABILITY_MOTION PAIRING_RECORD_SIZE = 8 PAIRING_RECORD_CAPACITY = 16 TRANSPORT_UNKNOWN = 0 @@ -123,6 +149,7 @@ class ConfigManagerError(RuntimeError): class UsbDevice(Protocol): bus: int | None address: int | None + port_numbers: tuple[int, ...] | None def ctrl_transfer( self, @@ -152,14 +179,38 @@ class DeviceInfo: firmware_version: tuple[int, int, int] board: int active_mode: int + capabilities: int maximum_configuration_size: int + def mode_name(self) -> str: + try: + return ACTIVE_MODE_NAMES[self.active_mode] + except IndexError as exc: + raise ConfigManagerError( + f"unknown active USB mode {self.active_mode}" + ) from exc + + def capability_names(self) -> tuple[str, ...]: + if self.capabilities == 0: + return ("unreported",) + names = ["input"] + if self.capabilities & CAPABILITY_RUMBLE: + names.append("rumble") + if self.capabilities & CAPABILITY_MOTION: + names.append("motion") + return tuple(names) + + def capability_summary(self) -> str: + names = self.capability_names() + return "input only" if names == ("input",) else ", ".join(names) + @dataclass(frozen=True) class AdapterConfiguration: pairing_window_seconds: int generation: int crc: int + requested_mode: int = REQUESTED_MODE_AUTO @dataclass(frozen=True) @@ -1168,6 +1219,10 @@ def _crc32(payload: bytes) -> int: return zlib.crc32(payload) & 0xFFFFFFFF +def _host_transaction_id() -> int: + return (secrets.randbits(31) & HOST_TRANSACTION_ID_MASK) or 1 + + def encode_request(operation: int, payload: bytes = b"") -> bytes: if len(payload) + REQUEST_HEADER_SIZE > MAXIMUM_REQUEST_SIZE: raise ConfigManagerError("management request exceeds EP0 limit") @@ -1269,6 +1324,18 @@ def read_info(device: UsbDevice) -> DeviceInfo: _raise_status(envelope) if len(envelope.payload) != 8: raise ConfigManagerError("invalid device-info payload") + active_mode = envelope.payload[4] + capabilities = envelope.payload[5] + if active_mode >= len(ACTIVE_MODE_NAMES): + raise ConfigManagerError(f"unknown active USB mode {active_mode}") + if capabilities & ~CAPABILITY_MASK: + raise ConfigManagerError( + f"unknown device capability flags 0x{capabilities:02x}" + ) + if capabilities != 0 and not capabilities & CAPABILITY_INPUT: + raise ConfigManagerError( + "device capability flags omit required input support" + ) return DeviceInfo( firmware_version=( envelope.payload[0], @@ -1276,7 +1343,8 @@ def read_info(device: UsbDevice) -> DeviceInfo: envelope.payload[2], ), board=envelope.payload[3], - active_mode=envelope.payload[4], + active_mode=active_mode, + capabilities=capabilities, maximum_configuration_size=struct.unpack_from( " AdapterConfiguration: if ( envelope.schema_version != CONFIGURATION_SCHEMA_VERSION or len(envelope.payload) != CONFIGURATION_SIZE - or envelope.payload[2:] != b"\x00\x00" + or envelope.payload[3:] != bytes(5) ): raise ConfigManagerError("unsupported configuration object") pairing_window_seconds = struct.unpack_from("= len(REQUESTED_MODE_NAMES): + raise ConfigManagerError( + f"invalid stored requested USB mode {requested_mode}" + ) return AdapterConfiguration( pairing_window_seconds=pairing_window_seconds, generation=envelope.generation, crc=envelope.payload_crc, + requested_mode=requested_mode, ) @@ -1340,8 +1414,17 @@ def write_configuration( raise ConfigManagerError( "pairing window must be between 10 and 300 seconds" ) - payload = struct.pack(" TransactionStatus: - transaction_id = secrets.randbits(32) or 1 + transaction_id = _host_transaction_id() _control_out( device, OP_CONFIGURATION_RESET, struct.pack(" TransactionStatus: + if type(requested_mode) is not int or requested_mode not in ( + REQUESTED_MODE_AUTO, + REQUESTED_MODE_SWITCH, + REQUESTED_MODE_XINPUT, + REQUESTED_MODE_DINPUT, + REQUESTED_MODE_MAC, + ): + raise ConfigManagerError("requested USB mode is not available") + transaction_id = _host_transaction_id() + _control_out( + device, + OP_MODE_SET, + struct.pack(" None: + _require_int( + transaction_id, "transaction ID", 1, HOST_TRANSACTION_ID_MASK + ) + _control_out(device, OP_REBOOT, struct.pack(" bool: + if requested_mode == REQUESTED_MODE_AUTO: + return active_mode in (ACTIVE_MODE_SWITCH_PROBE, ACTIVE_MODE_XINPUT) + if requested_mode == REQUESTED_MODE_SWITCH: + return active_mode == ACTIVE_MODE_SWITCH + if requested_mode == REQUESTED_MODE_XINPUT: + return active_mode == ACTIVE_MODE_XINPUT + if requested_mode == REQUESTED_MODE_DINPUT: + return active_mode == ACTIVE_MODE_DINPUT + if requested_mode == REQUESTED_MODE_MAC: + return active_mode == ACTIVE_MODE_MAC + return False + + def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: _raise_status(envelope) if envelope.schema_version not in ( @@ -1683,6 +1807,222 @@ def find_pico( raise ConfigManagerError("no USB-connected switch-pico firmware found") +_UsbPhysicalLocation = tuple[int, tuple[int, ...]] +_UsbEnumerationIdentity = tuple[int, int] + + +@dataclass(frozen=True) +class _ReenumerationSnapshot: + previous_device: UsbDevice + previous_bus: int | None + previous_address: int | None + selected_location: _UsbPhysicalLocation | None + other_locations: frozenset[_UsbPhysicalLocation] + other_enumerations: frozenset[_UsbEnumerationIdentity] + + +def _physical_location(device: UsbDevice) -> _UsbPhysicalLocation | None: + bus = getattr(device, "bus", None) + try: + port_numbers = getattr(device, "port_numbers", None) + except (AttributeError, NotImplementedError): + return None + if bus is None or port_numbers is None: + return None + ports = tuple(port_numbers) + if not ports: + return None + return bus, ports + + +def _enumeration_identity( + device: UsbDevice, +) -> _UsbEnumerationIdentity | None: + bus = getattr(device, "bus", None) + address = getattr(device, "address", None) + if bus is None or address is None: + return None + return bus, address + + +def _capture_reenumeration_snapshot( + previous_device: UsbDevice, +) -> _ReenumerationSnapshot: + previous_bus = getattr(previous_device, "bus", None) + previous_address = getattr(previous_device, "address", None) + previous_enumeration = _enumeration_identity(previous_device) + selected_location = _physical_location(previous_device) + other_locations: set[_UsbPhysicalLocation] = set() + other_enumerations: set[_UsbEnumerationIdentity] = set() + other_count = 0 + for device in _candidate_devices(): + enumeration = _enumeration_identity(device) + location = _physical_location(device) + if ( + device is previous_device + or ( + previous_enumeration is not None + and enumeration == previous_enumeration + ) + or ( + selected_location is not None + and location == selected_location + ) + ): + continue + other_count += 1 + if location is not None: + other_locations.add(location) + if enumeration is not None: + other_enumerations.add(enumeration) + + if selected_location is None and other_count: + raise ConfigManagerError( + "USB port topology is unavailable; cannot safely reboot while " + "multiple switch-pico adapters are connected" + ) + return _ReenumerationSnapshot( + previous_device, + previous_bus, + previous_address, + selected_location, + frozenset(other_locations), + frozenset(other_enumerations), + ) + + +def _is_previous_enumeration( + device: UsbDevice, snapshot: _ReenumerationSnapshot +) -> bool: + if device is snapshot.previous_device: + return True + identity = _enumeration_identity(device) + return ( + identity is not None + and snapshot.previous_bus is not None + and snapshot.previous_address is not None + and identity == (snapshot.previous_bus, snapshot.previous_address) + ) + + +def _is_reenumeration_candidate( + device: UsbDevice, snapshot: _ReenumerationSnapshot +) -> bool: + location = _physical_location(device) + enumeration = _enumeration_identity(device) + if location is not None: + if location in snapshot.other_locations: + return False + elif enumeration is not None and enumeration in snapshot.other_enumerations: + return False + + if snapshot.selected_location is not None: + return location == snapshot.selected_location + return ( + snapshot.previous_bus is None + or getattr(device, "bus", None) == snapshot.previous_bus + ) + + +def _wait_for_reenumeration( + snapshot: _ReenumerationSnapshot, timeout: float +) -> UsbDevice: + deadline = time.monotonic() + timeout + disappeared = False + failures: list[Exception] = [] + while True: + candidates = list(_candidate_devices()) + if not disappeared and not any( + _is_previous_enumeration(device, snapshot) + for device in candidates + ): + disappeared = True + if disappeared: + matches: list[UsbDevice] = [] + for device in candidates: + if not _is_reenumeration_candidate(device, snapshot): + continue + try: + _ = read_info(device) + except (ConfigManagerError, usb.core.USBError) as exc: + failures.append(exc) + continue + matches.append(device) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + locations = ", ".join( + f"{device.bus}:{device.address}" for device in matches + ) + if snapshot.selected_location is None: + raise ConfigManagerError( + "USB port topology is unavailable; multiple " + "switch-pico devices make reboot identity ambiguous " + f"({locations})" + ) + raise ConfigManagerError( + "multiple switch-pico devices re-enumerated on the " + f"selected USB port ({locations})" + ) + if time.monotonic() >= deadline: + break + time.sleep(0.05) + if not disappeared: + raise ConfigManagerError( + "Pico did not disappear from USB after the reboot request" + ) + if failures: + raise ConfigManagerError( + "Pico re-enumerated on the selected USB port, but did not accept " + f"the management request; last error: {failures[-1]}" + ) from failures[-1] + if snapshot.selected_location is not None: + raise ConfigManagerError( + "Pico did not re-enumerate on its original physical USB port " + "after reboot" + ) + raise ConfigManagerError("Pico did not re-enumerate after reboot") + + +def configure_mode( + device: UsbDevice, requested_mode: int, timeout: float +) -> tuple[UsbDevice, bool]: + if type(requested_mode) is not int or requested_mode not in ( + REQUESTED_MODE_AUTO, + REQUESTED_MODE_SWITCH, + REQUESTED_MODE_XINPUT, + REQUESTED_MODE_DINPUT, + REQUESTED_MODE_MAC, + ): + raise ConfigManagerError("requested USB mode is not available") + before_info = read_info(device) + before_configuration = read_configuration(device) + if ( + before_configuration.requested_mode == requested_mode + and _mode_is_active(requested_mode, before_info.active_mode) + ): + return device, False + reenumeration_snapshot = _capture_reenumeration_snapshot(device) + + transaction = set_mode(device, requested_mode, timeout) + request_reboot(device, transaction.transaction_id) + reenumerated = _wait_for_reenumeration( + reenumeration_snapshot, timeout + ) + after_info = read_info(reenumerated) + after_configuration = read_configuration(reenumerated) + if after_configuration.requested_mode != requested_mode: + raise ConfigManagerError( + "requested USB mode was not stored after reboot" + ) + if not _mode_is_active(requested_mode, after_info.active_mode): + raise ConfigManagerError( + f"device activated {after_info.mode_name()} instead of " + f"{REQUESTED_MODE_NAMES[requested_mode]}" + ) + return reenumerated, True + + def _print_pairings(snapshot: PairingSnapshot) -> None: if not snapshot.records: print("No stored pairings.") @@ -1780,8 +2120,8 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="switch-pico-config", description=( - "Manage switch-pico persistent configuration, profiles, " - "and pairings." + "Manage switch-pico USB modes, persistent configuration, " + "profiles, and pairings." ), ) parser.add_argument("--bus", type=int, help="USB bus number") @@ -1797,6 +2137,8 @@ def build_parser() -> argparse.ArgumentParser: ) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("status", help="show firmware and configuration status") + mode = commands.add_parser("mode", help="select the persistent USB mode") + mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES) config = commands.add_parser("config", help="read or change configuration") config_commands = config.add_subparsers(dest="config_command", required=True) @@ -1900,22 +2242,37 @@ def main(argv: Sequence[str] | None = None) -> int: info = read_info(device) configuration = read_configuration(device) version = ".".join(str(part) for part in info.firmware_version) - mode = "XInput" if info.active_mode else "Switch" print(f"Firmware: {version}") print(f"Board: Pico 2 W ({info.board})") - print(f"Active USB mode: {mode}") + print( + "Requested USB mode: " + f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}" + ) + print(f"Active USB mode: {info.mode_name()}") + print(f"Mode capabilities: {info.capability_summary()}") print(f"Configuration generation: {configuration.generation}") print(f"Configuration CRC: {configuration.crc:08x}") print( "Pairing window: " f"{configuration.pairing_window_seconds} seconds" ) + elif args.command == "mode": + requested_mode = REQUESTED_MODE_NAMES.index(args.mode) + _, changed = configure_mode(device, requested_mode, args.timeout) + if changed: + print(f"USB mode changed to {args.mode}.") + else: + print(f"USB mode is already {args.mode}.") elif args.command == "config": if args.config_command == "show": configuration = read_configuration(device) print( f"pairing_window_seconds={configuration.pairing_window_seconds}" ) + print( + "requested_mode=" + f"{REQUESTED_MODE_NAMES[configuration.requested_mode]}" + ) print(f"generation={configuration.generation}") print(f"crc={configuration.crc:08x}") elif args.config_command == "set": @@ -1926,6 +2283,7 @@ def main(argv: Sequence[str] | None = None) -> int: pairing_window_seconds=args.pairing_window_seconds, generation=before.generation, crc=before.crc, + requested_mode=before.requested_mode, ), args.timeout, ) diff --git a/switch-pico.cpp b/switch-pico.cpp index d315996..58d695d 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -3,16 +3,14 @@ #include "pico/stdlib.h" #include "tusb.h" #include "switch_pro_driver.h" +#include "usb_output_driver.h" #ifndef SWITCH_PICO_BLUEPAD32 #include "hardware/uart.h" #else +#include "adapter_mode_controller.h" #include "bluepad32_input_backend.h" -#include "controller_profile_runtime.h" #include "bootsel_pairing_button.h" -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY -#include "adapter_host_probe.h" -#include "xinput_feasibility_driver.h" -#endif +#include "controller_profile_runtime.h" #endif #ifdef SWITCH_PICO_LOG @@ -79,8 +77,8 @@ static void send_rumble_uart_frame(const ControllerRumbleOutput& rumble) { } #endif -static void on_rumble_from_switch(uint8_t instance, - const ControllerRumbleOutput& rumble) { +static void on_rumble_from_usb(uint8_t instance, + const ControllerRumbleOutput& rumble) { #ifdef SWITCH_PICO_BLUEPAD32 if (instance >= BLUEPAD32_INPUT_BACKEND_SLOT_COUNT) { return; @@ -188,30 +186,16 @@ static void log_usb_state() { #ifdef SWITCH_PICO_BLUEPAD32 for (uint8_t instance = 0; instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - const bool ready = - adapter_host_probe_mode() == AdapterUsbMode::kXInput - ? xinput_feasibility_is_ready(instance) - : switch_pro_is_ready(instance); -#else - const bool ready = switch_pro_is_ready(instance); -#endif + const bool ready = usb_output_driver_is_ready(instance); if (ready != g_last_ready[instance]) { g_last_ready[instance] = ready; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY LOG_PRINTF("[%s %u] driver %s\n", - adapter_host_probe_mode() == AdapterUsbMode::kXInput - ? "XINPUT" - : "SWITCH", - instance, ready ? "ready" : "not ready"); -#else - LOG_PRINTF("[SWITCH %u] driver %s\n", instance, - ready ? "ready (handshake OK)" : "not ready"); -#endif + usb_output_driver_name(), instance, + ready ? "ready" : "not ready"); } } #else - const bool ready = switch_pro_is_ready(SWITCH_HID_INSTANCE); + const bool ready = usb_output_driver_is_ready(SWITCH_HID_INSTANCE); if (ready != g_last_ready) { g_last_ready = ready; LOG_PRINTF("[SWITCH] driver %s\n", @@ -227,65 +211,36 @@ int main() { #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_init(); controller_profile_runtime_reset(); -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - adapter_host_probe_init(); -#endif + adapter_mode_controller_initialize_usb(); #else init_uart_input(); -#endif - + usb_output_driver_init(AdapterUsbMode::kSwitch); tusb_init(); +#endif #ifdef SWITCH_PICO_BLUEPAD32 for (uint8_t instance = 0; instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { - xinput_feasibility_init(instance); - xinput_feasibility_set_rumble_callback( - instance, on_rumble_from_switch); - g_user_states[instance] = neutral_input(); - xinput_feasibility_set_input(instance, - g_user_states[instance]); - } else { - switch_pro_init(instance); - switch_pro_set_rumble_callback(instance, - on_rumble_from_switch); - g_user_states[instance] = neutral_input(); - switch_pro_set_input( - instance, g_user_states[instance], - CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, - CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); - } -#else - switch_pro_init(instance); - switch_pro_set_rumble_callback(instance, on_rumble_from_switch); + usb_output_driver_set_rumble_callback(instance, + on_rumble_from_usb); g_user_states[instance] = neutral_input(); - switch_pro_set_input( + usb_output_driver_set_input( instance, g_user_states[instance], CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD, CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD); -#endif } #else - switch_pro_init(SWITCH_HID_INSTANCE); - switch_pro_set_rumble_callback(SWITCH_HID_INSTANCE, - on_rumble_from_switch); + usb_output_driver_set_rumble_callback(SWITCH_HID_INSTANCE, + on_rumble_from_usb); g_user_state = neutral_input(); - switch_pro_set_input(SWITCH_HID_INSTANCE, g_user_state, - SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, - SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + usb_output_driver_set_input(SWITCH_HID_INSTANCE, g_user_state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); #endif #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_start(); -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - LOG_PRINTF("[BOOT] adapter feasibility mode=%s\n", - adapter_host_probe_mode() == AdapterUsbMode::kXInput - ? "XInput" - : "Switch probe"); -#else - LOG_PRINTF("[BOOT] switch-pico starting (Bluepad32 wireless @ 115200)\n"); -#endif + LOG_PRINTF("[BOOT] adapter mode=%s\n", + usb_output_driver_mode_name()); #else LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n"); LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n", @@ -301,22 +256,23 @@ int main() { bluepad32_input_backend_open_pairing_window(); break; case BootselPairingButtonEvent::kClearPairings: - bluepad32_input_backend_clear_pairings(); + adapter_mode_controller_begin_recovery(); break; case BootselPairingButtonEvent::kNone: break; } const uint32_t now_ms = static_cast(to_ms_since_boot(get_absolute_time())); -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - const AdapterUsbMode output_mode = adapter_host_probe_mode(); -#else - constexpr AdapterUsbMode output_mode = AdapterUsbMode::kSwitchProbe; -#endif + const AdapterUsbMode output_mode = usb_output_driver_mode(); for (uint8_t instance = 0; instance < BLUEPAD32_INPUT_BACKEND_SLOT_COUNT; ++instance) { Bluepad32SlotSnapshot snapshot{}; bluepad32_input_backend_snapshot(instance, &snapshot); + adapter_mode_controller_process_input( + instance, snapshot.active, + snapshot.connection_generation, + &snapshot.pre_hotkey_button_mask, now_ms, + &snapshot.state); const ControllerProfileTransformResult transformed = controller_profile_runtime_transform( instance, snapshot, now_ms, output_mode); @@ -341,40 +297,23 @@ int main() { } } g_user_states[instance] = transformed.state; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - bool sent = false; - if (output_mode == AdapterUsbMode::kXInput) { - xinput_feasibility_set_input(instance, - g_user_states[instance]); - sent = xinput_feasibility_task(instance); - } else { - switch_pro_set_input( - instance, g_user_states[instance], - transformed.left_trigger_digital_threshold, - transformed.right_trigger_digital_threshold); - sent = switch_pro_task(instance); - } - if (sent) { - bluepad32_input_backend_report_sent(instance); - } -#else - switch_pro_set_input( + usb_output_driver_set_input( instance, g_user_states[instance], transformed.left_trigger_digital_threshold, transformed.right_trigger_digital_threshold); - if (switch_pro_task(instance)) { + if (usb_output_driver_task(instance)) { bluepad32_input_backend_report_sent(instance); } -#endif } + adapter_mode_controller_task(now_ms); #else bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; ControllerState state = g_user_state; - switch_pro_set_input(SWITCH_HID_INSTANCE, state, - SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, - SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); - (void)switch_pro_task(SWITCH_HID_INSTANCE); + usb_output_driver_set_input(SWITCH_HID_INSTANCE, state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + (void)usb_output_driver_task(SWITCH_HID_INSTANCE); #endif log_usb_state(); } diff --git a/switch_pro_driver.cpp b/switch_pro_driver.cpp index fe8a1f2..590e92d 100644 --- a/switch_pro_driver.cpp +++ b/switch_pro_driver.cpp @@ -8,10 +8,6 @@ #include "pico/rand.h" #include "pico/time.h" #include "tusb.h" -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY -#include "adapter_host_probe.h" -#include "xinput_feasibility_descriptors.h" -#endif #ifdef SWITCH_PICO_LOG #define LOG_PRINTF(...) printf(__VA_ARGS__) @@ -1034,10 +1030,11 @@ bool switch_pro_is_ready(uint8_t instance) { return context != nullptr && context->is_ready; } -// HID callbacks -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) { +// TinyUSB callback helpers routed by usb_output_driver. +uint16_t switch_pro_hid_get_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, + uint8_t* buffer, + uint16_t requested_length) { (void)report_id; (void)report_type; SwitchProContext* context = context_for(instance); @@ -1095,9 +1092,9 @@ static void process_output_report(uint8_t instance, } } -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 buffer_size) { +void switch_pro_hid_set_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, + const uint8_t* buffer, uint16_t buffer_size) { SwitchProContext* context = context_for(instance); if (context == nullptr || report_type != HID_REPORT_TYPE_OUTPUT) { return; @@ -1105,9 +1102,9 @@ void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, process_output_report(instance, *context, report_id, buffer, buffer_size); } -void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, - const uint8_t* buffer, - uint16_t buffer_size) { +void switch_pro_hid_report_received(uint8_t instance, uint8_t report_id, + const uint8_t* buffer, + uint16_t buffer_size) { SwitchProContext* context = context_for(instance); if (context == nullptr) { return; @@ -1115,114 +1112,17 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, process_output_report(instance, *context, report_id, buffer, buffer_size); } -uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) { +uint8_t const* switch_pro_hid_report_descriptor(uint8_t instance) { if (context_for(instance) == nullptr) { return nullptr; } return switch_pro_report_descriptor; } -uint8_t const* tud_descriptor_device_cb(void) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { - return XInputFeasibility::kDeviceDescriptor; - } - return XInputFeasibility::kSwitchProbeDeviceDescriptor; -#else - return switch_pro_device_descriptor; -#endif -} - -uint8_t const* tud_descriptor_configuration_cb(uint8_t index) { - (void)index; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { - return XInputFeasibility::kConfigurationDescriptor; - } -#endif - return switch_pro_configuration_descriptor; -} - -bool tud_control_request_cb(uint8_t rhport, - tusb_control_request_t const* request) { - (void)rhport; - (void)request; - LOG_PRINTF( - "[CTRL] bmReq=0x%02x bReq=0x%02x wValue=0x%04x wIndex=0x%04x " - "wLen=%u\n", - request->bmRequestType, request->bRequest, request->wValue, - request->wIndex, request->wLength); - return false; // let TinyUSB handle it normally -} - -void tud_mount_cb(void) { - LOG_PRINTF("[USB] mount_cb\n"); +void switch_pro_mount() { reset_all_contexts(false); } -void tud_umount_cb(void) { - LOG_PRINTF("[USB] umount_cb\n"); +void switch_pro_unmount() { reset_all_contexts(false); } - -static uint16_t desc_str[32]; - -uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) { - (void)langid; - -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - adapter_host_probe_note_string_descriptor(index); - if (index == 0xee) { - static constexpr char kSignature[] = "MSFT100"; - for (uint8_t i = 0; i < sizeof(kSignature) - 1; ++i) { - desc_str[1 + i] = kSignature[i]; - } - desc_str[8] = XInputFeasibility::kMsVendorRequest; - desc_str[0] = static_cast((0x03 << 8) | 18); - return desc_str; - } -#endif - - uint8_t chr_count = 0; - if (index == 0) { - memcpy(&desc_str[1], switch_pro_string_language, 2); - chr_count = 1; - } else { - const uint8_t* str = nullptr; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - static const uint8_t kManufacturer[] = "Switch Pico"; - static const uint8_t kProduct[] = "XInput Feasibility"; - static const uint8_t kSerial[] = "XINPUT-PROTOTYPE"; - static const uint8_t* const kXInputStrings[] = { - nullptr, kManufacturer, kProduct, kSerial}; - if (adapter_host_probe_mode() == AdapterUsbMode::kXInput) { - if (index >= sizeof(kXInputStrings) / - sizeof(kXInputStrings[0])) { - return nullptr; - } - str = kXInputStrings[index]; - } else -#endif - { - if (index >= sizeof(switch_pro_string_descriptors) / - sizeof(switch_pro_string_descriptors[0])) { - return nullptr; - } - str = switch_pro_string_descriptors[index]; - } - - while (str[chr_count] != 0) { - ++chr_count; - } - if (chr_count > 31) { - chr_count = 31; - } - for (uint8_t i = 0; i < chr_count; ++i) { - desc_str[1 + i] = str[i]; - } - } - - desc_str[0] = - static_cast((0x03 << 8) | (2 * chr_count + 2)); - return desc_str; -} diff --git a/switch_pro_driver.h b/switch_pro_driver.h index 5cc51f5..3c3561a 100644 --- a/switch_pro_driver.h +++ b/switch_pro_driver.h @@ -11,6 +11,7 @@ #include "controller_color.h" #include "controller_state.h" #include "switch_haptics.h" +#include "tusb.h" #include "switch_pro_descriptors.h" // Preserve the pre-neutral-state 35%-of-1023 digital trigger boundary. constexpr uint32_t SWITCH_PRO_LEGACY_TRIGGER_RANGE_MAXIMUM = 1023; @@ -45,4 +46,19 @@ bool switch_pro_apply_uart_packet(const uint8_t* packet, uint8_t length, bool switch_pro_is_ready(uint8_t instance); void switch_pro_set_rumble_callback(uint8_t instance, - ControllerRumbleCallback callback); \ No newline at end of file + ControllerRumbleCallback callback); + +// TinyUSB-facing helpers called only by usb_output_driver. +uint16_t switch_pro_hid_get_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, + uint8_t* buffer, + uint16_t requested_length); +void switch_pro_hid_set_report(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, + const uint8_t* buffer, uint16_t buffer_size); +void switch_pro_hid_report_received(uint8_t instance, uint8_t report_id, + const uint8_t* buffer, + uint16_t buffer_size); +uint8_t const* switch_pro_hid_report_descriptor(uint8_t instance); +void switch_pro_mount(); +void switch_pro_unmount(); \ No newline at end of file diff --git a/tests/adapter_host_probe_test.cpp b/tests/adapter_host_probe_test.cpp new file mode 100644 index 0000000..b346111 --- /dev/null +++ b/tests/adapter_host_probe_test.cpp @@ -0,0 +1,209 @@ +#include "adapter_host_probe.h" + +#include +#include +#include +#include + +#include "hardware/structs/watchdog.h" +#include "pico/time.h" +#include "xinput_descriptors.h" + +namespace { + +constexpr uint32_t kXInputBootMagic = 0x58494e50u; +watchdog_hw_t watchdog_registers{}; +uint64_t now_ms = 0; +alarm_callback_t pending_alarm = nullptr; +void* pending_alarm_user_data = nullptr; +int64_t pending_alarm_delay_ms = 0; +int alarm_count = 0; +int reset_count = 0; +int reboot_count = 0; +int call_sequence = 0; +int reset_sequence = 0; +int reboot_sequence = 0; +bool control_result = true; +int control_count = 0; +const void* control_buffer = nullptr; +uint16_t control_length = 0; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +void reset_harness(uint32_t scratch) { + watchdog_registers = {}; + watchdog_registers.scratch[0] = scratch; + now_ms = 0; + pending_alarm = nullptr; + pending_alarm_user_data = nullptr; + pending_alarm_delay_ms = 0; + alarm_count = 0; + reset_count = 0; + reboot_count = 0; + call_sequence = 0; + reset_sequence = 0; + reboot_sequence = 0; + control_result = true; + control_count = 0; + control_buffer = nullptr; + control_length = 0; +} + +tusb_control_request_t microsoft_request() { + tusb_control_request_t request{}; + request.bmRequestType_bit.direction = TUSB_DIR_IN; + request.bmRequestType_bit.type = TUSB_REQ_TYPE_VENDOR; + request.bmRequestType_bit.recipient = TUSB_REQ_RCPT_DEVICE; + request.bRequest = XInput::kMsVendorRequest; + request.wIndex = XInput::kMsCompatIdIndex; + return request; +} + +void test_auto_scratch_lifecycle() { + reset_harness(0); + adapter_host_probe_init(AdapterRequestedMode::kAuto); + require(adapter_host_probe_mode() == AdapterUsbMode::kSwitchProbe && + watchdog_registers.scratch[0] == 0, + "auto cold boot did not select Switch probe"); + + reset_harness(kXInputBootMagic); + adapter_host_probe_init(AdapterRequestedMode::kAuto); + require(adapter_host_probe_mode() == AdapterUsbMode::kXInput && + watchdog_registers.scratch[0] == 0, + "auto transition token did not select one XInput boot"); + + adapter_host_probe_init(AdapterRequestedMode::kAuto); + require(adapter_host_probe_mode() == AdapterUsbMode::kSwitchProbe, + "consumed scratch token caused a reboot loop"); + + reset_harness(0xa5a55a5au); + adapter_host_probe_init(AdapterRequestedMode::kAuto); + require(adapter_host_probe_mode() == AdapterUsbMode::kSwitchProbe && + watchdog_registers.scratch[0] == 0, + "stale non-token scratch was not consumed safely"); +} + +void test_manual_modes_bypass_and_consume_probe_state() { + tusb_control_request_t request = microsoft_request(); + + reset_harness(kXInputBootMagic); + adapter_host_probe_init(AdapterRequestedMode::kSwitch); + require(adapter_host_probe_mode() == AdapterUsbMode::kSwitch && + watchdog_registers.scratch[0] == 0, + "manual Switch honored stale auto scratch"); + adapter_host_probe_note_string_descriptor(0xee); + require(!adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_count == 0 && alarm_count == 0, + "manual Switch entered the host probe path"); + + reset_harness(0xdeadbeefu); + adapter_host_probe_init(AdapterRequestedMode::kXInput); + require(adapter_host_probe_mode() == AdapterUsbMode::kXInput && + watchdog_registers.scratch[0] == 0, + "manual XInput honored stale scratch"); + require(adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_count == 1 && + control_length == sizeof(XInput::kMsCompatIdDescriptor) && + std::memcmp(control_buffer, XInput::kMsCompatIdDescriptor, + control_length) == 0 && + alarm_count == 0, + "manual XInput did not serve XInput descriptors without probing"); + + reset_harness(kXInputBootMagic); + adapter_host_probe_init(AdapterRequestedMode::kDInput); + require(adapter_host_probe_mode() == AdapterUsbMode::kDInput && + watchdog_registers.scratch[0] == 0 && alarm_count == 0, + "manual DInput did not bypass and consume stale auto scratch"); + adapter_host_probe_note_string_descriptor(0xee); + require(!adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_count == 0 && alarm_count == 0, + "manual DInput entered the XInput probe path"); + + reset_harness(kXInputBootMagic); + adapter_host_probe_init(AdapterRequestedMode::kMac); + require(adapter_host_probe_mode() == AdapterUsbMode::kMac && + watchdog_registers.scratch[0] == 0 && alarm_count == 0, + "manual Mac did not bypass and consume stale auto scratch"); + adapter_host_probe_note_string_descriptor(0xee); + require(!adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_count == 0 && alarm_count == 0, + "manual Mac entered the XInput probe path"); +} + +void test_probe_transition_resets_before_watchdog() { + reset_harness(0); + adapter_host_probe_init(AdapterRequestedMode::kAuto); + adapter_host_probe_note_string_descriptor(0xee); + tusb_control_request_t request = microsoft_request(); + require(adapter_host_probe_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_length == + sizeof(XInput::kProbeMsCompatIdDescriptor) && + std::memcmp(control_buffer, + XInput::kProbeMsCompatIdDescriptor, + control_length) == 0 && + alarm_count == 1 && pending_alarm_delay_ms == 100 && + reboot_count == 0, + "auto probe did not queue the exact delayed transition"); + + pending_alarm(1, pending_alarm_user_data); + require(reset_count == 1 && reboot_count == 1 && + reset_sequence < reboot_sequence && + watchdog_registers.scratch[0] == kXInputBootMagic, + "probe reboot did not reset synthetic state before scratch reset"); + + adapter_host_probe_init(AdapterRequestedMode::kAuto); + require(adapter_host_probe_mode() == AdapterUsbMode::kXInput && + watchdog_registers.scratch[0] == 0, + "probe transition was not consumed on the next boot"); +} + +} // namespace + +watchdog_hw_t* watchdog_hw = &watchdog_registers; + +absolute_time_t get_absolute_time() { return now_ms; } +uint64_t to_ms_since_boot(absolute_time_t time) { return time; } + +alarm_id_t add_alarm_in_ms(int64_t delay_ms, alarm_callback_t callback, + void* user_data, bool) { + ++alarm_count; + pending_alarm_delay_ms = delay_ms; + pending_alarm = callback; + pending_alarm_user_data = user_data; + return alarm_count; +} + +bool tud_control_xfer(uint8_t, const tusb_control_request_t*, void* buffer, + uint16_t length) { + ++control_count; + control_buffer = buffer; + control_length = length; + return control_result; +} + +void controller_profile_runtime_reset() { + ++reset_count; + reset_sequence = ++call_sequence; +} + +void watchdog_reboot(uint32_t, uint32_t, uint32_t) { + ++reboot_count; + reboot_sequence = ++call_sequence; +} + +int main() { + test_auto_scratch_lifecycle(); + test_manual_modes_bypass_and_consume_probe_state(); + test_probe_transition_resets_before_watchdog(); + return 0; +} diff --git a/tests/adapter_mode_controller_test.cpp b/tests/adapter_mode_controller_test.cpp new file mode 100644 index 0000000..e6fb36e --- /dev/null +++ b/tests/adapter_mode_controller_test.cpp @@ -0,0 +1,663 @@ +#include "adapter_mode_controller.h" + +#include +#include +#include +#include +#include + +#include "adapter_configuration.h" +#include "adapter_reboot.h" +#include "bluepad32_input_backend.h" +#include "configuration_service.h" +#include "controller_profile.h" + +namespace { + +enum class Call : uint8_t { + kPreload, + kSnapshot, + kProbeInit, + kOutputInit, + kTinyUsbInit, + kSetMode, + kQueryMode, + kRebootReady, + kRuntimeReset, + kFeedback, + kReserveRecovery, + kClearPairings, + kPairingSnapshot, + kWatchdogReboot, +}; + +std::vector calls; +AdapterRequestedMode stored_mode = AdapterRequestedMode::kAuto; +AdapterRequestedMode probed_requested_mode = AdapterRequestedMode::kAuto; +AdapterUsbMode probed_active_mode = AdapterUsbMode::kSwitchProbe; +AdapterUsbMode initialized_output_mode = AdapterUsbMode::kSwitchProbe; +std::vector set_results; +size_t next_set_result = 0; +std::vector set_transaction_ids; +std::vector set_modes; +bool query_matches = true; +ConfigurationTransactionStatus query_status = + ConfigurationTransactionStatus::kPending; +uint32_t last_query_transaction_id = 0; +uint32_t last_reboot_ready_transaction_id = 0; +bool reboot_ready = true; +std::vector reboot_ready_results; +size_t next_reboot_ready_result = 0; +Bluepad32PairingSnapshotStatus pairing_status = + Bluepad32PairingSnapshotStatus::kPending; +uint32_t pairing_generation = 0; +uint32_t pairing_completed_clear_token = 0; +uint32_t clear_pairings_result_token = 1; +uint8_t feedback_slot = 0; +uint32_t feedback_generation = 0; +uint8_t feedback_pulses = 0; +ControllerProfileConfirmationPolicy feedback_policy = + ControllerProfileConfirmationPolicy::kNone; +int reboot_count = 0; +int runtime_reset_count = 0; +int clear_pairings_count = 0; +bool recovery_reserved = false; +bool abandoned_host_receive = false; +bool abandoned_host_receive_canceled = false; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + std::exit(1); + } +} + +void reset_harness(AdapterRequestedMode requested_mode = + AdapterRequestedMode::kAuto) { + calls.clear(); + stored_mode = requested_mode; + probed_requested_mode = AdapterRequestedMode::kAuto; + probed_active_mode = AdapterUsbMode::kSwitchProbe; + initialized_output_mode = AdapterUsbMode::kSwitchProbe; + set_results.clear(); + next_set_result = 0; + set_transaction_ids.clear(); + set_modes.clear(); + query_matches = true; + query_status = ConfigurationTransactionStatus::kPending; + last_query_transaction_id = 0; + last_reboot_ready_transaction_id = 0; + reboot_ready = true; + reboot_ready_results.clear(); + next_reboot_ready_result = 0; + pairing_status = Bluepad32PairingSnapshotStatus::kPending; + pairing_generation = 0; + pairing_completed_clear_token = 0; + clear_pairings_result_token = 1; + feedback_slot = 0; + feedback_generation = 0; + feedback_pulses = 0; + feedback_policy = ControllerProfileConfirmationPolicy::kNone; + reboot_count = 0; + runtime_reset_count = 0; + clear_pairings_count = 0; + recovery_reserved = false; + abandoned_host_receive = false; + abandoned_host_receive_canceled = false; + adapter_mode_controller_initialize_usb(); + calls.clear(); +} + +ControllerState held_state() { + ControllerState state{}; + state.button_left_shoulder = true; + state.button_right_shoulder = true; + state.button_select = true; + state.button_start = true; + state.button_system = true; + return state; +} + +uint16_t sample(uint8_t slot, uint32_t generation, uint16_t mask, + uint32_t now_ms, ControllerState* state) { + adapter_mode_controller_process_input(slot, true, generation, &mask, + now_ms, state); + return mask; +} + +void begin_hold(uint8_t slot, uint32_t generation, uint32_t start_ms) { + ControllerState state = held_state(); + sample(slot, generation, ADAPTER_MODE_CHORD_BUTTON_MASK, start_ms, + &state); +} + +void finish_hold(uint8_t slot, uint32_t generation, uint32_t start_ms) { + ControllerState state = held_state(); + sample(slot, generation, ADAPTER_MODE_CHORD_BUTTON_MASK, + start_ms + ADAPTER_MODE_CHORD_HOLD_MS, &state); +} + +void test_pre_tusb_ordering_and_configured_selection() { + calls.clear(); + stored_mode = AdapterRequestedMode::kXInput; + probed_active_mode = AdapterUsbMode::kXInput; + adapter_mode_controller_initialize_usb(); + require(calls == std::vector({ + Call::kPreload, Call::kSnapshot, + Call::kProbeInit, Call::kOutputInit, + Call::kTinyUsbInit}), + "persistent mode was not consumed before output init and tusb"); + require(probed_requested_mode == AdapterRequestedMode::kXInput && + initialized_output_mode == AdapterUsbMode::kXInput && + adapter_mode_controller_requested_mode() == + AdapterRequestedMode::kXInput, + "preloaded configured mode did not reach the frozen driver"); +} + +void test_mode_availability_has_one_stable_value() { + const AdapterModeAvailability& first = + adapter_usb_mode_availability(); + const AdapterModeAvailability& second = + adapter_usb_mode_availability(); + require(&first == &second && first.switch_mode && + first.xinput_mode && first.dinput_mode && + first.mac_mode, + "mode availability was not the shared five-mode value"); +} + +void test_exact_hold_release_wrap_and_consumption() { + reset_harness(); + ControllerState state = held_state(); + state.button_south = true; + const uint16_t consumed_mask = sample( + 0, 7, ADAPTER_MODE_CHORD_BUTTON_MASK, 100, &state); + require(consumed_mask == 0 && + !state.button_left_shoulder && + !state.button_right_shoulder && !state.button_select && + !state.button_start && !state.button_system && + state.button_south, + "held raw mode chord was not consumed before profile processing"); + + state = held_state(); + sample(0, 7, ADAPTER_MODE_CHORD_BUTTON_MASK, 3099, &state); + adapter_mode_controller_task(3099); + require(set_transaction_ids.empty(), + "mode chord fired before exactly three seconds"); + state = {}; + sample(0, 7, 0, 3100, &state); + begin_hold(0, 7, 4000); + finish_hold(0, 7, 4000); + set_results = {ConfigurationTransactionStatus::kBusy}; + adapter_mode_controller_task(7000); + require(set_transaction_ids.size() == 1, + "release did not rearm a full three-second hold"); + + reset_harness(); + constexpr uint32_t kWrapStart = UINT32_MAX - 999u; + begin_hold(0, 9, kWrapStart); + ControllerState wrap_state = held_state(); + sample(0, 9, ADAPTER_MODE_CHORD_BUTTON_MASK, 1999, &wrap_state); + adapter_mode_controller_task(1999); + require(set_transaction_ids.empty(), + "wrap-safe hold fired one millisecond early"); + wrap_state = held_state(); + sample(0, 9, ADAPTER_MODE_CHORD_BUTTON_MASK, 2000, &wrap_state); + set_results = {ConfigurationTransactionStatus::kBusy}; + adapter_mode_controller_task(2000); + require(set_transaction_ids.size() == 1, + "three-second hold failed across uint32 wrap"); +} + +AdapterRequestedMode triggered_target(AdapterRequestedMode initial) { + reset_harness(initial); + begin_hold(0, 1, 0); + finish_hold(0, 1, 0); + set_results = {ConfigurationTransactionStatus::kBusy}; + adapter_mode_controller_task(ADAPTER_MODE_CHORD_HOLD_MS); + require(set_modes.size() == 1, "cycle did not submit a mode request"); + return set_modes[0]; +} + +void test_cycle_and_slot_isolation() { + require(triggered_target(AdapterRequestedMode::kAuto) == + AdapterRequestedMode::kSwitch, + "Auto did not cycle to Switch"); + require(triggered_target(AdapterRequestedMode::kSwitch) == + AdapterRequestedMode::kXInput, + "Switch did not cycle to XInput"); + require(triggered_target(AdapterRequestedMode::kXInput) == + AdapterRequestedMode::kDInput, + "XInput did not cycle to DInput"); + require(triggered_target(AdapterRequestedMode::kDInput) == + AdapterRequestedMode::kMac, + "DInput did not cycle to Mac"); + require(triggered_target(AdapterRequestedMode::kMac) == + AdapterRequestedMode::kAuto, + "Mac did not cycle to Auto"); + + reset_harness(); + begin_hold(0, 10, 0); + begin_hold(1, 20, 1000); + ControllerState released{}; + sample(0, 10, 0, 2999, &released); + finish_hold(1, 20, 1000); + set_results = {ConfigurationTransactionStatus::kBusy}; + adapter_mode_controller_task(4000); + require(set_transaction_ids.size() == 1, + "one slot's release reset another slot's hold"); + + reset_harness(); + begin_hold(2, 30, 0); + ControllerState state = held_state(); + sample(2, 31, ADAPTER_MODE_CHORD_BUTTON_MASK, 3000, &state); + adapter_mode_controller_task(3000); + require(set_transaction_ids.empty(), + "connection generation change inherited a prior hold"); + uint16_t inactive_mask = ADAPTER_MODE_CHORD_BUTTON_MASK; + adapter_mode_controller_process_input( + 2, false, 31, &inactive_mask, 6000, &state); + sample(2, 31, ADAPTER_MODE_CHORD_BUTTON_MASK, 6001, &state); + adapter_mode_controller_task(6001); + require(set_transaction_ids.empty(), + "inactive slot retained mode chord state"); +} + +void test_busy_retry_and_one_shot() { + reset_harness(); + begin_hold(0, 1, 0); + finish_hold(0, 1, 0); + set_results = { + ConfigurationTransactionStatus::kBusy, + ConfigurationTransactionStatus::kBusy, + ConfigurationTransactionStatus::kPending, + }; + adapter_mode_controller_task(3000); + adapter_mode_controller_task(3001); + adapter_mode_controller_task(3002); + require(set_transaction_ids.size() == 3 && + set_transaction_ids[0] == set_transaction_ids[1] && + set_transaction_ids[1] == set_transaction_ids[2] && + (set_transaction_ids[0] & 0x80000000u) != 0, + "busy retry did not preserve one high-bit transaction ID"); + + query_status = ConfigurationTransactionStatus::kStorageError; + adapter_mode_controller_task(3003); + require(last_query_transaction_id == set_transaction_ids[0], + "commit polling lost internal transaction correlation"); + ControllerState state = held_state(); + sample(0, 1, ADAPTER_MODE_CHORD_BUTTON_MASK, 9000, &state); + adapter_mode_controller_task(9000); + require(set_transaction_ids.size() == 3 && reboot_count == 0, + "continuous hold retriggered after a failed transaction"); + + state = {}; + sample(0, 1, 0, 9001, &state); + begin_hold(0, 1, 10000); + finish_hold(0, 1, 10000); + set_results.push_back(ConfigurationTransactionStatus::kStorageError); + adapter_mode_controller_task(13000); + require(set_transaction_ids.size() == 4 && + set_transaction_ids[3] != set_transaction_ids[0], + "release did not create exactly one new internal transaction"); +} + +void test_commit_feedback_then_reboot() { + reset_harness(AdapterRequestedMode::kAuto); + begin_hold(3, 44, 0); + finish_hold(3, 44, 0); + set_results = {ConfigurationTransactionStatus::kPending}; + adapter_mode_controller_task(3000); + query_status = ConfigurationTransactionStatus::kCommitted; + adapter_mode_controller_task(3001); + require(runtime_reset_count == 1 && feedback_slot == 3 && + feedback_generation == 44 && feedback_pulses == 2 && + feedback_policy == + ControllerProfileConfirmationPolicy::kRumbleAndLed && + reboot_count == 0 && + calls[calls.size() - 2] == Call::kRuntimeReset && + calls.back() == Call::kFeedback, + "commit did not cancel synthetic state then acknowledge Switch"); + + adapter_mode_controller_task(3375); + require(reboot_count == 0, + "reboot occurred before bounded mode feedback completed"); + adapter_mode_controller_task(3376); + require(reboot_count == 1 && runtime_reset_count == 2 && + calls[calls.size() - 2] == Call::kRuntimeReset && + calls.back() == Call::kWatchdogReboot, + "committed mode did not reset synthetic state at watchdog reboot"); + adapter_mode_controller_task(4000); + require(reboot_count == 1 && runtime_reset_count == 2, + "scheduled watchdog reboot looped"); +} + +void test_feedback_distinguishes_all_modes() { + struct FeedbackCase { + AdapterRequestedMode initial_mode; + AdapterRequestedMode target_mode; + uint8_t pulses; + ControllerProfileConfirmationPolicy policy; + }; + constexpr FeedbackCase cases[] = { + {AdapterRequestedMode::kAuto, AdapterRequestedMode::kSwitch, 2, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + {AdapterRequestedMode::kSwitch, AdapterRequestedMode::kXInput, 3, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + {AdapterRequestedMode::kXInput, AdapterRequestedMode::kDInput, 4, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + {AdapterRequestedMode::kDInput, AdapterRequestedMode::kMac, 4, + ControllerProfileConfirmationPolicy::kLed}, + {AdapterRequestedMode::kMac, AdapterRequestedMode::kAuto, 1, + ControllerProfileConfirmationPolicy::kRumbleAndLed}, + }; + + for (const FeedbackCase& expected : cases) { + reset_harness(expected.initial_mode); + begin_hold(1, 23, 0); + finish_hold(1, 23, 0); + set_results = {ConfigurationTransactionStatus::kCommitted}; + adapter_mode_controller_task(ADAPTER_MODE_CHORD_HOLD_MS); + require(set_modes.size() == 1 && + set_modes[0] == expected.target_mode && + feedback_slot == 1 && feedback_generation == 23 && + feedback_pulses == expected.pulses && + feedback_policy == expected.policy && + reboot_count == 0, + "mode feedback tuple did not uniquely identify its target"); + + const uint32_t feedback_duration = + static_cast(expected.pulses) * 2u * 75u + 75u; + adapter_mode_controller_task( + ADAPTER_MODE_CHORD_HOLD_MS + feedback_duration - 1u); + require(reboot_count == 0, + "mode feedback deadline was shorter than its bounded pulse sequence"); + adapter_mode_controller_task( + ADAPTER_MODE_CHORD_HOLD_MS + feedback_duration); + require(reboot_count == 1, + "mode feedback did not reboot at its bounded deadline"); + } +} + +void test_configuration_failure_and_correlated_reboot() { + reset_harness(); + begin_hold(0, 1, 0); + finish_hold(0, 1, 0); + set_results = {ConfigurationTransactionStatus::kStorageError}; + adapter_mode_controller_task(3000); + require(runtime_reset_count == 0 && feedback_pulses == 0 && + reboot_count == 0, + "failed internal mode write rebooted or acknowledged"); + + reset_harness(); + reboot_ready = false; + require(!adapter_reboot_for_mode_transaction(12) && + runtime_reset_count == 0 && reboot_count == 0, + "non-current host mode transaction rebooted"); + reboot_ready = true; + require(!adapter_reboot_for_mode_transaction(0x80000001u) && + reboot_count == 0, + "internal transaction used the management reboot API"); + require(adapter_reboot_for_mode_transaction(12) && + last_reboot_ready_transaction_id == 12 && + runtime_reset_count == 1 && reboot_count == 1 && + calls[calls.size() - 2] == Call::kRuntimeReset && + calls.back() == Call::kWatchdogReboot, + "current committed host transaction did not reset then reboot"); + require(adapter_reboot_for_mode_transaction(12) && + reboot_count == 1 && runtime_reset_count == 1, + "duplicate normal reboot was not latched"); +} + +void test_recovery_auto_wins_host_mode_interleaving() { + reset_harness(AdapterRequestedMode::kXInput); + begin_hold(0, 5, 0); + finish_hold(0, 5, 0); + calls.clear(); + pairing_status = Bluepad32PairingSnapshotStatus::kReady; + pairing_generation = 41; + clear_pairings_result_token = 17; + abandoned_host_receive = true; + adapter_mode_controller_begin_recovery(); + adapter_mode_controller_begin_recovery(); + require(clear_pairings_count == 1 && recovery_reserved && + abandoned_host_receive_canceled && + !abandoned_host_receive && calls.size() >= 2 && + calls[0] == Call::kReserveRecovery && + calls[1] == Call::kClearPairings, + "BOOTSEL recovery must reserve and cancel abandoned host receive " + "before requesting one pairing clear"); + require(!adapter_reboot_for_mode_transaction(77) && + last_reboot_ready_transaction_id == 0 && + reboot_count == 0, + "host mode traffic rebooted during active recovery"); + + set_results = { + ConfigurationTransactionStatus::kBusy, + ConfigurationTransactionStatus::kPending, + ConfigurationTransactionStatus::kPending, + }; + adapter_mode_controller_task(0); + pairing_status = Bluepad32PairingSnapshotStatus::kReady; + pairing_generation = 42; + adapter_mode_controller_task(1); + require(set_modes.empty(), + "unrelated pairing refresh advanced recovery"); + + pairing_completed_clear_token = clear_pairings_result_token - 1; + adapter_mode_controller_task(2); + require(set_modes.empty(), + "pre-request pairing clear completion advanced recovery"); + + pairing_status = Bluepad32PairingSnapshotStatus::kPending; + adapter_mode_controller_task(3); + require(set_modes.empty(), + "pending recovery pairing clear advanced recovery"); + + // Recovery clear N completed, then host clear N+1 was accepted and + // completed before recovery observed N. N+1 must still acknowledge N. + pairing_status = Bluepad32PairingSnapshotStatus::kReady; + pairing_completed_clear_token = clear_pairings_result_token + 1; + adapter_mode_controller_task(4); + adapter_mode_controller_task(5); + require(set_modes.size() == 2 && + set_modes[0] == AdapterRequestedMode::kAuto && + set_modes[1] == AdapterRequestedMode::kAuto && + set_transaction_ids[0] == set_transaction_ids[1], + "later clear completion did not busy-retry recovery Auto"); + const size_t first_set_call = static_cast( + std::find(calls.begin(), calls.end(), Call::kSetMode) - + calls.begin()); + const size_t clear_call = static_cast( + std::find(calls.begin(), calls.end(), Call::kClearPairings) - + calls.begin()); + require(clear_call < first_set_call, + "recovery submitted Auto before requesting bond clear"); + + // Model a stale mode commit result. Reboot readiness rejects it, so + // recovery submits a new correlated Auto transaction. + reboot_ready_results = {false, true}; + query_status = ConfigurationTransactionStatus::kCommitted; + adapter_mode_controller_task(6); + require(reboot_count == 0 && set_transaction_ids.size() == 2, + "superseded recovery Auto rebooted"); + + adapter_mode_controller_task(7); + require(set_transaction_ids.size() == 3 && + set_modes[2] == AdapterRequestedMode::kAuto && + set_transaction_ids[2] != set_transaction_ids[1] && + reboot_count == 0, + "superseded Auto did not yield to a fresh recovery transaction"); + adapter_mode_controller_task(8); + require(last_reboot_ready_transaction_id == + set_transaction_ids[2] && + adapter_mode_controller_requested_mode() == + AdapterRequestedMode::kAuto && + feedback_pulses == 0 && runtime_reset_count == 1 && + reboot_count == 1 && + calls[calls.size() - 2] == Call::kRuntimeReset && + calls.back() == Call::kWatchdogReboot, + "recovery did not make Auto final and reboot immediately"); +} + +void test_failed_recovery_never_reboots() { + reset_harness(AdapterRequestedMode::kXInput); + clear_pairings_result_token = 0; + abandoned_host_receive = true; + adapter_mode_controller_begin_recovery(); + pairing_status = Bluepad32PairingSnapshotStatus::kReady; + pairing_completed_clear_token = 1; + adapter_mode_controller_task(0); + require(recovery_reserved && abandoned_host_receive_canceled && + clear_pairings_count == 1 && set_modes.empty() && + !adapter_reboot_for_mode_transaction(91) && + last_reboot_ready_transaction_id == 0 && + runtime_reset_count == 0 && reboot_count == 0, + "failed pairing clear released recovery ownership or rebooted"); + + reset_harness(AdapterRequestedMode::kXInput); + clear_pairings_result_token = UINT32_MAX; + adapter_mode_controller_begin_recovery(); + pairing_status = Bluepad32PairingSnapshotStatus::kReady; + pairing_completed_clear_token = 1; + set_results = {ConfigurationTransactionStatus::kStorageError}; + adapter_mode_controller_task(1); + adapter_mode_controller_task(2); + require(set_modes.size() == 1 && + set_modes[0] == AdapterRequestedMode::kAuto && + !adapter_reboot_for_mode_transaction(92) && + last_reboot_ready_transaction_id == 0 && + runtime_reset_count == 0 && feedback_pulses == 0 && + reboot_count == 0, + "wrapped clear completion did not start exactly one recovery Auto, " + "or failed recovery acknowledged, released ownership, or retried"); +} + +} // namespace + +void configuration_service_initialize_pre_usb() { + calls.push_back(Call::kPreload); +} + +void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { + calls.push_back(Call::kSnapshot); + *output = {}; + output->state = ConfigurationServiceState::kReady; + output->configuration.requested_mode = stored_mode; +} + +void configuration_service_reserve_for_recovery() { + calls.push_back(Call::kReserveRecovery); + recovery_reserved = true; + if (abandoned_host_receive) { + abandoned_host_receive = false; + abandoned_host_receive_canceled = true; + } +} + +ConfigurationTransactionStatus configuration_service_set_mode_internal( + uint32_t transaction_id, AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability) { + require(!recovery_reserved || + requested_mode == AdapterRequestedMode::kAuto, + "recovery reservation admitted a non-Auto internal mode"); + calls.push_back(Call::kSetMode); + require(availability.switch_mode && availability.xinput_mode && + availability.dinput_mode && availability.mac_mode, + "mode controller did not advertise all compiled drivers"); + set_transaction_ids.push_back(transaction_id); + set_modes.push_back(requested_mode); + if (next_set_result >= set_results.size()) { + return ConfigurationTransactionStatus::kBusy; + } + return set_results[next_set_result++]; +} + +bool configuration_service_mode_transaction_status( + uint32_t transaction_id, ConfigurationTransactionStatus* output) { + calls.push_back(Call::kQueryMode); + last_query_transaction_id = transaction_id; + if (!query_matches) { + return false; + } + *output = query_status; + return true; +} + +bool configuration_service_mode_transaction_reboot_ready( + uint32_t transaction_id) { + calls.push_back(Call::kRebootReady); + last_reboot_ready_transaction_id = transaction_id; + if (next_reboot_ready_result < reboot_ready_results.size()) { + return reboot_ready_results[next_reboot_ready_result++]; + } + return reboot_ready; +} + +void adapter_host_probe_init(AdapterRequestedMode requested_mode) { + calls.push_back(Call::kProbeInit); + probed_requested_mode = requested_mode; +} + +AdapterUsbMode adapter_host_probe_mode() { return probed_active_mode; } + +void tusb_init() { + calls.push_back(Call::kTinyUsbInit); +} +void usb_output_driver_init(AdapterUsbMode mode) { + calls.push_back(Call::kOutputInit); + initialized_output_mode = mode; +} + +void controller_profile_runtime_reset() { + calls.push_back(Call::kRuntimeReset); + ++runtime_reset_count; +} + +void bluepad32_input_backend_queue_profile_feedback( + uint8_t slot, uint32_t connection_generation, + uint8_t active_profile_number, + ControllerProfileConfirmationPolicy policy) { + calls.push_back(Call::kFeedback); + feedback_slot = slot; + feedback_generation = connection_generation; + feedback_pulses = active_profile_number; + feedback_policy = policy; +} + +uint32_t bluepad32_input_backend_clear_pairings() { + calls.push_back(Call::kClearPairings); + require(recovery_reserved, + "pairing clear was requested before recovery reservation"); + ++clear_pairings_count; + pairing_status = Bluepad32PairingSnapshotStatus::kPending; + return clear_pairings_result_token; +} + +void bluepad32_input_backend_pairing_snapshot( + Bluepad32PairingSnapshot* output) { + calls.push_back(Call::kPairingSnapshot); + *output = {}; + output->status = pairing_status; + output->generation = pairing_generation; + output->completed_clear_pairings_token = + pairing_completed_clear_token; +} + +void watchdog_reboot(uint32_t, uint32_t, uint32_t) { + calls.push_back(Call::kWatchdogReboot); + ++reboot_count; +} + +int main() { + test_pre_tusb_ordering_and_configured_selection(); + test_mode_availability_has_one_stable_value(); + test_exact_hold_release_wrap_and_consumption(); + test_cycle_and_slot_isolation(); + test_busy_retry_and_one_shot(); + test_commit_feedback_then_reboot(); + test_feedback_distinguishes_all_modes(); + test_configuration_failure_and_correlated_reboot(); + test_recovery_auto_wins_host_mode_interleaving(); + test_failed_recovery_never_reboots(); + return 0; +} diff --git a/tests/bluepad32_backend_lifecycle_test.cpp b/tests/bluepad32_backend_lifecycle_test.cpp index b380457..c499e64 100644 --- a/tests/bluepad32_backend_lifecycle_test.cpp +++ b/tests/bluepad32_backend_lifecycle_test.cpp @@ -48,6 +48,12 @@ int device_disconnect_calls = 0; uni_hid_device_t* last_disconnected_device = nullptr; uni_hid_device_t* lookup_devices[8]{}; size_t lookup_device_count = 0; +uint32_t expected_pending_clear_token = 0; +uint32_t repeated_in_progress_clear_token = 0; +bool repeat_clear_during_disconnect = false; +void require_clear_completion_pending(); +void require_clear_snapshot_published(); +void request_repeated_clear_during_disconnect(); gap_connection_type_t gap_connection_types[256]{}; struct CoreStopped {}; @@ -129,8 +135,9 @@ uni_hid_device_t* uni_hid_device_get_instance_for_connection_handle( } return nullptr; } - void uni_hid_device_disconnect(uni_hid_device_t* device) { + require_clear_completion_pending(); + request_repeated_clear_during_disconnect(); ++device_disconnect_calls; last_disconnected_device = device; } @@ -165,6 +172,8 @@ void uni_bt_le_scan_stop() { } void uni_bt_start_scanning_and_autoconnect_unsafe() { + require_clear_completion_pending(); + require_clear_snapshot_published(); uni_bt_bredr_scan_start(); uni_bt_le_scan_start(); } @@ -174,6 +183,7 @@ void uni_bt_stop_scanning_unsafe() { uni_bt_le_scan_stop(); } void uni_bt_del_keys_unsafe() { + require_clear_completion_pending(); ++delete_key_calls; classic_bond_count = 0; ble_bond_count = 0; @@ -434,6 +444,30 @@ uint32_t btstack_run_loop_get_time_ms() { #include "../controller_identity.cpp" #include "../bluepad32_input_backend.cpp" +namespace { +void require_clear_completion_pending() { + if (expected_pending_clear_token != 0) { + require(!bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, expected_pending_clear_token), + "pairing clear token completed before Core1 work finished"); + } +} +void require_clear_snapshot_published() { + if (expected_pending_clear_token != 0) { + require(g_pairing_snapshot.status == + Bluepad32PairingSnapshotStatus::kReady && + g_pairing_snapshot.record_count == 0, + "pairing clear policy ran before empty snapshot publication"); + } +} +void request_repeated_clear_during_disconnect() { + if (repeat_clear_during_disconnect) { + repeat_clear_during_disconnect = false; + repeated_in_progress_clear_token = + bluepad32_input_backend_clear_pairings(); + } +} +} // namespace ControllerIdentity observed_profile_identities[8]{}; size_t observed_profile_identity_count = 0; void configuration_service_prepare() {} @@ -470,7 +504,7 @@ void configuration_service_snapshot(ConfigurationServiceSnapshot* output) { output->configuration.pairing_window_seconds = ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT; } -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES AdapterUsbMode test_adapter_mode = AdapterUsbMode::kXInput; AdapterUsbMode adapter_host_probe_mode() { return test_adapter_mode; @@ -1811,7 +1845,7 @@ void test_stateful_host_rumble_restore() { generations[slot] = snapshot.connection_generation; } -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES test_adapter_mode = AdapterUsbMode::kXInput; const ControllerRumbleOutput desired[kSlotCount] = { {0x11, 0x21}, {0x12, 0x22}, {0x13, 0x23}, {0x14, 0x24}}; @@ -2075,7 +2109,7 @@ void test_host_rumble_mode_duration() { require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, "rumble-mode controller did not become ready"); -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES test_adapter_mode = AdapterUsbMode::kSwitchProbe; #endif bluepad32_input_backend_queue_rumble( @@ -2085,7 +2119,7 @@ void test_host_rumble_mode_duration() { kSwitchHostRumbleDurationMs, "Switch mode did not use bounded host rumble"); -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES test_adapter_mode = AdapterUsbMode::kXInput; bluepad32_input_backend_queue_rumble( 0, ControllerRumbleOutput{102, 103}); @@ -2111,8 +2145,12 @@ void test_clear_pairings() { g_pairing_snapshot.records[1].transport == Bluepad32PairingTransport::kBle, "initial pairing snapshot must enumerate Classic and BLE bonds"); + require(!bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, 0), + "zero pairing-clear token must never be a valid completion query"); const uint32_t snapshot_generation = g_pairing_snapshot.generation; + g_next_clear_pairings_request_token = UINT32_MAX; uni_hid_device_t devices[2] = {device(0), device(1)}; for (uni_hid_device_t& controller : devices) { require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS, @@ -2121,20 +2159,41 @@ void test_clear_pairings() { bluepad32_input_backend_queue_rumble( 0, ControllerRumbleOutput{100, 101}); - bluepad32_input_backend_clear_pairings(); - require(g_clear_pairings_requested && delete_key_calls == 0 && - device_disconnect_calls == 0, - "Core0 pairing reset request must wait for Core1"); + const uint32_t clear_token = + bluepad32_input_backend_clear_pairings(); + const uint32_t repeated_token = + bluepad32_input_backend_clear_pairings(); + expected_pending_clear_token = clear_token; + repeat_clear_during_disconnect = true; + g_connection_policy_state = + ConnectionPolicyState::Uninitialized; + require(clear_token == UINT32_MAX && + repeated_token == clear_token && + g_clear_pairings_requested_token == clear_token && + g_pairing_snapshot.completed_clear_pairings_token == 0 && + !bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, clear_token) && + delete_key_calls == 0 && device_disconnect_calls == 0, + "Core0 repeated pending clear calls must share one nonzero token, " + "remain incomplete, and defer the operation to Core1"); process_rumble_timer(&g_rumble_timer); + require(repeated_in_progress_clear_token == clear_token, + "clear repeated during Core1 work did not share its token"); require(delete_key_calls == 1 && device_disconnect_calls == 2, "pairing reset must delete bonds and disconnect every session"); require(g_pairing_snapshot.status == Bluepad32PairingSnapshotStatus::kReady && g_pairing_snapshot.record_count == 0 && g_pairing_snapshot.generation == - snapshot_generation + 1, - "pairing reset must publish an empty refreshed snapshot"); + snapshot_generation + 1 && + g_pairing_snapshot.completed_clear_pairings_token == + clear_token && + bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, clear_token), + "pairing reset must publish its completion token with an empty " + "refreshed snapshot after policy update"); + expected_pending_clear_token = 0; for (const BackendSlot& slot : g_slots) { require(slot.device == nullptr && !slot.active && !slot.rumble_pending && !slot.feedback_pending && @@ -2156,8 +2215,56 @@ void test_clear_pairings() { require(!observed_status_led_on, "pairing reset confirmation must use the rapid blink pattern"); process_rumble_timer(&g_rumble_timer); - require(delete_key_calls == 1 && device_disconnect_calls == 2, + require(delete_key_calls == 1 && device_disconnect_calls == 2 && + g_pairing_snapshot.completed_clear_pairings_token == + clear_token, "pairing reset request must execute only once"); + + const uint32_t completed_generation = + g_pairing_snapshot.generation; + bluepad32_input_backend_request_pairing_snapshot(); + require(g_pairing_snapshot.status == + Bluepad32PairingSnapshotStatus::kPending && + bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, clear_token), + "unrelated refresh revoked an already completed clear"); + process_rumble_timer(&g_rumble_timer); + require(g_pairing_snapshot.generation == + completed_generation + 1 && + g_pairing_snapshot.completed_clear_pairings_token == + clear_token && + bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, clear_token), + "ordinary pairing refresh fabricated or revoked clear completion"); + + const uint32_t refreshed_generation = + g_pairing_snapshot.generation; + const uint32_t wrapped_token = + bluepad32_input_backend_clear_pairings(); + require(wrapped_token == 1 && + g_pairing_snapshot.status == + Bluepad32PairingSnapshotStatus::kPending && + g_pairing_snapshot.completed_clear_pairings_token == + clear_token && + !bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, wrapped_token) && + bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, clear_token), + "UINT32_MAX-to-1 wrap reused the prior completion or revoked it"); + expected_pending_clear_token = wrapped_token; + process_rumble_timer(&g_rumble_timer); + expected_pending_clear_token = 0; + require(delete_key_calls == 2 && device_disconnect_calls == 2 && + g_pairing_snapshot.generation == + refreshed_generation + 1 && + g_pairing_snapshot.completed_clear_pairings_token == + wrapped_token && + bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, wrapped_token) && + bluepad32_input_backend_clear_pairings_completed( + g_pairing_snapshot, clear_token), + "wrapped clear completion did not acknowledge both itself and " + "the earlier pre-wrap request exactly once"); } void test_configuration_timer_rearms_before_storage_work() { diff --git a/tests/configuration_service_test.cpp b/tests/configuration_service_test.cpp new file mode 100644 index 0000000..557bf55 --- /dev/null +++ b/tests/configuration_service_test.cpp @@ -0,0 +1,521 @@ +#include "adapter_configuration.h" +#include "configuration_service.h" +#include "configuration_storage.h" +#include "pico_configuration_storage.h" + +#include +#include +#include + +namespace { + +constexpr size_t kSectorSize = 4096; +constexpr size_t kPageSize = 256; + +struct FakeFlash { + uint8_t bytes[CONFIGURATION_STORAGE_COPY_COUNT][kSectorSize]; + int read_count = 0; + int erase_count = 0; + int program_count = 0; + bool fail_program = false; + + FakeFlash() { memset(bytes, 0xff, sizeof(bytes)); } +}; + +FakeFlash g_flash; +bool g_reserve_recovery_during_program = false; + +void require(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + std::exit(1); + } +} + +bool fake_read(void* context, uint8_t copy, size_t offset, + uint8_t* output, size_t size) { + auto* flash = static_cast(context); + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || + offset + size > kSectorSize) { + return false; + } + memcpy(output, &flash->bytes[copy][offset], size); + ++flash->read_count; + return true; +} + +bool fake_erase(void* context, uint8_t copy) { + auto* flash = static_cast(context); + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT) { + return false; + } + memset(flash->bytes[copy], 0xff, kSectorSize); + ++flash->erase_count; + return true; +} + +bool fake_program(void* context, uint8_t copy, size_t offset, + const uint8_t* data, size_t size) { + auto* flash = static_cast(context); + if (copy >= CONFIGURATION_STORAGE_COPY_COUNT || size != kPageSize || + offset + size > kSectorSize || flash->fail_program) { + return false; + } + if (g_reserve_recovery_during_program) { + g_reserve_recovery_during_program = false; + configuration_service_reserve_for_recovery(); + } + for (size_t index = 0; index < size; ++index) { + flash->bytes[copy][offset + index] &= data[index]; + } + ++flash->program_count; + return true; +} + +ConfigurationStorageIo fake_io() { + return { + &g_flash, + kSectorSize, + kPageSize, + fake_read, + fake_erase, + fake_program, + }; +} + +void require_mode_status(uint32_t transaction_id, + ConfigurationTransactionStatus expected, + const char* message) { + ConfigurationTransactionStatus actual = + ConfigurationTransactionStatus::kIdle; + require(configuration_service_mode_transaction_status(transaction_id, + &actual) && + actual == expected, + message); +} + +} // namespace + +ConfigurationStorageIo pico_configuration_storage_io() { + return fake_io(); +} + +namespace { + +void seed_legacy_configuration() { + ConfigurationStorage seed; + const uint8_t legacy[] = {90, 0, 0, 0}; + require(seed.initialize(fake_io()), "legacy seed storage init failed"); + require(seed.commit(ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, + legacy, sizeof(legacy)) == + ConfigurationStorageResult::kOk, + "legacy seed commit failed"); +} + +void test_service_lifecycle_and_mutations() { + seed_legacy_configuration(); + const int programs_after_seed = g_flash.program_count; + const int erases_after_seed = g_flash.erase_count; + + configuration_service_prepare(); + configuration_service_initialize_pre_usb(); + + ConfigurationServiceSnapshot snapshot{}; + configuration_service_snapshot(&snapshot); + require(snapshot.state == ConfigurationServiceState::kReady && + snapshot.configuration.pairing_window_seconds == 90 && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto, + "Core 0 did not decode and publish the v1 configuration"); + require(g_flash.program_count == programs_after_seed && + g_flash.erase_count == erases_after_seed, + "pre-USB initialization wrote flash"); + + const AdapterModeAvailability implemented{true, true, true, true}; + require(configuration_service_set_mode( + 1, AdapterRequestedMode::kSwitch, implemented) == + ConfigurationTransactionStatus::kBusy, + "host mutation was accepted before storage-core adoption"); + + const int reads_before_adoption = g_flash.read_count; + configuration_service_initialize_on_storage_core(); + require(g_flash.read_count == reads_before_adoption && + g_flash.program_count == programs_after_seed && + g_flash.erase_count == erases_after_seed, + "Core 1 reread or wrote instead of adopting Core 0 state"); + require(configuration_service_set_mode( + 2, AdapterRequestedMode::kSwitch, implemented) == + ConfigurationTransactionStatus::kBusy, + "host mutation displaced the pending v1 migration"); + + configuration_service_task_on_storage_core(0); + configuration_service_snapshot(&snapshot); + require(snapshot.state == ConfigurationServiceState::kReady && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto && + snapshot.transaction.status == + ConfigurationTransactionStatus::kIdle, + "v1 migration changed configuration or host transaction state"); + + ConfigurationStorage after_migration; + require(after_migration.initialize(fake_io()) && + after_migration.snapshot().valid && + after_migration.snapshot().schema_version == + ADAPTER_CONFIGURATION_SCHEMA_VERSION && + after_migration.snapshot().payload_size == + ADAPTER_CONFIGURATION_ENCODED_SIZE, + "power cycle did not observe the migrated v2 record"); + AdapterConfiguration migrated{}; + require(adapter_configuration_decode( + after_migration.snapshot().schema_version, + after_migration.snapshot().payload, + after_migration.snapshot().payload_size, &migrated) && + migrated.pairing_window_seconds == 90 && + migrated.requested_mode == AdapterRequestedMode::kAuto, + "migrated v2 bytes did not preserve v1 configuration"); + + require(configuration_service_set_mode( + 10, AdapterRequestedMode::kSwitch, implemented) == + ConfigurationTransactionStatus::kPending, + "host mode transaction was not queued"); + require_mode_status(10, ConfigurationTransactionStatus::kPending, + "host mode correlation was not pending"); + require(configuration_service_set_mode_internal( + 0x80000001u, AdapterRequestedMode::kXInput, implemented) == + ConfigurationTransactionStatus::kBusy, + "internal mode displaced a pending host mode"); + configuration_service_task_on_storage_core(999); + require_mode_status(10, ConfigurationTransactionStatus::kPending, + "write-rate guard committed host mode too early"); + configuration_service_task_on_storage_core(1000); + require_mode_status(10, ConfigurationTransactionStatus::kCommitted, + "host mode transaction did not commit"); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.pairing_window_seconds == 90 && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kSwitch && + snapshot.transaction.transaction_id == 10 && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted, + "mode-only commit changed pairing state or host status"); + + const int programs_before_rejections = g_flash.program_count; + const AdapterModeAvailability unavailable{true, true, false, false}; + require(configuration_service_set_mode( + 11, AdapterRequestedMode::kDInput, unavailable) == + ConfigurationTransactionStatus::kUnsupportedSchema && + configuration_service_set_mode( + 11, static_cast(5), implemented) == + ConfigurationTransactionStatus::kMalformed && + configuration_service_set_mode( + 0x80000011u, AdapterRequestedMode::kXInput, + implemented) == + ConfigurationTransactionStatus::kMalformed && + configuration_service_begin( + 0x80000012u, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + ADAPTER_CONFIGURATION_ENCODED_SIZE, 0) == + ConfigurationTransactionStatus::kMalformed && + configuration_service_reset(0x80000013u) == + ConfigurationTransactionStatus::kMalformed, + "unavailable, invalid, or internal-range host request was accepted"); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.requested_mode == + AdapterRequestedMode::kSwitch && + snapshot.transaction.transaction_id == 10 && + g_flash.program_count == programs_before_rejections, + "rejected mode request corrupted persisted or host-visible state"); + + require(configuration_service_set_mode( + 12, AdapterRequestedMode::kSwitch, implemented) == + ConfigurationTransactionStatus::kPending, + "unchanged host mode was not serialized"); + const int programs_before_unchanged = g_flash.program_count; + configuration_service_task_on_storage_core(2000); + require_mode_status(12, ConfigurationTransactionStatus::kUnchanged, + "unchanged host mode did not terminate unchanged"); + require(g_flash.program_count == programs_before_unchanged, + "unchanged mode consumed a flash program"); + require(configuration_service_mode_transaction_reboot_ready(12), + "current unchanged host mode could not authorize reboot"); + + constexpr uint32_t kInternalXInput = 0x80000020u; + require(configuration_service_set_mode_internal( + kInternalXInput, AdapterRequestedMode::kXInput, + implemented) == ConfigurationTransactionStatus::kPending && + configuration_service_set_mode_internal( + kInternalXInput, AdapterRequestedMode::kXInput, + implemented) == ConfigurationTransactionStatus::kPending, + "accepted internal mode was not idempotently pending"); + require(!configuration_service_mode_transaction_reboot_ready(12), + "accepted internal mode did not immediately supersede host reboot"); + configuration_service_snapshot(&snapshot); + const ConfigurationTransactionSnapshot preserved_host = + snapshot.transaction; + require(configuration_service_begin( + 20, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + ADAPTER_CONFIGURATION_ENCODED_SIZE, 0) == + ConfigurationTransactionStatus::kBusy && + configuration_service_reset(21) == + ConfigurationTransactionStatus::kBusy, + "host mutation was not blocked by pending internal mode"); + configuration_service_task_on_storage_core(2000); + require_mode_status(12, ConfigurationTransactionStatus::kUnchanged, + "internal commit overwrote retained host status"); + require_mode_status(kInternalXInput, + ConfigurationTransactionStatus::kCommitted, + "internal mode transaction did not commit"); + require(!configuration_service_mode_transaction_reboot_ready(12) && + configuration_service_mode_transaction_reboot_ready( + kInternalXInput), + "stale host generation authorized reboot after internal commit"); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.pairing_window_seconds == 90 && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kXInput && + snapshot.transaction.transaction_id == + preserved_host.transaction_id && + snapshot.transaction.status == preserved_host.status && + snapshot.mode_transaction.transaction_id == kInternalXInput && + snapshot.mode_transaction.stored_generation == + snapshot.generation, + "internal mode changed host status or was not published"); + require(configuration_service_set_mode( + 13, AdapterRequestedMode::kAuto, implemented) == + ConfigurationTransactionStatus::kPending, + "host mode did not serialize after internal mode"); + require(!configuration_service_mode_transaction_reboot_ready( + kInternalXInput), + "accepted host mode did not immediately supersede internal reboot"); + configuration_service_task_on_storage_core(3000); + require_mode_status(kInternalXInput, + ConfigurationTransactionStatus::kCommitted, + "host commit overwrote retained internal status"); + require_mode_status(13, ConfigurationTransactionStatus::kCommitted, + "interleaved host mode transaction did not commit"); + require(!configuration_service_mode_transaction_reboot_ready( + kInternalXInput) && + configuration_service_mode_transaction_reboot_ready(13), + "stale internal generation authorized reboot after host commit"); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto && + snapshot.mode_transaction.transaction_id == 13 && + !snapshot.mode_transaction.internal && + snapshot.mode_transaction.stored_generation == + snapshot.generation, + "latest host mode transaction was not published"); + + AdapterConfiguration generic = snapshot.configuration; + generic.pairing_window_seconds = 120; + uint8_t generic_payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + require(adapter_configuration_encode(generic, generic_payload, + sizeof(generic_payload)), + "generic test configuration did not encode"); + const uint32_t generic_crc = + configuration_crc32(generic_payload, sizeof(generic_payload)); + require(configuration_service_begin( + 30, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(generic_payload), generic_crc) == + ConfigurationTransactionStatus::kReceiving, + "generic host transaction did not begin"); + require(!configuration_service_mode_transaction_reboot_ready(13), + "accepted generic configuration did not invalidate mode reboot"); + require(configuration_service_set_mode_internal( + 0x80000021u, AdapterRequestedMode::kAuto, implemented) == + ConfigurationTransactionStatus::kBusy, + "internal mode displaced receiving host configuration"); + require(configuration_service_append( + 30, 0, generic_payload, sizeof(generic_payload)) == + ConfigurationTransactionStatus::kReceiving && + configuration_service_commit(30) == + ConfigurationTransactionStatus::kPending, + "generic host configuration did not reach pending"); + configuration_service_task_on_storage_core(4000); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.pairing_window_seconds == 120 && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto && + snapshot.transaction.transaction_id == 30 && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted, + "generic host commit did not preserve requested mode"); + require_mode_status(13, ConfigurationTransactionStatus::kCommitted, + "generic commit erased retained host mode status"); + require(!configuration_service_mode_transaction_reboot_ready(13), + "host mode authorized reboot for a stale stored generation"); + ConfigurationTransactionStatus ignored{}; + require(!configuration_service_mode_transaction_status(30, &ignored), + "generic transaction was misidentified as a mode transaction"); + + constexpr uint32_t kFailingInternal = 0x80000022u; + require(configuration_service_set_mode_internal( + kFailingInternal, AdapterRequestedMode::kXInput, + implemented) == ConfigurationTransactionStatus::kPending, + "failing internal mode was not queued"); + g_flash.fail_program = true; + configuration_service_task_on_storage_core(5000); + g_flash.fail_program = false; + require_mode_status(kFailingInternal, + ConfigurationTransactionStatus::kStorageError, + "failed internal mode did not report storage error"); + configuration_service_snapshot(&snapshot); + require(snapshot.state == ConfigurationServiceState::kReady && + snapshot.configuration.pairing_window_seconds == 120 && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto && + snapshot.transaction.transaction_id == 30 && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted && + !configuration_service_mode_transaction_reboot_ready( + kFailingInternal), + "failed internal mode did not roll back coherently"); + + const uint32_t reset_generation_before = + configuration_service_reset_generation(); + require(configuration_service_reset(40) == + ConfigurationTransactionStatus::kPending && + configuration_service_reset_generation() == + reset_generation_before + 1, + "configuration reset acceptance semantics changed"); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.pairing_window_seconds == 120 && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto, + "reset published defaults before durable commit"); + configuration_service_task_on_storage_core(6000); + configuration_service_snapshot(&snapshot); + require(snapshot.configuration.pairing_window_seconds == + ADAPTER_PAIRING_WINDOW_SECONDS_DEFAULT && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kAuto && + snapshot.transaction.transaction_id == 40 && + snapshot.transaction.status == + ConfigurationTransactionStatus::kCommitted && + snapshot.reset_generation == reset_generation_before + 1, + "configuration reset did not durably restore v2 defaults"); + + constexpr uint32_t kCapturedHostMode = 50; + require(configuration_service_set_mode( + kCapturedHostMode, AdapterRequestedMode::kSwitch, + implemented) == ConfigurationTransactionStatus::kPending, + "host mode was not queued for the storage completion race"); + g_reserve_recovery_during_program = true; + configuration_service_task_on_storage_core(7000); + require_mode_status( + kCapturedHostMode, ConfigurationTransactionStatus::kBusy, + "in-flight host mode completion revived its canceled status"); + configuration_service_snapshot(&snapshot); + require(snapshot.transaction.transaction_id == kCapturedHostMode && + snapshot.transaction.status == + ConfigurationTransactionStatus::kBusy && + snapshot.configuration.requested_mode == + AdapterRequestedMode::kSwitch && + !configuration_service_mode_transaction_reboot_ready( + kCapturedHostMode), + "captured host completion escaped recovery ownership"); + + const uint8_t blocked_payload = 0; + require(configuration_service_append( + kCapturedHostMode, 0, &blocked_payload, + sizeof(blocked_payload)) == + ConfigurationTransactionStatus::kBusy && + configuration_service_commit(kCapturedHostMode) == + ConfigurationTransactionStatus::kBusy && + configuration_service_begin( + 51, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + ADAPTER_CONFIGURATION_ENCODED_SIZE, 0) == + ConfigurationTransactionStatus::kBusy && + configuration_service_reset(52) == + ConfigurationTransactionStatus::kBusy && + configuration_service_set_mode( + 53, AdapterRequestedMode::kSwitch, implemented) == + ConfigurationTransactionStatus::kBusy, + "host mutation escaped recovery reservation"); + require(configuration_service_set_mode_internal( + 0x80000030u, AdapterRequestedMode::kSwitch, + implemented) == ConfigurationTransactionStatus::kBusy, + "non-Auto internal mode escaped recovery reservation"); + + constexpr uint32_t kRecoveryAuto = 0x80000031u; + require(configuration_service_set_mode_internal( + kRecoveryAuto, AdapterRequestedMode::kAuto, + implemented) == ConfigurationTransactionStatus::kPending && + !configuration_service_mode_transaction_reboot_ready( + kRecoveryAuto), + "recovery Auto was blocked or authorized before persistence"); + configuration_service_task_on_storage_core(8000); + require_mode_status(kRecoveryAuto, + ConfigurationTransactionStatus::kCommitted, + "recovery Auto did not overwrite canceled host mode"); + require(configuration_service_mode_transaction_reboot_ready( + kRecoveryAuto), + "latest recovery Auto did not authorize reboot"); + + constexpr uint32_t kLatestRecoveryAuto = 0x80000032u; + require(configuration_service_set_mode_internal( + kLatestRecoveryAuto, AdapterRequestedMode::kAuto, + implemented) == ConfigurationTransactionStatus::kPending && + !configuration_service_mode_transaction_reboot_ready( + kRecoveryAuto), + "newer recovery Auto did not immediately supersede reboot"); + configuration_service_task_on_storage_core(9000); + require(!configuration_service_mode_transaction_reboot_ready( + kRecoveryAuto) && + configuration_service_mode_transaction_reboot_ready( + kLatestRecoveryAuto), + "recovery reboot authorization did not remain latest-only"); +} + +void test_abandoned_host_receive_does_not_block_recovery() { + configuration_service_prepare(); + configuration_service_initialize_pre_usb(); + configuration_service_initialize_on_storage_core(); + + ConfigurationServiceSnapshot snapshot{}; + configuration_service_snapshot(&snapshot); + AdapterConfiguration abandoned = snapshot.configuration; + abandoned.pairing_window_seconds = 180; + uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; + require(adapter_configuration_encode(abandoned, payload, + sizeof(payload)), + "abandoned recovery-race configuration did not encode"); + const uint32_t crc = configuration_crc32(payload, sizeof(payload)); + require(configuration_service_begin( + 60, ADAPTER_CONFIGURATION_SCHEMA_VERSION, + sizeof(payload), crc) == + ConfigurationTransactionStatus::kReceiving, + "abandoned host configuration did not begin receiving"); + + configuration_service_reserve_for_recovery(); + configuration_service_reserve_for_recovery(); + configuration_service_snapshot(&snapshot); + require(snapshot.transaction.transaction_id == 60 && + snapshot.transaction.status == + ConfigurationTransactionStatus::kBusy, + "recovery reservation did not terminally cancel host receive"); + + const AdapterModeAvailability implemented{true, true, true, true}; + constexpr uint32_t kRecoveryAuto = 0x80000040u; + require(configuration_service_set_mode_internal( + kRecoveryAuto, AdapterRequestedMode::kAuto, + implemented) == ConfigurationTransactionStatus::kPending, + "abandoned host receive blocked recovery Auto"); + configuration_service_task_on_storage_core(0); + require_mode_status(kRecoveryAuto, + ConfigurationTransactionStatus::kCommitted, + "recovery Auto did not commit after abandoned receive"); + require(configuration_service_mode_transaction_reboot_ready( + kRecoveryAuto), + "recovery Auto did not become latest reboot authority"); +} + +} // namespace + +int main(int argc, char**) { + if (argc > 1) { + test_abandoned_host_receive_does_not_block_recovery(); + } else { + test_service_lifecycle_and_mutations(); + } + return 0; +} diff --git a/tests/configuration_storage_test.cpp b/tests/configuration_storage_test.cpp index 778312a..97c6d45 100644 --- a/tests/configuration_storage_test.cpp +++ b/tests/configuration_storage_test.cpp @@ -78,23 +78,124 @@ ConfigurationStorageIo fake_io(FakeFlash* flash) { void test_schema_encoding() { AdapterConfiguration configuration{}; configuration.pairing_window_seconds = 90; + configuration.requested_mode = AdapterRequestedMode::kXInput; uint8_t payload[ADAPTER_CONFIGURATION_ENCODED_SIZE]{}; require(adapter_configuration_encode(configuration, payload, sizeof(payload)), - "valid configuration did not encode"); + "valid v2 configuration did not encode"); + const uint8_t expected[] = { + 90, + 0, + static_cast(AdapterRequestedMode::kXInput), + 0, + 0, + 0, + 0, + 0, + }; + require(memcmp(payload, expected, sizeof(expected)) == 0, + "v2 configuration bytes are not canonical"); + AdapterConfiguration decoded{}; - require(adapter_configuration_decode(payload, sizeof(payload), - &decoded) && - decoded.pairing_window_seconds == 90, - "configuration did not round trip"); - payload[2] = 1; - require(!adapter_configuration_decode(payload, sizeof(payload), - &decoded), - "nonzero reserved configuration byte was accepted"); + require(adapter_configuration_decode( + ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, + sizeof(payload), &decoded) && + decoded.pairing_window_seconds == 90 && + decoded.requested_mode == AdapterRequestedMode::kXInput, + "v2 configuration did not round trip"); + + const AdapterRequestedMode valid_modes[] = { + AdapterRequestedMode::kAuto, + AdapterRequestedMode::kSwitch, + AdapterRequestedMode::kXInput, + AdapterRequestedMode::kDInput, + AdapterRequestedMode::kMac, + }; + for (AdapterRequestedMode mode : valid_modes) { + configuration.requested_mode = mode; + require(adapter_configuration_encode(configuration, payload, + sizeof(payload)) && + adapter_configuration_decode( + ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, + sizeof(payload), &decoded) && + decoded.requested_mode == mode, + "valid requested mode did not round trip"); + } + + AdapterModeAvailability availability{}; + require(!adapter_requested_mode_available( + AdapterRequestedMode::kDInput, availability) && + !adapter_requested_mode_available( + AdapterRequestedMode::kMac, availability), + "future modes were available by default"); + availability.dinput_mode = true; + availability.mac_mode = true; + require(adapter_requested_mode_available( + AdapterRequestedMode::kDInput, availability) && + adapter_requested_mode_available( + AdapterRequestedMode::kMac, availability), + "availability API could not enable future modes"); + + const uint8_t legacy[] = {120, 0, 0, 0}; + require(adapter_configuration_decode( + ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, legacy, + sizeof(legacy), &decoded) && + decoded.pairing_window_seconds == 120 && + decoded.requested_mode == AdapterRequestedMode::kAuto, + "v1 configuration did not migrate to auto"); + uint8_t malformed_legacy[sizeof(legacy)]; + memcpy(malformed_legacy, legacy, sizeof(legacy)); + malformed_legacy[3] = 1; + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, + malformed_legacy, sizeof(malformed_legacy), &decoded), + "v1 nonzero reserved byte was accepted"); + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_LEGACY_SCHEMA_VERSION, legacy, + sizeof(legacy) - 1, &decoded), + "v1 record with wrong size was accepted"); + + for (size_t index = 3; index < sizeof(payload); ++index) { + uint8_t malformed[sizeof(payload)]; + memcpy(malformed, payload, sizeof(payload)); + malformed[index] = 1; + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_SCHEMA_VERSION, malformed, + sizeof(malformed), &decoded), + "v2 nonzero reserved byte was accepted"); + } + uint8_t invalid_mode[sizeof(payload)]; + memcpy(invalid_mode, payload, sizeof(payload)); + invalid_mode[2] = 5; + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_SCHEMA_VERSION, invalid_mode, + sizeof(invalid_mode), &decoded), + "out-of-range requested mode was accepted"); + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_SCHEMA_VERSION, payload, + sizeof(payload) - 1, &decoded), + "short v2 record was accepted"); + uint8_t oversized[ADAPTER_CONFIGURATION_ENCODED_SIZE + 1]{}; + memcpy(oversized, payload, sizeof(payload)); + require(!adapter_configuration_decode( + ADAPTER_CONFIGURATION_SCHEMA_VERSION, oversized, + sizeof(oversized), &decoded), + "oversized v2 record was accepted"); + configuration.pairing_window_seconds = 9; require(!adapter_configuration_encode(configuration, payload, sizeof(payload)), "out-of-range pairing window was accepted"); + configuration.pairing_window_seconds = 90; + configuration.requested_mode = + static_cast(5); + require(!adapter_configuration_encode(configuration, payload, + sizeof(payload)), + "out-of-range requested mode encoded"); + configuration.requested_mode = AdapterRequestedMode::kAuto; + require(!adapter_configuration_encode(configuration, oversized, + sizeof(oversized)), + "v2 encoder accepted a noncanonical output size"); } void test_two_copy_recovery() { @@ -189,7 +290,8 @@ void test_transaction_validation() { "replacement transaction did not begin"); require(transaction.append(8, 0, payload, 2) == ConfigurationTransactionStatus::kReceiving && - transaction.append(8, 2, &payload[2], 2) == + transaction.append(8, 2, &payload[2], + sizeof(payload) - 2) == ConfigurationTransactionStatus::kReceiving, "ordered chunks were rejected"); require(transaction.finish(8) == diff --git a/tests/generic_hid_descriptors_test.cpp b/tests/generic_hid_descriptors_test.cpp new file mode 100644 index 0000000..7d82554 --- /dev/null +++ b/tests/generic_hid_descriptors_test.cpp @@ -0,0 +1,630 @@ +#include "generic_hid_descriptors.h" + +#include +#include +#include +#include +#include +#include + +#ifndef EXPECTED_HID_INSTANCE_COUNT +#error "EXPECTED_HID_INSTANCE_COUNT must be defined by the test build" +#endif + +static_assert(SWITCH_PICO_HID_INSTANCE_COUNT == EXPECTED_HID_INSTANCE_COUNT); +static_assert(sizeof(GenericHid::kConfigurationDescriptor) == + 9u + 25u * EXPECTED_HID_INSTANCE_COUNT); +static_assert(sizeof(GenericHid::InputReport) == 15); +static_assert(sizeof(GenericHid::kDInputReportDescriptor) == + sizeof(GenericHid::kMacReportDescriptor)); + + +namespace { + +int failures = 0; + +void expect(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } +} + +uint16_t read_u16(const uint8_t* bytes) { + return static_cast(bytes[0]) | + static_cast(static_cast(bytes[1]) << 8u); +} + +int16_t read_i16(const uint8_t* bytes) { + return static_cast(read_u16(bytes)); +} + +struct ItemGolden { + uint8_t type; + uint8_t tag; + uint8_t size; + uint32_t value; +}; + +constexpr std::array kDInputReportItemGolden{{ + {1, 0, 1, 0x01}, {2, 0, 1, 0x05}, {0, 10, 1, 0x01}, + {1, 0, 1, 0x01}, {1, 1, 2, 0x8000}, {1, 2, 2, 0x7fff}, + {1, 7, 1, 0x10}, {1, 9, 1, 0x04}, {2, 0, 1, 0x30}, + {2, 0, 1, 0x31}, {2, 0, 1, 0x33}, {2, 0, 1, 0x34}, + {0, 8, 1, 0x02}, {1, 1, 1, 0x00}, {1, 2, 4, 0xffff}, + {1, 9, 1, 0x02}, {2, 0, 1, 0x32}, {2, 0, 1, 0x35}, + {0, 8, 1, 0x02}, {1, 1, 1, 0x00}, {1, 2, 1, 0x07}, + {1, 3, 1, 0x00}, {1, 4, 2, 0x013b}, {1, 6, 1, 0x14}, + {1, 7, 1, 0x04}, {1, 9, 1, 0x01}, {2, 0, 1, 0x39}, + {0, 8, 1, 0x42}, {1, 7, 1, 0x04}, {1, 9, 1, 0x01}, + {0, 8, 1, 0x03}, {1, 0, 1, 0x09}, {1, 1, 1, 0x00}, + {1, 2, 1, 0x01}, {1, 3, 1, 0x00}, {1, 4, 1, 0x00}, + {1, 6, 1, 0x00}, {2, 1, 1, 0x01}, {2, 2, 1, 0x10}, + {1, 7, 1, 0x01}, {1, 9, 1, 0x10}, {0, 8, 1, 0x02}, + {0, 12, 0, 0x00}, +}}; + +int32_t sign_extend(uint32_t value, uint8_t size) { + if (size == 4 || size == 0) { + return static_cast(value); + } + const uint8_t bits = static_cast(size * 8u); + const uint32_t sign = 1u << (bits - 1u); + return static_cast((value ^ sign) - sign); +} + +struct GlobalState { + uint32_t usage_page = 0; + int32_t logical_minimum = 0; + int64_t logical_maximum = 0; + int32_t physical_minimum = 0; + int64_t physical_maximum = 0; + uint32_t unit = 0; + uint32_t report_size = 0; + uint32_t report_count = 0; +}; + +struct LocalState { + std::array usages{}; + uint8_t usage_count = 0; + uint32_t usage_minimum = 0; + uint32_t usage_maximum = 0; + bool has_usage_range = false; + + void clear() { + *this = {}; + } +}; + +struct InputField { + uint16_t bit_offset; + uint8_t size; + uint8_t count; + uint8_t flags; + GlobalState globals; + LocalState locals; +}; + +void inspect_report_descriptor(const uint8_t* descriptor, + size_t descriptor_size, bool mac_variant) { + const std::array expected_stick_usages = + mac_variant ? std::array{{0x30, 0x31, 0x32, 0x33}} + : std::array{{0x30, 0x31, 0x33, 0x34}}; + const std::array expected_trigger_usages = + mac_variant ? std::array{{0x34, 0x35}} + : std::array{{0x32, 0x35}}; + size_t offset = 0; + size_t decoded = 0; + uint16_t report_bits = 0; + uint8_t collection_depth = 0; + bool gamepad_application = false; + bool saw_report_id = false; + bool saw_output = false; + bool saw_feature = false; + GlobalState globals{}; + LocalState locals{}; + std::vector fields; + + while (offset < descriptor_size) { + const uint8_t prefix = descriptor[offset++]; + expect(prefix != 0xfe, "long HID item is not part of the golden contract"); + if (prefix == 0xfe) { + break; + } + uint8_t size = prefix & 0x03u; + if (size == 3) { + size = 4; + } + expect(offset + size <= descriptor_size, + "HID item extends beyond the report descriptor"); + if (offset + size > descriptor_size) { + break; + } + uint32_t value = 0; + for (uint8_t byte = 0; byte < size; ++byte) { + value |= static_cast(descriptor[offset + byte]) << + (8u * byte); + } + offset += size; + const uint8_t type = static_cast((prefix >> 2u) & 0x03u); + const uint8_t tag = static_cast(prefix >> 4u); + + expect(decoded < kDInputReportItemGolden.size(), + "report descriptor contains an extra HID item"); + if (decoded < kDInputReportItemGolden.size()) { + const ItemGolden& golden = kDInputReportItemGolden[decoded]; + uint32_t expected_value = golden.value; + if (mac_variant) { + if (decoded == 10) { + expected_value = 0x32; + } else if (decoded == 11) { + expected_value = 0x33; + } else if (decoded == 16) { + expected_value = 0x34; + } else if (decoded == 17) { + expected_value = 0x35; + } + } + expect(type == golden.type && tag == golden.tag && + size == golden.size && value == expected_value, + "decoded HID item differs from its mode golden"); + } + ++decoded; + + if (type == 1) { + switch (tag) { + case 0: + globals.usage_page = value; + break; + case 1: + globals.logical_minimum = sign_extend(value, size); + break; + case 2: + globals.logical_maximum = globals.logical_minimum < 0 + ? sign_extend(value, size) + : value; + break; + case 3: + globals.physical_minimum = sign_extend(value, size); + break; + case 4: + globals.physical_maximum = globals.physical_minimum < 0 + ? sign_extend(value, size) + : value; + break; + case 6: + globals.unit = value; + break; + case 7: + globals.report_size = value; + break; + case 8: + saw_report_id = true; + break; + case 9: + globals.report_count = value; + break; + default: + expect(false, "unexpected global HID item"); + break; + } + continue; + } + if (type == 2) { + if (tag == 0) { + expect(locals.usage_count < locals.usages.size(), + "too many local usages in the report descriptor"); + if (locals.usage_count < locals.usages.size()) { + locals.usages[locals.usage_count++] = value; + } + } else if (tag == 1) { + locals.usage_minimum = value; + locals.has_usage_range = true; + } else if (tag == 2) { + locals.usage_maximum = value; + locals.has_usage_range = true; + } else { + expect(false, "unexpected local HID item"); + } + continue; + } + + expect(type == 0, "reserved HID item type is present"); + if (type != 0) { + continue; + } + if (tag == 10) { + gamepad_application = collection_depth == 0 && value == 1 && + globals.usage_page == 1 && + locals.usage_count == 1 && + locals.usages[0] == 5; + ++collection_depth; + } else if (tag == 12) { + expect(collection_depth > 0, "unbalanced End Collection item"); + if (collection_depth > 0) { + --collection_depth; + } + } else if (tag == 8) { + fields.push_back(InputField{ + report_bits, + static_cast(globals.report_size), + static_cast(globals.report_count), + static_cast(value), + globals, + locals, + }); + report_bits = static_cast( + report_bits + globals.report_size * globals.report_count); + } else if (tag == 9) { + saw_output = true; + } else if (tag == 11) { + saw_feature = true; + } else { + expect(false, "unexpected main HID item"); + } + locals.clear(); + } + + expect(offset == descriptor_size, "report descriptor was not fully decoded"); + expect(decoded == kDInputReportItemGolden.size(), + "report descriptor is missing a golden HID item"); + expect(gamepad_application && collection_depth == 0, + "report is not one balanced Game Pad application collection"); + expect(!saw_report_id, "single-interface report unexpectedly has a Report ID"); + expect(!saw_output && !saw_feature, + "input-only generic HID descriptor declares output or feature data"); + expect(report_bits == GenericHid::kReportSize * 8u, + "input report does not contain exactly 15 bytes"); + expect(fields.size() == 5, "input report has the wrong field count"); + if (fields.size() != 5) { + return; + } + + const InputField& sticks = fields[0]; + expect(sticks.bit_offset == 0 && sticks.size == 16 && sticks.count == 4 && + sticks.flags == 0x02 && sticks.globals.usage_page == 1 && + sticks.globals.logical_minimum == -32768 && + sticks.globals.logical_maximum == 32767 && + sticks.locals.usage_count == expected_stick_usages.size() && + sticks.locals.usages[0] == expected_stick_usages[0] && + sticks.locals.usages[1] == expected_stick_usages[1] && + sticks.locals.usages[2] == expected_stick_usages[2] && + sticks.locals.usages[3] == expected_stick_usages[3], + mac_variant ? "signed Mac X/Y/Z/Rx stick field layout is wrong" + : "signed DInput X/Y/Rx/Ry stick field layout is wrong"); + + const InputField& triggers = fields[1]; + expect(triggers.bit_offset == 64 && triggers.size == 16 && + triggers.count == 2 && triggers.flags == 0x02 && + triggers.globals.usage_page == 1 && + triggers.globals.logical_minimum == 0 && + triggers.globals.logical_maximum == 65535 && + triggers.locals.usage_count == + expected_trigger_usages.size() && + triggers.locals.usages[0] == expected_trigger_usages[0] && + triggers.locals.usages[1] == expected_trigger_usages[1], + mac_variant ? "unsigned Mac Ry/Rz trigger field layout is wrong" + : "unsigned DInput Z/Rz trigger field layout is wrong"); + if (mac_variant) { + const std::array usage_indices{{ + sticks.locals.usages[0] - 0x30u, + sticks.locals.usages[1] - 0x30u, + sticks.locals.usages[2] - 0x30u, + sticks.locals.usages[3] - 0x30u, + triggers.locals.usages[0] - 0x30u, + triggers.locals.usages[1] - 0x30u, + }}; + constexpr std::array kSemanticAxisGolden{{ + 0, 1, 2, 3, 4, 5, + }}; + expect(usage_indices == kSemanticAxisGolden, + "Mac physical fields do not map to Chromium axes 0..5"); + expect((sticks.bit_offset + 3u * sticks.size) / 8u == 6u && + usage_indices[3] == 3u && + triggers.bit_offset / 8u == 8u && + usage_indices[4] == 4u, + "Mac field offset 6 must be Rx/axis3 and offset 8 must be " + "Ry/axis4; reversing them reproduces the hardware failure"); + } + + const InputField& hat = fields[2]; + expect(hat.bit_offset == 96 && hat.size == 4 && hat.count == 1 && + hat.flags == 0x42 && hat.globals.usage_page == 1 && + hat.globals.logical_minimum == 0 && + hat.globals.logical_maximum == 7 && + hat.globals.physical_minimum == 0 && + hat.globals.physical_maximum == 315 && + hat.globals.unit == 0x14 && hat.locals.usage_count == 1 && + hat.locals.usages[0] == 0x39, + "Hat Switch field or declared null-state semantics are wrong"); + + const InputField& padding = fields[3]; + expect(padding.bit_offset == 100 && padding.size == 4 && + padding.count == 1 && padding.flags == 0x03 && + padding.locals.usage_count == 0 && + !padding.locals.has_usage_range, + "hat padding is not four constant bits"); + + const InputField& buttons = fields[4]; + expect(buttons.bit_offset == 104 && buttons.size == 1 && + buttons.count == 16 && buttons.flags == 0x02 && + buttons.globals.usage_page == 9 && + buttons.globals.logical_minimum == 0 && + buttons.globals.logical_maximum == 1 && + buttons.globals.unit == 0 && buttons.locals.has_usage_range && + buttons.locals.usage_minimum == 1 && + buttons.locals.usage_maximum == 16, + "sequential Button 1..16 field layout is wrong"); +} +void inspect_report_descriptor_parity() { + constexpr std::array kDInputDifferentBytes{{ + 0x33, 0x34, 0x32, + }}; + constexpr std::array kMacDifferentBytes{{ + 0x32, 0x33, 0x34, + }}; + size_t difference_count = 0; + for (size_t offset = 0; + offset < sizeof(GenericHid::kDInputReportDescriptor); ++offset) { + const uint8_t dinput = + GenericHid::kDInputReportDescriptor[offset]; + const uint8_t mac = GenericHid::kMacReportDescriptor[offset]; + if (dinput == mac) { + continue; + } + expect(difference_count < kDInputDifferentBytes.size(), + "report descriptors differ outside the three Mac axis usages"); + if (difference_count < kDInputDifferentBytes.size()) { + expect(dinput == kDInputDifferentBytes[difference_count] && + mac == kMacDifferentBytes[difference_count], + "report descriptor axis usage difference is wrong"); + } + ++difference_count; + } + expect(difference_count == kDInputDifferentBytes.size(), + "report descriptors do not differ at exactly three axis usages"); +} + + +void inspect_device_descriptors_and_strings() { + constexpr std::array dinput_golden{{ + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, 0xfe, + 0xca, 0x20, 0x40, 0x00, 0x01, 0x01, 0x02, 0x03, 0x01, + }}; + constexpr std::array mac_golden{{ + 0x12, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x40, 0xfe, + 0xca, 0x21, 0x40, 0x00, 0x01, 0x01, 0x02, 0x03, 0x01, + }}; + expect(std::memcmp(GenericHid::kDInputDeviceDescriptor, + dinput_golden.data(), dinput_golden.size()) == 0, + "DInput development device descriptor differs from its golden"); + expect(std::memcmp(GenericHid::kMacDeviceDescriptor, mac_golden.data(), + mac_golden.size()) == 0, + "Mac development device descriptor differs from its golden"); + expect(read_u16(GenericHid::kDInputDeviceDescriptor + 8) == 0xcafe && + read_u16(GenericHid::kDInputDeviceDescriptor + 10) == 0x4020 && + read_u16(GenericHid::kMacDeviceDescriptor + 8) == 0xcafe && + read_u16(GenericHid::kMacDeviceDescriptor + 10) == 0x4021, + "development VID/PIDs are wrong"); + expect(std::strcmp(GenericHid::kManufacturerString, "Switch Pico") == 0 && + std::strcmp(GenericHid::kDInputProductString, + "DInput Development") == 0 && + std::strcmp(GenericHid::kDInputSerialString, + "DINPUT-DEV-4020") == 0 && + std::strcmp(GenericHid::kMacProductString, + "Mac HID Development") == 0 && + std::strcmp(GenericHid::kMacSerialString, + "MAC-HID-DEV-4021") == 0, + "generic HID USB strings differ from their goldens"); + expect(std::strcmp(GenericHid::kDInputProductString, + GenericHid::kMacProductString) != 0 && + std::strcmp(GenericHid::kDInputSerialString, + GenericHid::kMacSerialString) != 0, + "DInput and Mac identities do not have distinct strings"); +} + +void inspect_configuration_descriptor() { + constexpr uint8_t kConfiguration = 0x02; + constexpr uint8_t kInterface = 0x04; + constexpr uint8_t kEndpoint = 0x05; + constexpr uint8_t kHid = 0x21; + const uint8_t* descriptor = GenericHid::kConfigurationDescriptor; + const size_t descriptor_size = sizeof(GenericHid::kConfigurationDescriptor); + + expect(descriptor[0] == 9 && descriptor[1] == kConfiguration && + read_u16(descriptor + 2) == descriptor_size && + descriptor[4] == EXPECTED_HID_INSTANCE_COUNT, + "generic HID configuration header is malformed"); + + std::array interfaces{}; + std::array endpoints{}; + std::array hid_counts{}; + std::array endpoint_counts{}; + int current_interface = -1; + size_t offset = descriptor[0]; + while (offset < descriptor_size) { + const uint8_t length = descriptor[offset]; + expect(length >= 2 && offset + length <= descriptor_size, + "configuration child descriptor has an invalid length"); + if (length < 2 || offset + length > descriptor_size) { + break; + } + const uint8_t type = descriptor[offset + 1]; + if (type == kInterface) { + expect(length == 9, "HID interface descriptor length is wrong"); + const uint8_t number = descriptor[offset + 2]; + expect(number < interfaces.size(), + "HID interface number is outside the configured range"); + if (number < interfaces.size()) { + expect(!interfaces[number], "HID interface number is duplicated"); + interfaces[number] = true; + current_interface = number; + } else { + current_interface = -1; + } + expect(descriptor[offset + 3] == 0 && + descriptor[offset + 4] == 1 && + descriptor[offset + 5] == 0x03 && + descriptor[offset + 6] == 0 && + descriptor[offset + 7] == 0, + "generic HID interface class or endpoint count is wrong"); + } else if (type == kHid) { + expect(current_interface >= 0 && length == 9, + "HID descriptor is not attached to an interface"); + const uint16_t report_descriptor_length = + read_u16(descriptor + offset + 7); + expect(report_descriptor_length == + sizeof(GenericHid::kDInputReportDescriptor) && + report_descriptor_length == + sizeof(GenericHid::kMacReportDescriptor), + "HID descriptor advertises the wrong report length"); + if (current_interface >= 0) { + ++hid_counts[static_cast(current_interface)]; + } + } else if (type == kEndpoint) { + expect(current_interface >= 0 && length == 7, + "endpoint is not attached to an interface"); + const uint8_t address = descriptor[offset + 2]; + const uint8_t endpoint_number = address & 0x0fu; + expect((address & 0x80u) != 0, + "generic HID exposes an OUT endpoint"); + expect(endpoint_number > 0 && endpoint_number < endpoints.size(), + "generic HID endpoint number is invalid"); + if (endpoint_number < endpoints.size()) { + expect(!endpoints[endpoint_number], + "generic HID endpoint address is duplicated"); + endpoints[endpoint_number] = true; + } + expect(current_interface < 0 || + address == static_cast( + 0x81u + current_interface), + "IN endpoint does not belong to its HID interface"); + expect(descriptor[offset + 3] == 0x03 && + read_u16(descriptor + offset + 4) == + GenericHid::kEndpointSize && + descriptor[offset + 6] == + GenericHid::kEndpointIntervalMs, + "generic HID interrupt endpoint contract is wrong"); + if (current_interface >= 0) { + ++endpoint_counts[static_cast(current_interface)]; + } + } else { + expect(false, "unexpected configuration child descriptor type"); + } + offset += length; + } + + expect(offset == descriptor_size, + "configuration descriptor was not fully decoded"); + for (size_t instance = 0; instance < interfaces.size(); ++instance) { + expect(interfaces[instance] && hid_counts[instance] == 1 && + endpoint_counts[instance] == 1 && endpoints[instance + 1], + "configured HID interface is missing or not isolated"); + } +} + +void inspect_report_encoding() { + ControllerState state{}; + state.left_stick_x = INT16_MIN; + state.left_stick_y = INT16_MAX; + state.right_stick_x = static_cast(0x1234); + state.right_stick_y = static_cast(-0x1234); + state.left_trigger = 0; + state.right_trigger = UINT16_MAX; + GenericHid::InputReport report = GenericHid::build_input_report(state); + expect(read_i16(report.data + 0) == INT16_MIN && + read_i16(report.data + 2) == INT16_MAX && + read_i16(report.data + 4) == static_cast(0x1234) && + read_i16(report.data + 6) == static_cast(-0x1234), + "signed stick endpoints or little-endian encoding are wrong"); + expect(read_u16(report.data + 8) == 0 && + read_u16(report.data + 10) == UINT16_MAX, + "unsigned trigger endpoints or little-endian encoding are wrong"); + expect(report.data[12] == GenericHid::kHatCenter && + (report.data[12] & 0xf0u) == 0, + "neutral Hat Switch does not use null value 8 with zero padding"); + + struct HatCase { + bool up; + bool down; + bool left; + bool right; + uint8_t expected; + }; + constexpr std::array hats{{ + {false, false, false, false, GenericHid::kHatCenter}, + {true, false, false, false, GenericHid::kHatUp}, + {true, false, false, true, GenericHid::kHatUpRight}, + {false, false, false, true, GenericHid::kHatRight}, + {false, true, false, true, GenericHid::kHatDownRight}, + {false, true, false, false, GenericHid::kHatDown}, + {false, true, true, false, GenericHid::kHatDownLeft}, + {false, false, true, false, GenericHid::kHatLeft}, + {true, false, true, false, GenericHid::kHatUpLeft}, + }}; + for (const HatCase& hat : hats) { + ControllerState direction{}; + direction.dpad_up = hat.up; + direction.dpad_down = hat.down; + direction.dpad_left = hat.left; + direction.dpad_right = hat.right; + const GenericHid::InputReport direction_report = + GenericHid::build_input_report(direction); + expect(direction_report.data[12] == hat.expected, + "D-pad direction or diagonal maps to the wrong hat value"); + } + ControllerState contradictory{}; + contradictory.dpad_up = true; + contradictory.dpad_down = true; + contradictory.dpad_left = true; + contradictory.dpad_right = true; + expect(GenericHid::build_input_report(contradictory).data[12] == + GenericHid::kHatCenter, + "contradictory D-pad input does not resolve to center"); + + using ButtonMember = bool ControllerState::*; + constexpr std::array button_members{{ + &ControllerState::button_south, + &ControllerState::button_east, + &ControllerState::button_west, + &ControllerState::button_north, + &ControllerState::button_left_shoulder, + &ControllerState::button_right_shoulder, + &ControllerState::button_select, + &ControllerState::button_start, + &ControllerState::button_left_stick, + &ControllerState::button_right_stick, + &ControllerState::button_system, + &ControllerState::button_capture, + }}; + for (size_t button = 0; button < button_members.size(); ++button) { + ControllerState pressed{}; + pressed.*button_members[button] = true; + const GenericHid::InputReport button_report = + GenericHid::build_input_report(pressed); + expect(read_u16(button_report.data + 13) == (1u << button), + "positional button maps to the wrong sequential usage"); + } + ControllerState all_buttons{}; + for (ButtonMember member : button_members) { + all_buttons.*member = true; + } + expect(read_u16(GenericHid::build_input_report(all_buttons).data + 13) == + GenericHid::kDefinedButtonMask, + "button bits 12..15 are not reserved zero"); +} + +} // namespace + +int main() { + inspect_report_descriptor(GenericHid::kDInputReportDescriptor, + sizeof(GenericHid::kDInputReportDescriptor), + false); + inspect_report_descriptor(GenericHid::kMacReportDescriptor, + sizeof(GenericHid::kMacReportDescriptor), true); + inspect_report_descriptor_parity(); + inspect_device_descriptors_and_strings(); + inspect_configuration_descriptor(); + inspect_report_encoding(); + return failures == 0 ? 0 : 1; +} diff --git a/tests/generic_hid_driver_test.cpp b/tests/generic_hid_driver_test.cpp new file mode 100644 index 0000000..f84d191 --- /dev/null +++ b/tests/generic_hid_driver_test.cpp @@ -0,0 +1,312 @@ +#include "generic_hid_driver.h" + +#include "generic_hid_descriptors.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr uint8_t kInstanceCount = SWITCH_PICO_HID_INSTANCE_COUNT; +constexpr uint8_t kInvalidInstance = kInstanceCount; +static_assert(kInstanceCount == 4, + "the generic HID driver harness must exercise four interfaces"); + +struct SentReport { + uint8_t instance = 0xff; + uint8_t report_id = 0xff; + uint16_t length = 0; + GenericHid::InputReport report{}; +}; + +std::array hid_ready{}; +std::array send_succeeds{}; +std::array send_attempts{}; +std::array sent_reports{}; +size_t sent_report_count = 0; +int failures = 0; + +void expect(bool condition, const char* message) { + if (!condition) { + std::cerr << message << '\n'; + ++failures; + } +} + +uint16_t read_u16(const uint8_t* bytes) { + return static_cast(bytes[0]) | + static_cast(static_cast(bytes[1]) << 8u); +} + +void reset_harness() { + hid_ready.fill(true); + send_succeeds.fill(true); + send_attempts = {}; + sent_reports = {}; + sent_report_count = 0; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + generic_hid_init(instance); + } +} + +GenericHid::InputReport get_report(uint8_t instance) { + GenericHid::InputReport report{}; + expect(generic_hid_get_report(instance, 0, HID_REPORT_TYPE_INPUT, + report.data, sizeof(report)) == + sizeof(report), + "valid input GetReport did not return 15 bytes"); + return report; +} + +void test_initial_report_and_descriptor_routing() { + reset_harness(); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + const GenericHid::InputReport report = get_report(instance); + expect(report.data[12] == GenericHid::kHatCenter, + "initialized generic HID report is not centered"); + for (size_t byte = 0; byte < sizeof(report); ++byte) { + if (byte != 12) { + expect(report.data[byte] == 0, + "initialized generic HID report is not neutral"); + } + } + const uint8_t* dinput_descriptor = generic_hid_report_descriptor( + instance, GenericHid::ReportDescriptorVariant::kDInput); + const uint8_t* mac_descriptor = generic_hid_report_descriptor( + instance, GenericHid::ReportDescriptorVariant::kMac); + expect(dinput_descriptor != nullptr && + std::memcmp(dinput_descriptor, + GenericHid::kDInputReportDescriptor, + sizeof(GenericHid::kDInputReportDescriptor)) == + 0, + "valid HID instance did not receive the DInput descriptor"); + expect(mac_descriptor != nullptr && + std::memcmp(mac_descriptor, + GenericHid::kMacReportDescriptor, + sizeof(GenericHid::kMacReportDescriptor)) == 0, + "valid HID instance did not receive the Mac descriptor"); + } + expect(generic_hid_report_descriptor( + kInvalidInstance, + GenericHid::ReportDescriptorVariant::kDInput) == nullptr && + generic_hid_report_descriptor( + kInvalidInstance, + GenericHid::ReportDescriptorVariant::kMac) == nullptr, + "invalid HID instance received a report descriptor"); +} + +void test_latest_get_report_and_instance_isolation() { + reset_harness(); + std::array states{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + ControllerState& state = states[instance]; + state.left_stick_x = static_cast(-30000 + instance * 1000); + state.left_stick_y = static_cast(1000 + instance * 2000); + state.right_stick_x = static_cast(3000 + instance * 3000); + state.right_stick_y = static_cast(-4000 - instance * 4000); + state.left_trigger = static_cast(0x1111u * (instance + 1u)); + state.right_trigger = static_cast(0x8888u + instance * 0x1111u); + if (instance == 0) { + state.button_south = true; + state.dpad_up = true; + } else if (instance == 1) { + state.button_east = true; + state.dpad_right = true; + } else if (instance == 2) { + state.button_system = true; + state.dpad_down = true; + } else { + state.button_capture = true; + state.dpad_left = true; + } + generic_hid_set_input(instance, state); + } + + std::array reports{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + reports[instance] = get_report(instance); + const GenericHid::InputReport expected = + GenericHid::build_input_report(states[instance]); + expect(std::memcmp(reports[instance].data, expected.data, + sizeof(expected)) == 0, + "GetReport did not expose the latest addressed input"); + } + for (uint8_t left = 0; left < kInstanceCount; ++left) { + for (uint8_t right = static_cast(left + 1u); + right < kInstanceCount; ++right) { + expect(std::memcmp(reports[left].data, reports[right].data, + sizeof(GenericHid::InputReport)) != 0, + "latest reports crossed HID instances"); + } + } + + ControllerState changed = states[2]; + changed.button_system = false; + changed.button_north = true; + changed.left_trigger = UINT16_MAX; + generic_hid_set_input(2, changed); + const GenericHid::InputReport latest = get_report(2); + const GenericHid::InputReport unchanged = get_report(1); + expect(read_u16(latest.data + 13) == GenericHid::kButtonNorth && + read_u16(latest.data + 8) == UINT16_MAX, + "changed input was not materialized before the next task call"); + expect(std::memcmp(unchanged.data, reports[1].data, sizeof(unchanged)) == 0, + "changing one input mutated another HID context"); +} + +void test_ready_and_periodic_send_routing() { + reset_harness(); + ControllerState state{}; + state.left_stick_x = INT16_MIN; + state.right_stick_y = INT16_MAX; + state.left_trigger = 0x1234; + state.right_trigger = 0xabcd; + state.button_left_stick = true; + state.dpad_down = true; + state.dpad_right = true; + generic_hid_set_input(3, state); + + hid_ready[3] = false; + expect(!generic_hid_is_ready(3) && !generic_hid_task(3) && + send_attempts[3] == 0 && sent_report_count == 0, + "not-ready HID instance attempted an interrupt send"); + hid_ready[3] = true; + expect(generic_hid_is_ready(3) && generic_hid_task(3) && + send_attempts[3] == 1 && sent_report_count == 1, + "ready HID instance did not send its periodic input report"); + expect(sent_reports[0].instance == 3 && + sent_reports[0].report_id == 0 && + sent_reports[0].length == GenericHid::kReportSize, + "generic input used the wrong TinyUSB interface, ID, or length"); + const GenericHid::InputReport expected = + GenericHid::build_input_report(state); + expect(std::memcmp(sent_reports[0].report.data, expected.data, + sizeof(expected)) == 0, + "interrupt send did not use the latest generic input report"); + + expect(generic_hid_task(3) && send_attempts[3] == 2 && + sent_report_count == 2, + "ready polling did not permit the next report interval send"); + + send_succeeds[3] = false; + expect(!generic_hid_task(3) && send_attempts[3] == 3 && + sent_report_count == 2, + "failed TinyUSB send was reported as successful"); + expect(!generic_hid_is_ready(kInvalidInstance) && + !generic_hid_task(kInvalidInstance), + "invalid HID instance was ready or attempted a send"); + for (uint8_t instance = 0; instance < 3; ++instance) { + expect(send_attempts[instance] == 0, + "task send leaked to a different HID interface"); + } +} + +void test_reset_and_invalid_inputs() { + reset_harness(); + ControllerState zero{}; + zero.button_south = true; + zero.left_stick_x = 1234; + ControllerState one{}; + one.button_east = true; + one.right_trigger = 4321; + generic_hid_set_input(0, zero); + generic_hid_set_input(1, one); + + generic_hid_init(0); + const GenericHid::InputReport reset = get_report(0); + const GenericHid::InputReport preserved = get_report(1); + expect(reset.data[12] == GenericHid::kHatCenter && + read_u16(reset.data + 13) == 0 && + read_u16(reset.data) == 0, + "init did not reset the addressed HID context"); + expect(read_u16(preserved.data + 13) == GenericHid::kButtonEast && + read_u16(preserved.data + 10) == 4321, + "reset crossed into another HID context"); + + ControllerState invalid{}; + invalid.button_capture = true; + generic_hid_set_input(kInvalidInstance, invalid); + generic_hid_init(kInvalidInstance); + expect(std::memcmp(get_report(1).data, preserved.data, + sizeof(preserved)) == 0, + "invalid instance input/reset mutated a valid context"); +} + +void test_get_report_rejections_and_truncation() { + reset_harness(); + ControllerState state{}; + state.left_stick_x = static_cast(0x1234); + state.left_stick_y = static_cast(0x5678); + generic_hid_set_input(0, state); + + std::array buffer{}; + buffer.fill(0xa5); + expect(generic_hid_get_report(kInvalidInstance, 0, + HID_REPORT_TYPE_INPUT, buffer.data(), + buffer.size()) == 0, + "GetReport accepted an invalid HID instance"); + expect(generic_hid_get_report(0, 1, HID_REPORT_TYPE_INPUT, buffer.data(), + buffer.size()) == 0, + "GetReport accepted a nonzero Report ID"); + expect(generic_hid_get_report(0, 0, HID_REPORT_TYPE_OUTPUT, buffer.data(), + buffer.size()) == 0 && + generic_hid_get_report(0, 0, HID_REPORT_TYPE_FEATURE, + buffer.data(), buffer.size()) == 0, + "input-only driver accepted output or feature GetReport"); + expect(generic_hid_get_report(0, 0, HID_REPORT_TYPE_INPUT, nullptr, + buffer.size()) == 0 && + generic_hid_get_report(0, 0, HID_REPORT_TYPE_INPUT, + buffer.data(), 0) == 0, + "GetReport accepted an invalid destination"); + for (uint8_t byte : buffer) { + expect(byte == 0xa5, "rejected GetReport modified its destination"); + } + + std::array truncated{}; + expect(generic_hid_get_report(0, 0, HID_REPORT_TYPE_INPUT, + truncated.data(), truncated.size()) == + truncated.size() && + truncated[0] == 0x34 && truncated[1] == 0x12 && + truncated[2] == 0x78, + "GetReport did not safely truncate the latest input report"); +} + +} // namespace + +extern "C" bool tud_hid_n_ready(uint8_t instance) { + return instance < kInstanceCount && hid_ready[instance]; +} + +extern "C" bool tud_hid_n_report(uint8_t instance, uint8_t report_id, + const void* report, uint16_t length) { + if (instance >= kInstanceCount || report == nullptr || + length != sizeof(GenericHid::InputReport)) { + return false; + } + ++send_attempts[instance]; + if (!send_succeeds[instance] || sent_report_count >= sent_reports.size()) { + return false; + } + SentReport& sent = sent_reports[sent_report_count++]; + sent.instance = instance; + sent.report_id = report_id; + sent.length = length; + std::memcpy(&sent.report, report, length); + return true; +} + +int main() { + test_initial_report_and_descriptor_routing(); + test_latest_get_report_and_instance_isolation(); + test_ready_and_periodic_send_routing(); + test_reset_and_invalid_inputs(); + test_get_report_rejections_and_truncation(); + if (failures != 0) { + std::cerr << failures << " generic HID driver test(s) failed\n"; + return 1; + } + return 0; +} diff --git a/tests/mode_native_stubs/hardware/structs/watchdog.h b/tests/mode_native_stubs/hardware/structs/watchdog.h new file mode 100644 index 0000000..9257293 --- /dev/null +++ b/tests/mode_native_stubs/hardware/structs/watchdog.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +struct watchdog_hw_t { + uint32_t scratch[8]; +}; + +extern watchdog_hw_t* watchdog_hw; diff --git a/tests/mode_native_stubs/hardware/watchdog.h b/tests/mode_native_stubs/hardware/watchdog.h new file mode 100644 index 0000000..a972f53 --- /dev/null +++ b/tests/mode_native_stubs/hardware/watchdog.h @@ -0,0 +1,5 @@ +#pragma once + +#include + +void watchdog_reboot(uint32_t pc, uint32_t sp, uint32_t delay_ms); diff --git a/tests/mode_native_stubs/pico/time.h b/tests/mode_native_stubs/pico/time.h new file mode 100644 index 0000000..312a3e1 --- /dev/null +++ b/tests/mode_native_stubs/pico/time.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +typedef int32_t alarm_id_t; +typedef uint64_t absolute_time_t; +typedef int64_t (*alarm_callback_t)(alarm_id_t alarm_id, void* user_data); + +absolute_time_t get_absolute_time(); +uint64_t to_ms_since_boot(absolute_time_t time); +alarm_id_t add_alarm_in_ms(int64_t delay_ms, alarm_callback_t callback, + void* user_data, bool fire_if_past); diff --git a/tests/mode_native_stubs/tusb.h b/tests/mode_native_stubs/tusb.h new file mode 100644 index 0000000..2bdddc3 --- /dev/null +++ b/tests/mode_native_stubs/tusb.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +enum { + CONTROL_STAGE_SETUP = 0, + CONTROL_STAGE_DATA = 1, + CONTROL_STAGE_ACK = 2, + TUSB_REQ_RCPT_DEVICE = 0, + TUSB_DIR_OUT = 0, + TUSB_REQ_TYPE_VENDOR = 2, + TUSB_DIR_IN = 1, +}; + +struct tusb_request_type_bits_t { + uint8_t recipient; + uint8_t type; + uint8_t direction; +}; + +struct tusb_control_request_t { + tusb_request_type_bits_t bmRequestType_bit; + uint8_t bRequest; + uint16_t wValue; + uint16_t wIndex; + uint16_t wLength; +}; +void tusb_init(); + +bool tud_control_xfer(uint8_t rhport, + const tusb_control_request_t* request, + void* buffer, uint16_t length); diff --git a/tests/native_stubs/device/usbd_pvt.h b/tests/native_stubs/device/usbd_pvt.h new file mode 100644 index 0000000..efe47c2 --- /dev/null +++ b/tests/native_stubs/device/usbd_pvt.h @@ -0,0 +1,36 @@ +#pragma once + +#include "tusb.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char const* name; + void (*init)(void); + bool (*deinit)(void); + void (*reset)(uint8_t rhport); + uint16_t (*open)(uint8_t rhport, + tusb_desc_interface_t const* interface_descriptor, + uint16_t max_length); + bool (*control_xfer_cb)(uint8_t rhport, uint8_t stage, + tusb_control_request_t const* request); + bool (*xfer_cb)(uint8_t rhport, uint8_t endpoint, + xfer_result_t result, uint32_t transferred); + void (*sof)(uint8_t rhport, uint32_t frame_count); +} usbd_class_driver_t; + +usbd_class_driver_t const* usbd_app_driver_get_cb(uint8_t* driver_count); + +bool usbd_edpt_open(uint8_t rhport, + tusb_desc_endpoint_t const* endpoint_descriptor); +bool usbd_edpt_xfer(uint8_t rhport, uint8_t endpoint, uint8_t* buffer, + uint16_t total_bytes); +bool usbd_edpt_busy(uint8_t rhport, uint8_t endpoint); +bool usbd_edpt_claim(uint8_t rhport, uint8_t endpoint); +bool usbd_edpt_release(uint8_t rhport, uint8_t endpoint); + +#ifdef __cplusplus +} +#endif diff --git a/tests/native_stubs/tusb.h b/tests/native_stubs/tusb.h index 0905dee..4e144e7 100644 --- a/tests/native_stubs/tusb.h +++ b/tests/native_stubs/tusb.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #ifdef __cplusplus @@ -14,6 +15,54 @@ typedef enum { HID_REPORT_TYPE_FEATURE = 3, } hid_report_type_t; +typedef enum { + XFER_RESULT_SUCCESS = 0, + XFER_RESULT_FAILED, +} xfer_result_t; + +enum { + TUSB_DIR_OUT = 0, + TUSB_DIR_IN = 1, + TUSB_DESC_ENDPOINT = 5, + TUSB_DESC_STRING = 3, +}; + +#pragma pack(push, 1) +typedef struct { + uint8_t bLength; + uint8_t bDescriptorType; + uint8_t bInterfaceNumber; + uint8_t bAlternateSetting; + uint8_t bNumEndpoints; + uint8_t bInterfaceClass; + uint8_t bInterfaceSubClass; + uint8_t bInterfaceProtocol; + uint8_t iInterface; +} tusb_desc_interface_t; + +typedef struct { + uint8_t bLength; + uint8_t bDescriptorType; + uint8_t bEndpointAddress; + uint8_t bmAttributes; + uint16_t wMaxPacketSize; + uint8_t bInterval; +} tusb_desc_endpoint_t; +#pragma pack(pop) + +static inline uint8_t const* tu_desc_next(void const* descriptor) { + uint8_t const* bytes = (uint8_t const*)descriptor; + return bytes + bytes[0]; +} + +static inline uint8_t tu_desc_type(void const* descriptor) { + return ((uint8_t const*)descriptor)[1]; +} + +static inline uint8_t tu_edpt_dir(uint8_t endpoint) { + return (endpoint & 0x80u) != 0 ? TUSB_DIR_IN : TUSB_DIR_OUT; +} + typedef struct { uint8_t bmRequestType; uint8_t bRequest; @@ -27,6 +76,7 @@ bool tud_hid_n_report(uint8_t instance, uint8_t report_id, const void* report, uint16_t length); bool tud_suspended(void); bool tud_remote_wakeup(void); +bool tud_ready(void); uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t* buffer, @@ -39,6 +89,14 @@ void tud_hid_report_received_cb(uint8_t instance, uint8_t report_id, uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance); void tud_mount_cb(void); void tud_umount_cb(void); +uint8_t const* tud_descriptor_device_cb(void); +uint8_t const* tud_descriptor_configuration_cb(uint8_t index); +uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid); +bool tud_vendor_control_xfer_cb( + uint8_t rhport, uint8_t stage, + tusb_control_request_t const* request); +bool tud_control_request_cb(uint8_t rhport, + tusb_control_request_t const* request); #ifdef __cplusplus } diff --git a/tests/switch_pro_driver_context_test.cpp b/tests/switch_pro_driver_context_test.cpp index ce7dfac..fb595ed 100644 --- a/tests/switch_pro_driver_context_test.cpp +++ b/tests/switch_pro_driver_context_test.cpp @@ -1,4 +1,5 @@ #include "switch_pro_driver.h" +#include "usb_output_driver.h" #include "controller_color_config.h" #include "tusb.h" #include "pico/time.h" @@ -57,8 +58,8 @@ void initialize_contexts() { for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { hid_ready[instance] = true; hid_report_succeeds[instance] = true; - switch_pro_init(instance); } + usb_output_driver_init(AdapterUsbMode::kSwitchProbe); clear_sent_reports(); } @@ -500,7 +501,7 @@ void test_rumble_callbacks_and_decoders_are_isolated() { initialize_contexts(); rumble_events = {}; for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { - switch_pro_set_rumble_callback(instance, rumble_callback); + usb_output_driver_set_rumble_callback(instance, rumble_callback); } constexpr uint32_t neutral = 0x40400100u; auto full_payload = rumble_payload(type_2(64, 16, 64, 16), neutral); @@ -592,7 +593,7 @@ void test_lifecycle_and_invalid_instances() { switch_pro_set_input(kInvalidInstance, ignored, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); - switch_pro_set_rumble_callback(kInvalidInstance, rumble_callback); + usb_output_driver_set_rumble_callback(kInvalidInstance, rumble_callback); expect(!switch_pro_task(kInvalidInstance), "invalid instance ran a driver task"); expect(!switch_pro_is_ready(kInvalidInstance), diff --git a/tests/test_adapter_host_probe_native.py b/tests/test_adapter_host_probe_native.py new file mode 100644 index 0000000..24d31e2 --- /dev/null +++ b/tests/test_adapter_host_probe_native.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_adapter_host_probe_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + executable = tmp_path / "adapter_host_probe_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", + f"-I{root / 'tests' / 'mode_native_stubs'}", + f"-I{root}", + str(root / "adapter_host_probe.cpp"), + str(root / "tests" / "adapter_host_probe_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_adapter_mode_controller_native.py b/tests/test_adapter_mode_controller_native.py new file mode 100644 index 0000000..29ea44a --- /dev/null +++ b/tests/test_adapter_mode_controller_native.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def test_adapter_mode_controller_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + executable = tmp_path / "adapter_mode_controller_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", + f"-I{root / 'tests' / 'mode_native_stubs'}", + f"-I{root}", + str(root / "adapter_mode_controller.cpp"), + str(root / "tests" / "adapter_mode_controller_test.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_bluepad32_backend_lifecycle_native.py b/tests/test_bluepad32_backend_lifecycle_native.py index 53157a8..489702c 100644 --- a/tests/test_bluepad32_backend_lifecycle_native.py +++ b/tests/test_bluepad32_backend_lifecycle_native.py @@ -23,7 +23,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None: "-DSWITCH_PICO_HID_INSTANCE_COUNT=4", ] if adapter_feasibility: - command.append("-DSWITCH_PICO_ADAPTER_FEASIBILITY=1") + command.append("-DSWITCH_PICO_USB_OUTPUT_MODES=1") command.extend( [ f"-I{root / 'tests' / 'bluepad32_native_stubs'}", diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 0086969..c44176a 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -36,15 +36,28 @@ def make_response( class FakeDevice: bus = 1 address = 7 + port_numbers = (1,) def __init__(self) -> None: - self.configuration = struct.pack(" None: device = FakeDevice() before = config_manager.read_configuration(device) assert before.pairing_window_seconds == 60 + assert before.requested_mode == config_manager.REQUESTED_MODE_AUTO status = config_manager.write_configuration( device, - config_manager.AdapterConfiguration(90, before.generation, before.crc), + config_manager.AdapterConfiguration( + 90, + before.generation, + before.crc, + config_manager.REQUESTED_MODE_XINPUT, + ), 1.0, ) assert status.stored_generation == 4 - assert config_manager.read_configuration(device).pairing_window_seconds == 90 + stored = config_manager.read_configuration(device) + assert stored.pairing_window_seconds == 90 + assert stored.requested_mode == config_manager.REQUESTED_MODE_XINPUT + assert device.configuration == struct.pack( + " None: + device = FakeDevice() + generated_values = iter((0xFFFFFFFF, 0x80000000, 0xFEDCBA98)) + requested_bits: list[int] = [] + + def randbits(bits: int) -> int: + requested_bits.append(bits) + return next(generated_values) + + monkeypatch.setattr(config_manager.secrets, "randbits", randbits) + monkeypatch.setattr(config_manager.time, "sleep", lambda _seconds: None) + before = config_manager.read_configuration(device) + config_manager.write_configuration( + device, + config_manager.AdapterConfiguration( + 90, + before.generation, + before.crc, + config_manager.REQUESTED_MODE_AUTO, + ), + 1.0, + ) + config_manager.reset_configuration(device, 1.0) + config_manager.set_mode( + device, config_manager.REQUESTED_MODE_SWITCH, 1.0 + ) + + transaction_ids = [ + struct.unpack_from(" None: + device = FakeDevice() + monkeypatch.setattr(config_manager.time, "sleep", lambda _seconds: None) + monkeypatch.setattr( + config_manager.secrets, "randbits", lambda _bits: 0x12345678 + ) + + status = config_manager.set_mode( + device, config_manager.REQUESTED_MODE_XINPUT, 1.0 + ) + + mode_payload = struct.pack( + " None: + device = FakeDevice() + device.fail_mode_status = failure_status + monkeypatch.setattr(config_manager.time, "sleep", lambda _seconds: None) + + with pytest.raises(config_manager.ConfigManagerError, match=message): + config_manager.configure_mode( + device, config_manager.REQUESTED_MODE_SWITCH, 1.0 + ) + + assert config_manager.OP_REBOOT not in device.requests + + +def test_mode_transaction_must_correlate_before_reboot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class WrongTransactionDevice(FakeDevice): + def _transaction_payload(self) -> bytes: + payload = bytearray(super()._transaction_payload()) + struct.pack_into(" None: + device = FakeDevice() + device.configuration = struct.pack(" None: + previous = FakeDevice() + if requested_mode == config_manager.REQUESTED_MODE_AUTO: + previous.configuration = struct.pack( + " None: + previous = FakeDevice() + previous.port_numbers = (2, 1) + other = FakeDevice() + other.address = 8 + other.port_numbers = (2, 2) + replacement = FakeDevice() + replacement.address = 9 + replacement.port_numbers = previous.port_numbers + replacement.configuration = struct.pack( + " None: + previous = FakeDevice() + previous.port_numbers = None + other = FakeDevice() + other.address = 8 + other.port_numbers = None + monkeypatch.setattr( + config_manager, + "_candidate_devices", + lambda: (previous, other), + ) + + with pytest.raises( + config_manager.ConfigManagerError, + match="topology is unavailable.*multiple", + ): + config_manager.configure_mode( + previous, config_manager.REQUESTED_MODE_SWITCH, 1.0 + ) + + assert previous.out_requests == [] + assert other.out_requests == [] + + +@pytest.mark.parametrize( + ("requested_mode", "stored_mode", "active_mode", "message"), + ( + ( + config_manager.REQUESTED_MODE_SWITCH, + config_manager.REQUESTED_MODE_SWITCH, + config_manager.ACTIVE_MODE_SWITCH_PROBE, + "activated", + ), + ( + config_manager.REQUESTED_MODE_SWITCH, + config_manager.REQUESTED_MODE_AUTO, + config_manager.ACTIVE_MODE_SWITCH, + "was not stored", + ), + ( + config_manager.REQUESTED_MODE_DINPUT, + config_manager.REQUESTED_MODE_DINPUT, + config_manager.ACTIVE_MODE_MAC, + "activated", + ), + ( + config_manager.REQUESTED_MODE_MAC, + config_manager.REQUESTED_MODE_DINPUT, + config_manager.ACTIVE_MODE_MAC, + "was not stored", + ), + ), +) +def test_mode_verifies_requested_and_active_state_after_reenumeration( + monkeypatch: pytest.MonkeyPatch, + requested_mode: int, + stored_mode: int, + active_mode: int, + message: str, +) -> None: + previous = FakeDevice() + reenumerated = FakeDevice() + reenumerated.address = 8 + reenumerated.configuration = struct.pack(" None: + device = FakeDevice() + monkeypatch.setattr( + config_manager, "_candidate_devices", lambda: (device,) + ) + with pytest.raises( + config_manager.ConfigManagerError, match="did not disappear" + ): + snapshot = config_manager._capture_reenumeration_snapshot(device) + config_manager._wait_for_reenumeration(snapshot, 0) + + monkeypatch.setattr(config_manager, "_candidate_devices", lambda: ()) + with pytest.raises( + config_manager.ConfigManagerError, match="did not re-enumerate" + ): + snapshot = config_manager._capture_reenumeration_snapshot(device) + config_manager._wait_for_reenumeration(snapshot, 0) + + +def test_requested_and_active_mode_response_validation() -> None: + device = FakeDevice() + device.configuration = struct.pack( + " None: @@ -711,6 +1277,7 @@ def test_profile_reset_and_activate_wait_for_correlated_transactions( reset = config_manager.reset_profile(device, identity, 2, 1.0) + assert reset.transaction_id == device.profile_transaction_id == 1 assert reset.status == config_manager.STATUS_OK assert reset.stored_generation == 8 @@ -981,6 +1548,21 @@ def test_status_and_pairing_commands( output = capsys.readouterr().out assert "Firmware: 0.2.0" in output assert "Pairing window: 60 seconds" in output + assert "Requested USB mode: auto" in output + assert "Active USB mode: Switch probe" in output + assert "Mode capabilities: input, rumble, motion" in output + + device.configuration = struct.pack( + " None: + device = FakeDevice() + device.configuration = struct.pack( + " None: + previous = FakeDevice() + reenumerated = FakeDevice() + reenumerated.address = 8 + reenumerated.configuration = struct.pack( + " None: + for mode in config_manager.REQUESTED_MODE_NAMES: + args = config_manager.build_parser().parse_args(["mode", mode]) + assert args.mode == mode + + +def test_candidate_discovery_checks_all_usb_identities( + monkeypatch: pytest.MonkeyPatch, +) -> None: + lookups: list[tuple[int, int]] = [] + + assert config_manager.USB_IDENTITIES == ( + (0x057E, 0x2009), + (0xCAFE, 0x4010), + (0xCAFE, 0x4020), + (0xCAFE, 0x4021), + ) + + def find(**arguments: object) -> tuple[object, ...]: + lookups.append( + ( + int(arguments["idVendor"]), + int(arguments["idProduct"]), + ) + ) + return () + + monkeypatch.setattr(config_manager.usb.core, "find", find) + + assert list(config_manager._candidate_devices()) == [] + assert tuple(lookups) == config_manager.USB_IDENTITIES + + def test_find_requires_selector_for_multiple_picos( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_configuration_service_native.py b/tests/test_configuration_service_native.py new file mode 100644 index 0000000..7707392 --- /dev/null +++ b/tests/test_configuration_service_native.py @@ -0,0 +1,36 @@ +import shutil +import subprocess +from pathlib import Path + + +def test_configuration_service_native(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + + executable = tmp_path / "configuration_service_test" + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-I{root / 'tests' / 'bluepad32_native_stubs'}", + f"-I{root}", + str(root / "tests" / "configuration_service_test.cpp"), + str(root / "adapter_configuration.cpp"), + str(root / "configuration_service.cpp"), + str(root / "configuration_storage.cpp"), + str(root / "configuration_transaction.cpp"), + "-o", + str(executable), + ], + check=True, + cwd=root, + ) + subprocess.run([str(executable)], check=True, cwd=root) + subprocess.run( + [str(executable), "abandoned-receive"], check=True, cwd=root + ) diff --git a/tests/test_generic_hid_native.py b/tests/test_generic_hid_native.py new file mode 100644 index 0000000..7c3e500 --- /dev/null +++ b/tests/test_generic_hid_native.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def host_compiler() -> str: + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + return compiler + + +def compile_cpp( + root: Path, + compiler: str, + output: Path, + sources: list[Path], + definitions: list[str], + include_stubs: bool = False, +) -> subprocess.CompletedProcess[str]: + command = [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + *[f"-D{definition}" for definition in definitions], + ] + if include_stubs: + command.append(f"-I{root / 'tests' / 'native_stubs'}") + command.extend( + [ + f"-I{root}", + *[str(source) for source in sources], + "-o", + str(output), + ] + ) + return subprocess.run( + command, + check=False, + cwd=root, + text=True, + capture_output=True, + ) + + +def test_generic_hid_descriptor_and_report_contracts(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = host_compiler() + source = root / "tests" / "generic_hid_descriptors_test.cpp" + for instance_count in range(1, 5): + executable = tmp_path / f"generic_hid_descriptors_{instance_count}_test" + result = compile_cpp( + root, + compiler, + executable, + [source], + [ + f"EXPECTED_HID_INSTANCE_COUNT={instance_count}", + f"SWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", + ], + ) + assert result.returncode == 0, result.stderr + subprocess.run([str(executable)], check=True, cwd=root) + + +def test_generic_hid_rejects_unsupported_interface_counts( + tmp_path: Path, +) -> None: + root = Path(__file__).resolve().parents[1] + compiler = host_compiler() + source = root / "tests" / "generic_hid_descriptors_test.cpp" + for instance_count in (0, 5): + executable = tmp_path / f"generic_hid_descriptors_{instance_count}_test" + result = compile_cpp( + root, + compiler, + executable, + [source], + [ + f"EXPECTED_HID_INSTANCE_COUNT={instance_count}", + f"SWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", + ], + ) + assert result.returncode != 0, ( + f"unsupported interface count {instance_count} compiled successfully" + ) + + +def test_generic_hid_driver_contexts(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + executable = tmp_path / "generic_hid_driver_test" + result = compile_cpp( + root, + host_compiler(), + executable, + [ + root / "generic_hid_driver.cpp", + root / "tests" / "generic_hid_driver_test.cpp", + ], + ["SWITCH_PICO_HID_INSTANCE_COUNT=4"], + include_stubs=True, + ) + assert result.returncode == 0, result.stderr + subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_switch_pro_driver_context_native.py b/tests/test_switch_pro_driver_context_native.py index 45102de..d3c2963 100644 --- a/tests/test_switch_pro_driver_context_native.py +++ b/tests/test_switch_pro_driver_context_native.py @@ -23,6 +23,7 @@ def test_switch_pro_driver_four_contexts_native(tmp_path: Path) -> None: f"-I{root / 'tests' / 'native_stubs'}", f"-I{root}", str(root / "switch_pro_driver.cpp"), + str(root / "usb_output_driver.cpp"), str(root / "switch_haptics.cpp"), str(root / "tests" / "switch_pro_driver_context_test.cpp"), "-o", diff --git a/tests/test_usb_output_driver_native.py b/tests/test_usb_output_driver_native.py new file mode 100644 index 0000000..b6f4ff8 --- /dev/null +++ b/tests/test_usb_output_driver_native.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def host_compiler() -> str: + compiler = shutil.which("c++") or shutil.which("g++") + assert compiler is not None, "a host C++ compiler is required" + return compiler + + +def test_usb_output_driver_contracts(tmp_path: Path) -> None: + root = Path(__file__).resolve().parents[1] + compiler = host_compiler() + backends = ( + ("uart", []), + ("bluepad32", ["-DSWITCH_PICO_BLUEPAD32=1"]), + ) + for backend, backend_definitions in backends: + for instance_count in range(1, 5): + executable = ( + tmp_path + / f"usb_output_driver_{backend}_{instance_count}_test" + ) + result = subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + f"-DSWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", + "-DSWITCH_PICO_USB_OUTPUT_MODES=1", + *backend_definitions, + f"-I{root / 'tests' / 'native_stubs'}", + f"-I{root}", + str(root / "tests" / "usb_output_driver_test.cpp"), + str(root / "switch_pro_driver.cpp"), + str(root / "usb_output_driver.cpp"), + str(root / "generic_hid_driver.cpp"), + str(root / "xinput_driver.cpp"), + str(root / "switch_haptics.cpp"), + "-o", + str(executable), + ], + check=False, + cwd=root, + text=True, + capture_output=True, + ) + assert result.returncode == 0, result.stderr + _ = subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/test_xinput_feasibility_native.py b/tests/test_xinput_feasibility_native.py deleted file mode 100644 index 7a94c20..0000000 --- a/tests/test_xinput_feasibility_native.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import shutil -import subprocess -from pathlib import Path - - -def host_compiler() -> str: - compiler = shutil.which("c++") or shutil.which("g++") - assert compiler is not None, "a host C++ compiler is required" - return compiler - - -def test_xinput_feasibility_contracts(tmp_path: Path) -> None: - root = Path(__file__).resolve().parents[1] - compiler = host_compiler() - for instance_count in range(1, 5): - executable = tmp_path / f"xinput_feasibility_{instance_count}_test" - result = subprocess.run( - [ - compiler, - "-std=c++17", - "-Wall", - "-Wextra", - "-Werror", - "-pedantic", - f"-DSWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", - f"-I{root}", - str(root / "tests" / "xinput_feasibility_test.cpp"), - "-o", - str(executable), - ], - check=False, - cwd=root, - text=True, - capture_output=True, - ) - assert result.returncode == 0, result.stderr - _ = subprocess.run([str(executable)], check=True, cwd=root) diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 27d9e2c..2b3cfa7 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -6,6 +6,7 @@ #include #include +#include "usb_output_driver.h" namespace { @@ -14,6 +15,21 @@ ConfigurationServiceSnapshot current_configuration{}; ProfileServiceListSnapshot current_profile_list{}; ProfileServiceSelectedSnapshot current_profile_selected{}; ProfileServiceTransactionSnapshot current_profile_transaction{}; +AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe; +uint8_t current_capabilities = + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; +ConfigurationTransactionStatus mode_set_result = + ConfigurationTransactionStatus::kPending; +uint32_t mode_set_transaction_id = 0; +AdapterRequestedMode mode_set_requested_mode = AdapterRequestedMode::kAuto; +AdapterModeAvailability mode_set_availability{}; +AdapterModeAvailability runtime_mode_availability{true, true, true, true}; +uint32_t mode_availability_query_count = 0; +uint32_t mode_set_call_count = 0; +uint32_t correlated_reboot_transaction_id = 0; +uint32_t reboot_transaction_id = 0; +uint32_t reboot_call_count = 0; bool refresh_requested = false; bool clear_requested = false; std::vector control_payload; @@ -158,10 +174,10 @@ void perform_out(UsbConfigurationManagement::Operation operation, tusb_control_request_t request = setup_request( operation, TUSB_DIR_OUT, static_cast(next_out_payload.size())); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request), "valid OUT setup was rejected"); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_ACK, &request) == expected_ack, "OUT acknowledgement result was incorrect"); } @@ -177,7 +193,7 @@ void test_vendor_requests() { tusb_control_request_t request = setup_request( Operation::kPairingRead, TUSB_DIR_IN, kMaximumResponseSize); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload[5] == static_cast(Operation::kPairingRead) && @@ -221,18 +237,175 @@ void test_vendor_requests() { request = setup_request( Operation::kPairingRefresh, TUSB_DIR_OUT, static_cast(next_out_payload.size())); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && - !tud_vendor_control_xfer_cb( + !usb_configuration_management_vendor_control( 0, CONTROL_STAGE_ACK, &request), "bad request CRC was accepted"); request.wValue = 0; - require(!tud_vendor_control_xfer_cb( + require(!usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request), "request with invalid magic was accepted"); } +void test_mode_vendor_requests() { + using namespace UsbConfigurationManagement; + current_configuration.configuration.requested_mode = + AdapterRequestedMode::kXInput; + current_active_mode = AdapterUsbMode::kSwitchProbe; + + tusb_control_request_t request = + setup_request(Operation::kInfo, TUSB_DIR_IN, kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[kResponseHeaderSize + 4] == + static_cast(AdapterUsbMode::kSwitchProbe) && + control_payload[kResponseHeaderSize + 5] == + current_capabilities, + "info response did not report active mode capabilities"); + + current_active_mode = AdapterUsbMode::kDInput; + current_capabilities = USB_OUTPUT_CAPABILITY_INPUT; + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[kResponseHeaderSize + 4] == + static_cast(AdapterUsbMode::kDInput) && + control_payload[kResponseHeaderSize + 5] == + USB_OUTPUT_CAPABILITY_INPUT, + "generic info response promised unsupported output capabilities"); + current_active_mode = AdapterUsbMode::kSwitchProbe; + current_capabilities = + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; + + request = setup_request( + Operation::kConfigurationRead, TUSB_DIR_IN, + kMaximumResponseSize); + require(usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload.size() == + kResponseHeaderSize + + ADAPTER_CONFIGURATION_ENCODED_SIZE && + control_payload[10] == + ADAPTER_CONFIGURATION_SCHEMA_VERSION && + control_payload[kResponseHeaderSize + 2] == + static_cast(AdapterRequestedMode::kXInput), + "configuration response did not keep requested mode separate"); + + std::vector mode_set(5); + write_u32(&mode_set, 0, 0x12345678); + mode_set[4] = + static_cast(AdapterRequestedMode::kXInput); + const std::vector encoded = + make_request(Operation::kModeSet, mode_set); + require(encoded.size() == kRequestHeaderSize + 5 && + encoded[5] == + static_cast(Operation::kModeSet) && + encoded[8] == 5 && + read_u32(encoded, kRequestHeaderSize) == 0x12345678 && + encoded[kRequestHeaderSize + 4] == + static_cast( + AdapterRequestedMode::kXInput), + "mode-set request envelope does not match the protocol"); + + mode_set_result = ConfigurationTransactionStatus::kPending; + perform_out(Operation::kModeSet, mode_set); + require(mode_set_transaction_id == 0x12345678 && + mode_set_requested_mode == + AdapterRequestedMode::kXInput && + mode_set_availability.switch_mode && + mode_set_availability.xinput_mode && + mode_set_availability.dinput_mode && + mode_set_availability.mac_mode && + mode_availability_query_count == 1, + "XInput mode set did not use runtime availability"); + write_u32(&mode_set, 0, 0x12345679); + mode_set[4] = static_cast(AdapterRequestedMode::kSwitch); + perform_out(Operation::kModeSet, mode_set); + require(mode_set_requested_mode == AdapterRequestedMode::kSwitch && + mode_availability_query_count == 2, + "Switch mode set did not use runtime availability"); + + mode_set_result = ConfigurationTransactionStatus::kBusy; + write_u32(&mode_set, 0, 0x1234567a); + perform_out(Operation::kModeSet, mode_set, false); + mode_set_result = ConfigurationTransactionStatus::kStorageError; + write_u32(&mode_set, 0, 0x1234567b); + perform_out(Operation::kModeSet, mode_set, false); + + const uint32_t calls_before_invalid = mode_set_call_count; + const uint32_t availability_queries_before_invalid = + mode_availability_query_count; + for (const uint32_t transaction_id : {0u, 0x80000000u}) { + write_u32(&mode_set, 0, transaction_id); + perform_out(Operation::kModeSet, mode_set, false); + } + write_u32(&mode_set, 0, 0x1234567c); + mode_set[4] = 0xff; + perform_out(Operation::kModeSet, mode_set, false); + require(mode_set_call_count == calls_before_invalid && + mode_availability_query_count == + availability_queries_before_invalid, + "malformed mode request reached runtime mode selection"); + + mode_set_result = ConfigurationTransactionStatus::kPending; + for (const AdapterRequestedMode generic_mode : { + AdapterRequestedMode::kDInput, + AdapterRequestedMode::kMac, + }) { + write_u32(&mode_set, 0, + 0x12345680u + + static_cast(generic_mode)); + mode_set[4] = static_cast(generic_mode); + perform_out(Operation::kModeSet, mode_set); + require(mode_set_requested_mode == generic_mode, + "generic mode request was not dispatched"); + } + require(mode_set_call_count == calls_before_invalid + 2 && + mode_availability_query_count == + availability_queries_before_invalid + 2, + "generic modes did not use runtime availability"); + + request = setup_request( + Operation::kModeSet, TUSB_DIR_OUT, kRequestHeaderSize + 4); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "short mode-set request was accepted"); + + std::vector reboot(4); + write_u32(&reboot, 0, 0x12345678); + const std::vector encoded_reboot = + make_request(Operation::kReboot, reboot); + require(encoded_reboot.size() == kRequestHeaderSize + 4 && + encoded_reboot[5] == + static_cast(Operation::kReboot) && + encoded_reboot[8] == 4 && + read_u32(encoded_reboot, kRequestHeaderSize) == + 0x12345678, + "reboot request envelope does not match the protocol"); + + correlated_reboot_transaction_id = 0x12345678; + perform_out(Operation::kReboot, reboot); + require(reboot_transaction_id == correlated_reboot_transaction_id, + "correlated reboot request was not dispatched"); + write_u32(&reboot, 0, 0x12345679); + perform_out(Operation::kReboot, reboot, false); + const uint32_t reboot_calls_before_invalid = reboot_call_count; + for (const uint32_t transaction_id : {0u, 0x80000000u}) { + write_u32(&reboot, 0, transaction_id); + perform_out(Operation::kReboot, reboot, false); + } + request = setup_request( + Operation::kReboot, TUSB_DIR_OUT, kRequestHeaderSize + 5); + require(!usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request), + "oversized reboot request was accepted"); + require(reboot_call_count == reboot_calls_before_invalid, + "malformed reboot transaction reached the helper"); +} + + void test_profile_vendor_requests() { using namespace UsbConfigurationManagement; ControllerIdentity expected_identity{}; @@ -251,7 +424,7 @@ void test_profile_vendor_requests() { current_profile_list.rows[1].active_profile = 2; tusb_control_request_t request = setup_request( Operation::kProfileList, TUSB_DIR_IN, kMaximumResponseSize); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload.size() == kResponseHeaderSize + 33 && control_payload[5] == @@ -274,7 +447,7 @@ void test_profile_vendor_requests() { controller_profile_default(expected_identity, 2); request = setup_request( Operation::kProfileRead, TUSB_DIR_IN, kMaximumResponseSize); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload.size() == kResponseHeaderSize + @@ -299,7 +472,7 @@ void test_profile_vendor_requests() { request = setup_request( Operation::kProfileTransactionStatus, TUSB_DIR_IN, kMaximumResponseSize); - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload.size() == kResponseHeaderSize + 20 && control_payload[6] == @@ -313,7 +486,7 @@ void test_profile_vendor_requests() { current_profile_transaction.transaction.stored_generation = 0x11223344; current_profile_transaction.transaction.stored_crc = 0xaabbccdd; - require(tud_vendor_control_xfer_cb( + require(usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request) && control_payload[6] == static_cast(Status::kOk) && read_u32(control_payload, kResponseHeaderSize) == @@ -391,7 +564,7 @@ void test_profile_vendor_requests() { perform_out(Operation::kProfileActivate, mutation, false); request = setup_request( Operation::kProfileReset, TUSB_DIR_OUT, kRequestHeaderSize + 15); - require(!tud_vendor_control_xfer_cb( + require(!usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request), "legacy profile reset payload was accepted"); @@ -400,7 +573,7 @@ void test_profile_vendor_requests() { request = setup_request( Operation::kProfileSelect, TUSB_DIR_OUT, kRequestHeaderSize + 14); - require(!tud_vendor_control_xfer_cb( + require(!usb_configuration_management_vendor_control( 0, CONTROL_STAGE_SETUP, &request), "short profile selection request was accepted"); } @@ -448,6 +621,35 @@ ConfigurationTransactionStatus configuration_service_reset(uint32_t) { return ConfigurationTransactionStatus::kPending; } +const AdapterModeAvailability& adapter_usb_mode_availability() { + ++mode_availability_query_count; + return runtime_mode_availability; +} + +ConfigurationTransactionStatus configuration_service_set_mode( + uint32_t transaction_id, AdapterRequestedMode requested_mode, + const AdapterModeAvailability& availability) { + mode_set_transaction_id = transaction_id; + mode_set_requested_mode = requested_mode; + mode_set_availability = availability; + ++mode_set_call_count; + if (!adapter_requested_mode_available(requested_mode, availability)) { + return ConfigurationTransactionStatus::kUnsupportedSchema; + } + return mode_set_result; +} + +AdapterUsbMode usb_output_driver_mode() { return current_active_mode; } +uint8_t usb_output_driver_capabilities() { + return current_capabilities; +} + +bool adapter_reboot_for_mode_transaction(uint32_t transaction_id) { + reboot_transaction_id = transaction_id; + ++reboot_call_count; + return transaction_id == correlated_reboot_transaction_id; +} + ConfigurationTransactionStatus profile_service_select( const ControllerIdentity& identity, uint8_t selected_profile) { profile_identity = identity; @@ -521,8 +723,9 @@ void bluepad32_input_backend_request_pairing_snapshot() { refresh_requested = true; } -void bluepad32_input_backend_clear_pairings() { +uint32_t bluepad32_input_backend_clear_pairings() { clear_requested = true; + return 1; } void bluepad32_input_backend_pairing_snapshot( @@ -530,6 +733,11 @@ void bluepad32_input_backend_pairing_snapshot( *out = current_pairings; } +bool adapter_host_probe_vendor_control( + uint8_t, uint8_t, const tusb_control_request_t*) { + return false; +} + bool tud_control_xfer(uint8_t, const tusb_control_request_t* request, void* buffer, uint16_t length) { if (request->bmRequestType_bit.direction == TUSB_DIR_OUT) { @@ -560,6 +768,7 @@ int main() { test_envelope_encoding(); test_pairing_encoding(); test_vendor_requests(); + test_mode_vendor_requests(); test_profile_vendor_requests(); return 0; } diff --git a/tests/usb_management_native_stubs/tusb.h b/tests/usb_management_native_stubs/tusb.h index 2ec477e..3db2bb0 100644 --- a/tests/usb_management_native_stubs/tusb.h +++ b/tests/usb_management_native_stubs/tusb.h @@ -36,9 +36,6 @@ bool tud_control_xfer(uint8_t rhport, void* buffer, uint16_t length); bool tud_control_status(uint8_t rhport, const tusb_control_request_t* request); -bool tud_vendor_control_xfer_cb( - uint8_t rhport, uint8_t stage, - const tusb_control_request_t* request); #ifdef __cplusplus } #endif diff --git a/tests/usb_output_driver_test.cpp b/tests/usb_output_driver_test.cpp new file mode 100644 index 0000000..b2f61c5 --- /dev/null +++ b/tests/usb_output_driver_test.cpp @@ -0,0 +1,840 @@ +#include +#include +#include +#include +#include + +#include "adapter_host_probe_state.h" +#include "generic_hid_descriptors.h" +#include "generic_hid_driver.h" +#include "xinput_descriptors.h" +#include "xinput_protocol.h" +#include "device/usbd_pvt.h" +#include "pico/time.h" +#include "switch_pro_driver.h" +#include "tusb.h" +#include "usb_output_driver.h" +#include "xinput_driver.h" + +namespace { + +int failures = 0; + +constexpr uint8_t kInstanceCount = SWITCH_PICO_HID_INSTANCE_COUNT; +constexpr uint8_t kInvalidInstance = kInstanceCount; + +struct EndpointHarness { + bool opened = false; + bool busy = false; + bool claimed = false; + uint8_t* armed_buffer = nullptr; + uint16_t armed_length = 0; + std::array last_transfer{}; + uint16_t last_transfer_length = 0; + unsigned transfer_count = 0; +}; + +struct RumbleEvent { + unsigned count = 0; + ControllerRumbleOutput output{}; +}; + +std::array endpoint_harness{}; +std::array xinput_rumble_events{}; +uint64_t now_ms = 0; +uint32_t random_value = 1; +bool usb_ready = true; +unsigned hid_report_count = 0; +unsigned switch_inactive_rumble_count = 0; +unsigned observed_string_count = 0; +uint8_t last_observed_string = 0; + +#ifdef SWITCH_PICO_BLUEPAD32 +bool management_vendor_control_result = false; +unsigned management_vendor_control_count = 0; +uint8_t management_vendor_control_rhport = 0; +uint8_t management_vendor_control_stage = 0; +const tusb_control_request_t* management_vendor_control_request = nullptr; +#endif + +void expect(bool condition, const char *message) { + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } +} + +uint16_t read_le16(const uint8_t *data) { + return static_cast(data[0] | (data[1] << 8)); +} + +uint32_t read_le32(const uint8_t *data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | + (static_cast(data[3]) << 24); +} + +void test_device_and_configuration_descriptors() { + using namespace XInput; + expect(read_le16(&kSwitchProbeDeviceDescriptor[8]) == + kSwitchProbeVendorId, + "Switch probe VID mismatch"); + expect(read_le16(&kSwitchProbeDeviceDescriptor[10]) == + kSwitchProbeProductId, + "Switch probe PID mismatch"); + expect(read_le16(&kSwitchProbeDeviceDescriptor[12]) == + kSwitchProbeDeviceRevision, + "Switch probe revision mismatch"); + expect(kSwitchProbeDeviceRevision != 0x0210, + "Switch probe reuses the genuine controller cache identity"); + expect(read_le16(&kDeviceDescriptor[8]) == kDevelopmentVendorId, + "development VID mismatch"); + expect(read_le16(&kDeviceDescriptor[10]) == kDevelopmentProductId, + "development PID mismatch"); + expect(read_le16(&kDeviceDescriptor[12]) == + kDevelopmentDeviceRevision, + "development revision mismatch"); + expect(kDeviceDescriptor[4] == 0 && kDeviceDescriptor[5] == 0 && + kDeviceDescriptor[6] == 0, + "multi-interface development device is not composite"); + expect(kDevelopmentVendorId != 0x045e, + "development device must not impersonate Microsoft's VID"); + expect(read_le16(&kConfigurationDescriptor[2]) == + sizeof(kConfigurationDescriptor), + "configuration total length mismatch"); + expect(kConfigurationDescriptor[4] == SWITCH_PICO_HID_INSTANCE_COUNT, + "configuration interface count mismatch"); + + std::array endpoints{}; + for (uint8_t instance = 0; instance < SWITCH_PICO_HID_INSTANCE_COUNT; + ++instance) { + const size_t offset = 9 + instance * kInterfaceDescriptorSize; + const uint8_t *interface = &kConfigurationDescriptor[offset]; + expect(interface[0] == 9 && interface[1] == 4, + "missing interface descriptor"); + expect(interface[2] == instance, "interface number mismatch"); + expect(interface[5] == 0xff && interface[6] == 0x5d && + interface[7] == 0x01, + "XInput interface class tuple mismatch"); + expect(interface[9] == 0x10 && interface[10] == 0x21, + "XInput capability descriptor missing"); + const uint8_t in_endpoint = interface[27]; + const uint8_t out_endpoint = interface[34]; + expect(in_endpoint == static_cast(0x81 + instance), + "input endpoint mismatch"); + expect(out_endpoint == static_cast(0x01 + instance), + "output endpoint mismatch"); + expect(interface[15] == in_endpoint && interface[21] == out_endpoint, + "capability descriptor endpoint mismatch"); + expect(!endpoints[in_endpoint & 0x0f] && + !endpoints[out_endpoint & 0x0f], + "endpoint number reused"); + endpoints[in_endpoint & 0x0f] = true; + } +} + +void test_microsoft_compatible_id_descriptor() { + using namespace XInput; + expect(read_le32(kMsCompatIdDescriptor) == sizeof(kMsCompatIdDescriptor), + "Microsoft descriptor total length mismatch"); + expect(read_le16(&kMsCompatIdDescriptor[4]) == 0x0100, + "Microsoft descriptor version mismatch"); + expect(read_le16(&kMsCompatIdDescriptor[6]) == kMsCompatIdIndex, + "Microsoft descriptor index mismatch"); + expect(kMsCompatIdDescriptor[8] == SWITCH_PICO_HID_INSTANCE_COUNT, + "Microsoft function count mismatch"); + for (uint8_t instance = 0; + instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) { + const uint8_t *function = &kMsCompatIdDescriptor[16 + instance * 24]; + expect(function[0] == instance, + "Microsoft descriptor interface mismatch"); + expect(std::memcmp(&function[2], "XUSB10", 6) == 0, + "XUSB10 compatible ID missing"); + } + expect(read_le32(kProbeMsCompatIdDescriptor) == 16 && + kProbeMsCompatIdDescriptor[8] == 0, + "probe descriptor must expose no compatible functions"); +} + +void test_input_report_mapping() { + ControllerState state{}; + auto report = XInput::build_input_report(state); + expect(report.report_id == 0 && report.report_size == 20, + "neutral report header mismatch"); + expect(report.buttons == 0 && report.left_trigger == 0 && + report.right_trigger == 0, + "neutral report controls mismatch"); + expect(report.left_x == 0 && report.left_y == 0 && report.right_x == 0 && + report.right_y == 0, + "neutral axes mismatch"); + + state.dpad_up = true; + state.button_south = true; + state.button_east = true; + state.button_west = true; + state.button_north = true; + state.button_start = true; + state.button_select = true; + state.button_system = true; + state.left_trigger = UINT16_MAX; + state.right_trigger = UINT16_MAX; + state.left_stick_x = INT16_MIN; + state.left_stick_y = INT16_MIN; + state.right_stick_x = INT16_MAX; + state.right_stick_y = INT16_MAX; + report = XInput::build_input_report(state); + expect((report.buttons & XInput::kDpadUp) != 0, + "D-pad mapping missing"); + expect((report.buttons & XInput::kButtonA) != 0 && + (report.buttons & XInput::kButtonB) != 0 && + (report.buttons & XInput::kButtonX) != 0 && + (report.buttons & XInput::kButtonY) != 0, + "positional face-button mapping mismatch"); + expect(report.left_trigger == 0xff && report.right_trigger == 0xff, + "full analog trigger mapping mismatch"); + expect(report.left_x == INT16_MIN && report.left_y == INT16_MAX && + report.right_x == INT16_MAX && + report.right_y == -INT16_MAX, + "axis endpoint mapping mismatch"); + + state.left_trigger = 0x8000; + state.right_trigger = 0x7fff; + report = XInput::build_input_report(state); + expect(report.left_trigger == 0x80 && report.right_trigger == 0x7f, + "analog trigger precision was discarded"); +} + +void test_rumble_report() { + const uint8_t packet[8] = {0x00, 0x08, 0x00, 0xa5, 0x5a, 0x00, 0x00, 0x00}; + ControllerRumbleOutput output{}; + expect(XInput::parse_rumble_report(packet, sizeof(packet), &output), + "valid rumble report rejected"); + expect(output.low_frequency_magnitude == 0xa5 && + output.high_frequency_magnitude == 0x5a, + "rumble magnitudes mapped incorrectly"); + expect(!XInput::parse_rumble_report(packet, 4, &output), + "truncated rumble report accepted"); + uint8_t wrong_type[8]{}; + expect(!XInput::parse_rumble_report(wrong_type, sizeof(wrong_type), + &output), + "wrong rumble report type accepted"); +} + +void test_host_probe_sequence() { + AdapterHostProbeState state; + state.note_ms_compat_id_request(10); + expect(!state.windows_confirmed(), + "compatible-ID request without signature confirmed Windows"); + state.note_ms_os_string(); + expect(state.saw_ms_os_string(), + "Microsoft OS string observation was not retained"); + state.note_ms_compat_id_request(20); + expect(state.windows_confirmed(), + "two-stage Windows signature not confirmed"); + expect(!state.should_reboot(119), "probe rebooted before delay"); + expect(state.should_reboot(120), "probe did not reboot at deadline"); + + AdapterHostProbeState wrapped; + wrapped.note_ms_os_string(); + wrapped.note_ms_compat_id_request(UINT32_MAX - 50); + expect(!wrapped.should_reboot(48), + "wrapped timer rebooted before deadline"); + expect(wrapped.should_reboot(49), "wrapped timer missed deadline"); +} + +void reset_usb_harness() { + endpoint_harness = {}; + xinput_rumble_events = {}; + now_ms = 0; + usb_ready = true; + hid_report_count = 0; + switch_inactive_rumble_count = 0; + observed_string_count = 0; + last_observed_string = 0; +} + +void xinput_rumble_callback(uint8_t instance, + const ControllerRumbleOutput& output) { + expect(instance < xinput_rumble_events.size(), + "XInput rumble used an invalid instance"); + if (instance < xinput_rumble_events.size()) { + ++xinput_rumble_events[instance].count; + xinput_rumble_events[instance].output = output; + } +} + +void inactive_switch_rumble_callback( + uint8_t instance, const ControllerRumbleOutput& output) { + (void)instance; + (void)output; + ++switch_inactive_rumble_count; +} + +void expect_usb_string(uint8_t index, const char* expected, + const char* message) { + const uint16_t* descriptor = tud_descriptor_string_cb(index, 0x0409); + const size_t length = std::strlen(expected); + bool matches = descriptor != nullptr && + (descriptor[0] & 0xffu) == 2u * length + 2u && + (descriptor[0] >> 8u) == TUSB_DESC_STRING; + if (matches) { + for (size_t i = 0; i < length; ++i) { + if (descriptor[i + 1] != + static_cast(expected[i])) { + matches = false; + break; + } + } + } + expect(matches, message); +} + +void test_switch_boundary_dispatch() { + + reset_usb_harness(); + usb_output_driver_init(AdapterUsbMode::kSwitchProbe); + expect(usb_output_driver_mode() == AdapterUsbMode::kSwitchProbe && + std::strcmp(usb_output_driver_name(), "SWITCH") == 0 && + std::strcmp(usb_output_driver_mode_name(), + "Switch probe") == 0, + "Switch boundary mode was not frozen"); + expect(usb_output_driver_capabilities() == + (USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION), + "Switch probe capabilities were not rumble plus motion"); + expect(std::memcmp(tud_descriptor_device_cb(), + XInput::kSwitchProbeDeviceDescriptor, + sizeof(XInput::kSwitchProbeDeviceDescriptor)) == 0, + "Switch probe device descriptor changed at the boundary"); + expect(std::memcmp(tud_descriptor_configuration_cb(0), + switch_pro_configuration_descriptor, + sizeof(switch_pro_configuration_descriptor)) == 0, + "Switch configuration descriptor changed at the boundary"); + const uint8_t* hid_descriptor = tud_hid_descriptor_report_cb(0); + expect(hid_descriptor != nullptr && + std::memcmp(hid_descriptor, switch_pro_report_descriptor, + sizeof(switch_pro_report_descriptor)) == 0, + "Switch HID report descriptor changed at the boundary"); + expect(tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr, + "Switch HID callback accepted an invalid instance"); + + std::array report{}; + expect(tud_hid_get_report_cb(0, 0, HID_REPORT_TYPE_INPUT, + report.data(), report.size()) == + sizeof(SwitchProReport), + "Switch GET_REPORT was not dispatched"); + expect(tud_hid_get_report_cb(kInvalidInstance, 0, + HID_REPORT_TYPE_INPUT, report.data(), + report.size()) == 0, + "Switch GET_REPORT accepted an invalid instance"); + + uint8_t driver_count = 0xff; + expect(usbd_app_driver_get_cb(&driver_count) == nullptr && + driver_count == 0, + "Switch mode registered the XInput custom class"); + expect(usbd_app_driver_get_cb(nullptr) == nullptr, + "custom class callback accepted a null count"); + + ControllerState switch_state{}; + switch_state.button_south = true; + usb_output_driver_set_input(0, switch_state, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD, + SWITCH_PRO_DIGITAL_TRIGGER_THRESHOLD); + now_ms = 15; + expect(usb_output_driver_task(0) && hid_report_count == 1, + "Switch input task was not dispatched"); + expect(tud_hid_get_report_cb(0, 0, HID_REPORT_TYPE_INPUT, + report.data(), report.size()) == + sizeof(SwitchProReport), + "dispatched Switch report was unavailable"); + SwitchProReport switch_report{}; + std::memcpy(&switch_report, report.data(), sizeof(switch_report)); + expect(switch_report.inputs.buttonB, + "Switch boundary did not apply input state"); + hid_report_count = 0; + + expect(usb_output_driver_is_ready(0), + "Switch context was not ready after initialization"); + tud_mount_cb(); + expect(!usb_output_driver_is_ready(0), + "Switch mount did not reset handshake readiness"); + expect(!usb_output_driver_task(0) && hid_report_count == 1, + "Switch startup identify was not dispatched"); + tud_umount_cb(); + expect(!usb_output_driver_is_ready(0), + "Switch unmount did not reset handshake readiness"); + expect(!usb_output_driver_task(kInvalidInstance), + "Switch task accepted an invalid instance"); + + const uint16_t* os_string = tud_descriptor_string_cb(0xee, 0x0409); + expect(os_string != nullptr && os_string[0] == 0x0312 && + os_string[1] == 'M' && os_string[2] == 'S' && + os_string[3] == 'F' && os_string[4] == 'T' && + os_string[5] == '1' && os_string[6] == '0' && + os_string[7] == '0' && + os_string[8] == XInput::kMsVendorRequest, + "Microsoft OS string changed at the boundary"); + expect(observed_string_count == 1 && last_observed_string == 0xee, + "host probe did not observe the Microsoft OS string"); + expect(!tud_control_request_cb(0, nullptr), + "generic control routing claimed an unhandled request"); +} +void test_manual_switch_selection() { + reset_usb_harness(); + usb_output_driver_init(AdapterUsbMode::kSwitch); + expect(usb_output_driver_mode() == AdapterUsbMode::kSwitch && + std::strcmp(usb_output_driver_mode_name(), "Switch") == 0, + "manual Switch mode was not frozen separately from probe mode"); + expect(std::memcmp(tud_descriptor_device_cb(), + switch_pro_device_descriptor, + sizeof(switch_pro_device_descriptor)) == 0, + "manual Switch did not use the production Switch descriptor"); + expect(tud_descriptor_string_cb(0xee, 0x0409) == nullptr, + "manual Switch exposed the automatic Windows probe string"); + expect(usb_output_driver_capabilities() == + (USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION), + "manual Switch capabilities lost rumble or motion"); +} + +void open_xinput_interfaces(usbd_class_driver_t const* driver) { + endpoint_harness = {}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + auto const* interface_descriptor = + reinterpret_cast( + &XInput::kConfigurationDescriptor[ + 9 + instance * XInput::kInterfaceDescriptorSize]); + expect(driver->open(0, interface_descriptor, + XInput::kInterfaceDescriptorSize) == + XInput::kInterfaceDescriptorSize, + "XInput interface did not open"); + expect(endpoint_harness[static_cast(0x81 + instance)] + .opened && + endpoint_harness[static_cast(0x01 + instance)] + .opened, + "XInput interface endpoints were not opened"); + } +} + +void test_xinput_boundary_dispatch() { + reset_usb_harness(); + usb_output_driver_init(AdapterUsbMode::kXInput); + + expect(usb_output_driver_mode() == AdapterUsbMode::kXInput && + std::strcmp(usb_output_driver_name(), "XINPUT") == 0 && + std::strcmp(usb_output_driver_mode_name(), "XInput") == 0, + "XInput boundary mode was not frozen"); + expect(usb_output_driver_capabilities() == + (USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE) && + (usb_output_driver_capabilities() & + USB_OUTPUT_CAPABILITY_MOTION) == 0, + "XInput capabilities did not report rumble without motion"); + expect(std::memcmp(tud_descriptor_device_cb(), + XInput::kDeviceDescriptor, + sizeof(XInput::kDeviceDescriptor)) == 0, + "XInput device descriptor changed at the boundary"); + expect(std::memcmp(tud_descriptor_configuration_cb(0), + XInput::kConfigurationDescriptor, + sizeof(XInput::kConfigurationDescriptor)) == 0, + "XInput configuration descriptor changed at the boundary"); + expect(tud_hid_descriptor_report_cb(0) == nullptr, + "inactive HID class claimed an XInput report descriptor"); + std::array hid_report{}; + expect(tud_hid_get_report_cb(0, 0, HID_REPORT_TYPE_INPUT, + hid_report.data(), hid_report.size()) == 0, + "inactive HID GET_REPORT handled XInput mode"); + + uint8_t driver_count = 0; + usbd_class_driver_t const* driver = + usbd_app_driver_get_cb(&driver_count); + expect(driver == xinput_class_driver() && driver_count == 1, + "XInput custom class was not selected"); + expect(driver != nullptr && std::strcmp(driver->name, "XINPUT") == 0, + "XInput custom class retained a feasibility name"); + if (driver == nullptr) { + return; + } + driver->init(); + open_xinput_interfaces(driver); + + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + usb_output_driver_set_rumble_callback( + instance, xinput_rumble_callback); + ControllerState state{}; + state.button_south = (instance & 1u) == 0; + state.button_north = (instance & 1u) != 0; + state.left_trigger = + static_cast(0x1000u * (instance + 1u)); + state.right_trigger = + static_cast(0x0800u * (instance + 1u)); + state.left_stick_x = static_cast(100 + instance); + state.right_stick_y = static_cast(-200 - instance); + usb_output_driver_set_input(instance, state, 1, 2); + expect(usb_output_driver_is_ready(instance), + "configured XInput instance was not ready"); + expect(usb_output_driver_task(instance), + "XInput input report was not dispatched"); + + const EndpointHarness& input_endpoint = + endpoint_harness[static_cast(0x81 + instance)]; + const XInput::InputReport expected = + XInput::build_input_report(state); + expect(input_endpoint.last_transfer_length == sizeof(expected) && + std::memcmp(input_endpoint.last_transfer.data(), + &expected, sizeof(expected)) == 0, + "XInput boundary changed an input report"); + } + expect(!usb_output_driver_task(kInvalidInstance), + "XInput task accepted an invalid instance"); + + switch_pro_set_rumble_callback(0, + inactive_switch_rumble_callback); + std::array switch_output{}; + switch_output[0] = REPORT_OUTPUT_10; + tud_hid_report_received_cb(0, 0, switch_output.data(), + switch_output.size()); + tud_hid_set_report_cb(0, REPORT_OUTPUT_10, HID_REPORT_TYPE_OUTPUT, + switch_output.data() + 1, + switch_output.size() - 1); + expect(switch_inactive_rumble_count == 0, + "inactive Switch HID callbacks handled XInput output"); + + tud_mount_cb(); + tud_umount_cb(); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(usb_output_driver_is_ready(instance), + "generic mount callback reset the active XInput class"); + } + + driver->reset(0); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(!usb_output_driver_is_ready(instance), + "XInput bus reset retained configured state"); + } + open_xinput_interfaces(driver); + + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + EndpointHarness& output_endpoint = + endpoint_harness[static_cast(0x01 + instance)]; + expect(output_endpoint.armed_buffer != nullptr && + output_endpoint.armed_length == 32, + "XInput output endpoint was not armed"); + if (output_endpoint.armed_buffer == nullptr) { + continue; + } + std::memset(output_endpoint.armed_buffer, 0, + output_endpoint.armed_length); + output_endpoint.armed_buffer[0] = 0x00; + output_endpoint.armed_buffer[1] = 0x08; + output_endpoint.armed_buffer[3] = + static_cast(0x20 + instance); + output_endpoint.armed_buffer[4] = + static_cast(0x40 + instance); + expect(driver->xfer_cb( + 0, static_cast(0x01 + instance), + XFER_RESULT_SUCCESS, 8), + "XInput output transfer was not rearmed"); + } + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(xinput_rumble_events[instance].count == 1 && + xinput_rumble_events[instance] + .output.low_frequency_magnitude == + static_cast(0x20 + instance) && + xinput_rumble_events[instance] + .output.high_frequency_magnitude == + static_cast(0x40 + instance), + "XInput rumble crossed instance boundaries"); + } + + expect_usb_string(1, "Switch Pico", + "XInput manufacturer string changed"); + expect_usb_string(2, "XInput Feasibility", + "XInput development product string changed"); + expect_usb_string(3, "XINPUT-PROTOTYPE", + "XInput development serial string changed"); +} + +void test_generic_boundary_dispatch( + AdapterUsbMode mode, const uint8_t* expected_device_descriptor, + const uint8_t* expected_report_descriptor, + const char* expected_driver_name, const char* expected_mode_name, + const char* expected_product, const char* expected_serial) { + reset_usb_harness(); + usb_output_driver_init(mode); + + expect(usb_output_driver_mode() == mode && + std::strcmp(usb_output_driver_name(), + expected_driver_name) == 0 && + std::strcmp(usb_output_driver_mode_name(), + expected_mode_name) == 0, + "generic boundary mode was not frozen"); + expect(usb_output_driver_capabilities() == + USB_OUTPUT_CAPABILITY_INPUT && + (usb_output_driver_capabilities() & + USB_OUTPUT_CAPABILITY_RUMBLE) == 0 && + (usb_output_driver_capabilities() & + USB_OUTPUT_CAPABILITY_MOTION) == 0, + "generic mode promised rumble or motion capability"); + const uint8_t* device_descriptor = tud_descriptor_device_cb(); + expect(device_descriptor != nullptr && + std::memcmp(device_descriptor, expected_device_descriptor, + sizeof(GenericHid::kDInputDeviceDescriptor)) == 0, + "generic device descriptor was not selected"); + const uint8_t* configuration_descriptor = + tud_descriptor_configuration_cb(0); + expect(configuration_descriptor != nullptr && + std::memcmp(configuration_descriptor, + GenericHid::kConfigurationDescriptor, + GenericHid::kConfigurationDescriptorSize) == 0, + "generic configuration descriptor was not selected"); + const uint8_t* report_descriptor = + tud_hid_descriptor_report_cb(0); + expect(report_descriptor != nullptr && + std::memcmp(report_descriptor, expected_report_descriptor, + sizeof(GenericHid::kDInputReportDescriptor)) == 0 && + tud_hid_descriptor_report_cb(kInvalidInstance) == nullptr, + "generic report descriptor routing was incorrect"); + + uint8_t driver_count = 0xff; + expect(usbd_app_driver_get_cb(&driver_count) == nullptr && + driver_count == 0, + "generic mode registered the XInput custom class"); + expect(tud_descriptor_string_cb(0xee, 0x0409) == nullptr, + "generic mode exposed the XInput Microsoft OS string"); + expect_usb_string(1, GenericHid::kManufacturerString, + "generic manufacturer string changed"); + expect_usb_string(2, expected_product, + "generic product string changed"); + expect_usb_string(3, expected_serial, + "generic serial string changed"); + + std::array expected_reports{}; + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + ControllerState state{}; + state.left_stick_x = + static_cast(INT16_MIN + instance); + state.right_stick_y = + static_cast(INT16_MAX - instance); + state.left_trigger = + static_cast(0x1111u * (instance + 1u)); + state.button_south = (instance & 1u) == 0; + state.button_north = (instance & 1u) != 0; + usb_output_driver_set_input(instance, state, 1, 2); + expected_reports[instance] = + GenericHid::build_input_report(state); + } + + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + std::array report{}; + expect(tud_hid_get_report_cb( + instance, 0, HID_REPORT_TYPE_INPUT, report.data(), + report.size()) == GenericHid::kReportSize && + std::memcmp(report.data(), + expected_reports[instance].data, + report.size()) == 0, + "generic GET_REPORT crossed instance state"); + expect(usb_output_driver_is_ready(instance) && + usb_output_driver_task(instance), + "generic readiness/task helper was not dispatched"); + } + std::array invalid_report{}; + expect(tud_hid_get_report_cb( + kInvalidInstance, 0, HID_REPORT_TYPE_INPUT, + invalid_report.data(), invalid_report.size()) == 0 && + !usb_output_driver_is_ready(kInvalidInstance) && + !usb_output_driver_task(kInvalidInstance), + "generic callbacks accepted an invalid instance"); + expect(tud_hid_get_report_cb( + 0, 0, HID_REPORT_TYPE_OUTPUT, invalid_report.data(), + invalid_report.size()) == 0 && + tud_hid_get_report_cb( + 0, 1, HID_REPORT_TYPE_INPUT, invalid_report.data(), + invalid_report.size()) == 0, + "generic boundary accepted output or report-ID GET_REPORT"); + + switch_pro_set_rumble_callback(0, + inactive_switch_rumble_callback); + usb_output_driver_set_rumble_callback(0, xinput_rumble_callback); + std::array output{}; + output[0] = REPORT_OUTPUT_10; + tud_hid_report_received_cb(0, 0, output.data(), output.size()); + tud_hid_set_report_cb(0, REPORT_OUTPUT_10, HID_REPORT_TYPE_OUTPUT, + output.data() + 1, output.size() - 1); + expect(switch_inactive_rumble_count == 0 && + xinput_rumble_events[0].count == 0, + "generic input-only mode handled an output report"); + + tud_mount_cb(); + tud_umount_cb(); + for (uint8_t instance = 0; instance < kInstanceCount; ++instance) { + expect(usb_output_driver_is_ready(instance), + "generic lifecycle callback disturbed active state"); + } +} + +void test_generic_modes_boundary_dispatch() { + test_generic_boundary_dispatch( + AdapterUsbMode::kDInput, GenericHid::kDInputDeviceDescriptor, + GenericHid::kDInputReportDescriptor, "DINPUT", "DInput", + GenericHid::kDInputProductString, GenericHid::kDInputSerialString); + test_generic_boundary_dispatch( + AdapterUsbMode::kMac, GenericHid::kMacDeviceDescriptor, + GenericHid::kMacReportDescriptor, "MAC", "Mac", + GenericHid::kMacProductString, GenericHid::kMacSerialString); +} + +void test_vendor_control_boundary() { + constexpr uint8_t kSetupStage = 0; + constexpr uint8_t kRhport = 2; + tusb_control_request_t request{}; + request.bRequest = 0x42; + +#ifdef SWITCH_PICO_BLUEPAD32 + management_vendor_control_result = true; + management_vendor_control_count = 0; + expect(tud_vendor_control_xfer_cb(kRhport, kSetupStage, &request), + "BLUEPAD32 vendor control was not forwarded"); + expect(management_vendor_control_count == 1 && + management_vendor_control_rhport == kRhport && + management_vendor_control_stage == kSetupStage && + management_vendor_control_request == &request, + "BLUEPAD32 vendor control forwarding changed its arguments"); + + management_vendor_control_result = false; + expect(!tud_vendor_control_xfer_cb(kRhport, kSetupStage, &request) && + management_vendor_control_count == 2, + "inactive BLUEPAD32 vendor control was claimed"); +#else + expect(!tud_vendor_control_xfer_cb(kRhport, kSetupStage, &request), + "UART vendor control was claimed"); +#endif +} + +} // namespace + +extern "C" absolute_time_t get_absolute_time(void) { + return {now_ms}; +} + +extern "C" uint32_t to_ms_since_boot(absolute_time_t time) { + return static_cast(time.milliseconds); +} + +extern "C" uint32_t get_rand_32(void) { + return random_value++; +} + +extern "C" bool tud_hid_n_ready(uint8_t instance) { + return instance < kInstanceCount; +} + +extern "C" bool tud_hid_n_report(uint8_t instance, uint8_t report_id, + const void* report, uint16_t length) { + (void)report_id; + if (instance >= kInstanceCount || report == nullptr || length == 0) { + return false; + } + ++hid_report_count; + return true; +} + +extern "C" bool tud_suspended(void) { + return false; +} + +extern "C" bool tud_remote_wakeup(void) { + return true; +} + +extern "C" bool tud_ready(void) { + return usb_ready; +} + +extern "C" bool usbd_edpt_open( + uint8_t rhport, tusb_desc_endpoint_t const* endpoint_descriptor) { + (void)rhport; + if (endpoint_descriptor == nullptr) { + return false; + } + endpoint_harness[endpoint_descriptor->bEndpointAddress].opened = true; + return true; +} + +extern "C" bool usbd_edpt_xfer(uint8_t rhport, uint8_t endpoint, + uint8_t* buffer, uint16_t total_bytes) { + (void)rhport; + if (buffer == nullptr) { + return false; + } + EndpointHarness& harness = endpoint_harness[endpoint]; + ++harness.transfer_count; + harness.claimed = false; + if (tu_edpt_dir(endpoint) == TUSB_DIR_IN) { + harness.last_transfer_length = + total_bytes < harness.last_transfer.size() + ? total_bytes + : static_cast(harness.last_transfer.size()); + std::memcpy(harness.last_transfer.data(), buffer, + harness.last_transfer_length); + } else { + harness.armed_buffer = buffer; + harness.armed_length = total_bytes; + } + return true; +} + +extern "C" bool usbd_edpt_busy(uint8_t rhport, uint8_t endpoint) { + (void)rhport; + return endpoint_harness[endpoint].busy; +} + +extern "C" bool usbd_edpt_claim(uint8_t rhport, uint8_t endpoint) { + (void)rhport; + EndpointHarness& harness = endpoint_harness[endpoint]; + if (harness.claimed) { + return false; + } + harness.claimed = true; + return true; +} + +extern "C" bool usbd_edpt_release(uint8_t rhport, uint8_t endpoint) { + (void)rhport; + endpoint_harness[endpoint].claimed = false; + return true; +} + +void adapter_host_probe_note_string_descriptor(uint8_t index) { + ++observed_string_count; + last_observed_string = index; +} + +#ifdef SWITCH_PICO_BLUEPAD32 +bool usb_configuration_management_vendor_control( + uint8_t rhport, uint8_t stage, + tusb_control_request_t const* request) { + ++management_vendor_control_count; + management_vendor_control_rhport = rhport; + management_vendor_control_stage = stage; + management_vendor_control_request = request; + return management_vendor_control_result; +} +#endif + +int main() { + test_device_and_configuration_descriptors(); + test_microsoft_compatible_id_descriptor(); + test_input_report_mapping(); + test_rumble_report(); + test_host_probe_sequence(); + test_switch_boundary_dispatch(); + test_manual_switch_selection(); + test_xinput_boundary_dispatch(); + test_generic_modes_boundary_dispatch(); + test_vendor_control_boundary(); + return failures == 0 ? 0 : 1; +} diff --git a/tests/xinput_feasibility_test.cpp b/tests/xinput_feasibility_test.cpp deleted file mode 100644 index 41ce9da..0000000 --- a/tests/xinput_feasibility_test.cpp +++ /dev/null @@ -1,211 +0,0 @@ -#include -#include -#include -#include -#include - -#include "adapter_host_probe_state.h" -#include "xinput_feasibility_descriptors.h" -#include "xinput_feasibility_protocol.h" - -namespace { - -int failures = 0; - -void expect(bool condition, const char *message) { - if (!condition) { - std::cerr << "FAIL: " << message << '\n'; - ++failures; - } -} - -uint16_t read_le16(const uint8_t *data) { - return static_cast(data[0] | (data[1] << 8)); -} - -uint32_t read_le32(const uint8_t *data) { - return static_cast(data[0]) | - (static_cast(data[1]) << 8) | - (static_cast(data[2]) << 16) | - (static_cast(data[3]) << 24); -} - -void test_device_and_configuration_descriptors() { - using namespace XInputFeasibility; - expect(read_le16(&kSwitchProbeDeviceDescriptor[8]) == - kSwitchProbeVendorId, - "Switch probe VID mismatch"); - expect(read_le16(&kSwitchProbeDeviceDescriptor[10]) == - kSwitchProbeProductId, - "Switch probe PID mismatch"); - expect(read_le16(&kSwitchProbeDeviceDescriptor[12]) == - kSwitchProbeDeviceRevision, - "Switch probe revision mismatch"); - expect(kSwitchProbeDeviceRevision != 0x0210, - "Switch probe reuses the genuine controller cache identity"); - expect(read_le16(&kDeviceDescriptor[8]) == kPrototypeVendorId, - "prototype VID mismatch"); - expect(read_le16(&kDeviceDescriptor[10]) == kPrototypeProductId, - "prototype PID mismatch"); - expect(read_le16(&kDeviceDescriptor[12]) == - kPrototypeDeviceRevision, - "prototype revision mismatch"); - expect(kDeviceDescriptor[4] == 0 && kDeviceDescriptor[5] == 0 && - kDeviceDescriptor[6] == 0, - "multi-interface prototype is not a composite USB device"); - expect(kPrototypeVendorId != 0x045e, - "prototype must not impersonate Microsoft's VID"); - expect(read_le16(&kConfigurationDescriptor[2]) == - sizeof(kConfigurationDescriptor), - "configuration total length mismatch"); - expect(kConfigurationDescriptor[4] == SWITCH_PICO_HID_INSTANCE_COUNT, - "configuration interface count mismatch"); - - std::array endpoints{}; - for (uint8_t instance = 0; instance < SWITCH_PICO_HID_INSTANCE_COUNT; - ++instance) { - const size_t offset = 9 + instance * kInterfaceDescriptorSize; - const uint8_t *interface = &kConfigurationDescriptor[offset]; - expect(interface[0] == 9 && interface[1] == 4, - "missing interface descriptor"); - expect(interface[2] == instance, "interface number mismatch"); - expect(interface[5] == 0xff && interface[6] == 0x5d && - interface[7] == 0x01, - "XInput interface class tuple mismatch"); - expect(interface[9] == 0x10 && interface[10] == 0x21, - "XInput capability descriptor missing"); - const uint8_t in_endpoint = interface[27]; - const uint8_t out_endpoint = interface[34]; - expect(in_endpoint == static_cast(0x81 + instance), - "input endpoint mismatch"); - expect(out_endpoint == static_cast(0x01 + instance), - "output endpoint mismatch"); - expect(interface[15] == in_endpoint && interface[21] == out_endpoint, - "capability descriptor endpoint mismatch"); - expect(!endpoints[in_endpoint & 0x0f] && - !endpoints[out_endpoint & 0x0f], - "endpoint number reused"); - endpoints[in_endpoint & 0x0f] = true; - } -} - -void test_microsoft_compatible_id_descriptor() { - using namespace XInputFeasibility; - expect(read_le32(kMsCompatIdDescriptor) == sizeof(kMsCompatIdDescriptor), - "Microsoft descriptor total length mismatch"); - expect(read_le16(&kMsCompatIdDescriptor[4]) == 0x0100, - "Microsoft descriptor version mismatch"); - expect(read_le16(&kMsCompatIdDescriptor[6]) == kMsCompatIdIndex, - "Microsoft descriptor index mismatch"); - expect(kMsCompatIdDescriptor[8] == SWITCH_PICO_HID_INSTANCE_COUNT, - "Microsoft function count mismatch"); - for (uint8_t instance = 0; - instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) { - const uint8_t *function = &kMsCompatIdDescriptor[16 + instance * 24]; - expect(function[0] == instance, - "Microsoft descriptor interface mismatch"); - expect(std::memcmp(&function[2], "XUSB10", 6) == 0, - "XUSB10 compatible ID missing"); - } - expect(read_le32(kProbeMsCompatIdDescriptor) == 16 && - kProbeMsCompatIdDescriptor[8] == 0, - "probe descriptor must expose no compatible functions"); -} - -void test_input_report_mapping() { - ControllerState state{}; - auto report = XInputFeasibility::build_input_report(state); - expect(report.report_id == 0 && report.report_size == 20, - "neutral report header mismatch"); - expect(report.buttons == 0 && report.left_trigger == 0 && - report.right_trigger == 0, - "neutral report controls mismatch"); - expect(report.left_x == 0 && report.left_y == 0 && report.right_x == 0 && - report.right_y == 0, - "neutral axes mismatch"); - - state.dpad_up = true; - state.button_south = true; - state.button_east = true; - state.button_west = true; - state.button_north = true; - state.button_start = true; - state.button_select = true; - state.button_system = true; - state.left_trigger = UINT16_MAX; - state.right_trigger = UINT16_MAX; - state.left_stick_x = INT16_MIN; - state.left_stick_y = INT16_MIN; - state.right_stick_x = INT16_MAX; - state.right_stick_y = INT16_MAX; - report = XInputFeasibility::build_input_report(state); - expect((report.buttons & XInputFeasibility::kDpadUp) != 0, - "D-pad mapping missing"); - expect((report.buttons & XInputFeasibility::kButtonA) != 0 && - (report.buttons & XInputFeasibility::kButtonB) != 0 && - (report.buttons & XInputFeasibility::kButtonX) != 0 && - (report.buttons & XInputFeasibility::kButtonY) != 0, - "positional face-button mapping mismatch"); - expect(report.left_trigger == 0xff && report.right_trigger == 0xff, - "full analog trigger mapping mismatch"); - expect(report.left_x == INT16_MIN && report.left_y == INT16_MAX && - report.right_x == INT16_MAX && - report.right_y == -INT16_MAX, - "axis endpoint mapping mismatch"); - - state.left_trigger = 0x8000; - state.right_trigger = 0x7fff; - report = XInputFeasibility::build_input_report(state); - expect(report.left_trigger == 0x80 && report.right_trigger == 0x7f, - "analog trigger precision was discarded"); -} - -void test_rumble_report() { - const uint8_t packet[8] = {0x00, 0x08, 0x00, 0xa5, 0x5a, 0x00, 0x00, 0x00}; - ControllerRumbleOutput output{}; - expect( - XInputFeasibility::parse_rumble_report(packet, sizeof(packet), &output), - "valid rumble report rejected"); - expect(output.low_frequency_magnitude == 0xa5 && - output.high_frequency_magnitude == 0x5a, - "rumble magnitudes mapped incorrectly"); - expect(!XInputFeasibility::parse_rumble_report(packet, 4, &output), - "truncated rumble report accepted"); - uint8_t wrong_type[8]{}; - expect(!XInputFeasibility::parse_rumble_report(wrong_type, - sizeof(wrong_type), &output), - "wrong rumble report type accepted"); -} - -void test_host_probe_sequence() { - AdapterHostProbeState state; - state.note_ms_compat_id_request(10); - expect(!state.windows_confirmed(), - "compatible-ID request without signature confirmed Windows"); - state.note_ms_os_string(); - expect(state.saw_ms_os_string(), - "Microsoft OS string observation was not retained"); - state.note_ms_compat_id_request(20); - expect(state.windows_confirmed(), - "two-stage Windows signature not confirmed"); - expect(!state.should_reboot(119), "probe rebooted before delay"); - expect(state.should_reboot(120), "probe did not reboot at deadline"); - - AdapterHostProbeState wrapped; - wrapped.note_ms_os_string(); - wrapped.note_ms_compat_id_request(UINT32_MAX - 50); - expect(!wrapped.should_reboot(48), - "wrapped timer rebooted before deadline"); - expect(wrapped.should_reboot(49), "wrapped timer missed deadline"); -} - -} // namespace - -int main() { - test_device_and_configuration_descriptors(); - test_microsoft_compatible_id_descriptor(); - test_input_report_mapping(); - test_rumble_report(); - test_host_probe_sequence(); - return failures == 0 ? 0 : 1; -} diff --git a/tools/Test-SwitchPicoMac.swift b/tools/Test-SwitchPicoMac.swift new file mode 100755 index 0000000..b688368 --- /dev/null +++ b/tools/Test-SwitchPicoMac.swift @@ -0,0 +1,293 @@ +#!/usr/bin/env swift + +import Darwin +import Dispatch +import Foundation +import IOKit.hid + +private let vendorID = 0xCAFE +private let productID = 0x4021 +private let genericDesktopPage = 0x01 +private let gamePadUsage = 0x05 +private let buttonPage = 0x09 +private let expectedInterfaceCount = 4 + +private func writeError(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) +} + +private func ioReturnDescription(_ result: IOReturn) -> String { + String(format: "0x%08X", UInt32(bitPattern: Int32(result))) +} + +private func printInputMonitoringHint() { + writeError( + "If macOS denied HID access, enable your terminal (or the app launching Swift) in " + + "System Settings > Privacy & Security > Input Monitoring, then rerun the command." + ) +} + +private func numberProperty(_ device: IOHIDDevice, keys: [String]) -> Int? { + for key in keys { + if let number = IOHIDDeviceGetProperty(device, key as CFString) as? NSNumber { + return number.intValue + } + } + return nil +} + +private func deviceKey(_ device: IOHIDDevice) -> ObjectIdentifier { + ObjectIdentifier(device) +} + +private struct DeviceIdentity { + let interface: String + let location: String + + init(_ device: IOHIDDevice) { + if let value = numberProperty(device, keys: ["InterfaceID", "InterfaceNumber", "bInterfaceNumber"]) { + interface = String(value) + } else { + interface = "unknown" + } + + if let value = numberProperty(device, keys: ["LocationID", "locationID"]) { + location = String(format: "0x%08X", UInt32(truncatingIfNeeded: value)) + } else { + location = "unknown" + } + } + + var prefix: String { + "[interface=\(interface) location=\(location)]" + } +} + +private final class DeviceState { + let device: IOHIDDevice + let identity: DeviceIdentity + var lastValues: [Int: CFIndex] = [:] + + init(device: IOHIDDevice) { + self.device = device + identity = DeviceIdentity(device) + } +} + +private final class HIDMonitor { + private let manager: IOHIDManager + private let runLoop: CFRunLoop + private var devices: [ObjectIdentifier: DeviceState] = [:] + private var interruptSource: DispatchSourceSignal? + private var didPrintPermissionHint = false + private var isCleanedUp = false + + init(manager: IOHIDManager, runLoop: CFRunLoop) { + self.manager = manager + self.runLoop = runLoop + } + + func start() -> IOReturn { + let context = Unmanaged.passUnretained(self).toOpaque() + let matching: [String: Any] = [ + kIOHIDVendorIDKey as String: vendorID, + kIOHIDProductIDKey as String: productID, + kIOHIDDeviceUsagePageKey as String: genericDesktopPage, + kIOHIDDeviceUsageKey as String: gamePadUsage, + ] + + IOHIDManagerSetDeviceMatching(manager, matching as CFDictionary) + IOHIDManagerRegisterDeviceMatchingCallback(manager, deviceMatchedCallback, context) + IOHIDManagerRegisterDeviceRemovalCallback(manager, deviceRemovedCallback, context) + IOHIDManagerRegisterInputValueCallback(manager, inputValueCallback, context) + IOHIDManagerScheduleWithRunLoop(manager, runLoop, CFRunLoopMode.defaultMode.rawValue) + + let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + guard result == kIOReturnSuccess else { + writeError("Could not open the IOHID manager (\(ioReturnDescription(result))).") + printPermissionHintOnce() + return result + } + + let matchedDeviceCount: Int + if let matchedDevices = IOHIDManagerCopyDevices(manager) { + matchedDeviceCount = CFSetGetCount(matchedDevices) + } else { + matchedDeviceCount = 0 + } + if matchedDeviceCount == 0 { + print( + "No CAFE:4021 Generic Desktop Game Pad interfaces are connected; " + + "waiting for hot-plug. Press Ctrl-C to stop." + ) + } else { + print( + "Monitoring \(matchedDeviceCount)/\(expectedInterfaceCount) CAFE:4021 " + + "raw Game Pad interfaces. Press Ctrl-C to stop." + ) + } + + signal(SIGINT, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global(qos: .userInitiated)) + source.setEventHandler { [weak self] in + guard let self else { return } + CFRunLoopStop(self.runLoop) + } + source.resume() + interruptSource = source + return result + } + + func deviceMatched(_ device: IOHIDDevice, result: IOReturn) { + guard result == kIOReturnSuccess else { + writeError("Device matching callback failed (\(ioReturnDescription(result))).") + return + } + + let key = deviceKey(device) + guard devices[key] == nil else { return } + + let identity = DeviceIdentity(device) + let openResult = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) + guard openResult == kIOReturnSuccess else { + writeError("\(identity.prefix) Could not open HID interface (\(ioReturnDescription(openResult))).") + printPermissionHintOnce() + return + } + + devices[key] = DeviceState(device: device) + print("\(identity.prefix) attached (\(devices.count)/\(expectedInterfaceCount) Game Pad interfaces open)") + } + + func deviceRemoved(_ device: IOHIDDevice, result: IOReturn) { + let key = deviceKey(device) + let identity = devices.removeValue(forKey: key)?.identity ?? DeviceIdentity(device) + IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone)) + + if result == kIOReturnSuccess { + print("\(identity.prefix) removed (\(devices.count)/\(expectedInterfaceCount) Game Pad interfaces open)") + } else { + writeError("\(identity.prefix) removal callback failed (\(ioReturnDescription(result))).") + } + } + + func inputValue(_ value: IOHIDValue, result: IOReturn) { + guard result == kIOReturnSuccess else { + writeError("Input callback failed (\(ioReturnDescription(result))).") + return + } + + let element = IOHIDValueGetElement(value) + let usagePage = IOHIDElementGetUsagePage(element) + let usage = IOHIDElementGetUsage(element) + guard let name = usageName(page: usagePage, usage: usage) else { return } + + let device = IOHIDElementGetDevice(element) + let key = deviceKey(device) + guard let state = devices[key] else { return } + + let integerValue = IOHIDValueGetIntegerValue(value) + let elementCookie = Int(IOHIDElementGetCookie(element)) + guard state.lastValues[elementCookie] != integerValue else { return } + state.lastValues[elementCookie] = integerValue + + let logicalMin = IOHIDElementGetLogicalMin(element) + let logicalMax = IOHIDElementGetLogicalMax(element) + print( + "\(state.identity.prefix) \(name) " + + "page=\(hexUsage(usagePage)) usage=\(hexUsage(usage)) " + + "logical=\(logicalMin)...\(logicalMax) value=\(integerValue)" + ) + } + + func cleanup() { + guard !isCleanedUp else { return } + isCleanedUp = true + + interruptSource?.cancel() + interruptSource = nil + for state in devices.values { + print("\(state.identity.prefix) closing") + IOHIDDeviceClose(state.device, IOOptionBits(kIOHIDOptionsTypeNone)) + } + devices.removeAll() + IOHIDManagerUnscheduleFromRunLoop(manager, runLoop, CFRunLoopMode.defaultMode.rawValue) + IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + } + + private func printPermissionHintOnce() { + guard !didPrintPermissionHint else { return } + didPrintPermissionHint = true + printInputMonitoringHint() + } +} + +private func usageName(page: UInt32, usage: UInt32) -> String? { + if page == UInt32(genericDesktopPage) { + switch usage { + case 0x30: return "X" + case 0x31: return "Y" + case 0x32: return "Z" + case 0x33: return "Rx" + case 0x34: return "Ry" + case 0x35: return "Rz" + case 0x39: return "Hat" + default: return nil + } + } + if page == UInt32(buttonPage) { + return "Button \(usage)" + } + return nil +} + +private func hexUsage(_ value: UInt32) -> String { + String(format: "0x%02X", value) +} + +private func monitor(from context: UnsafeMutableRawPointer?) -> HIDMonitor? { + guard let context else { return nil } + return Unmanaged.fromOpaque(context).takeUnretainedValue() +} + +private func deviceMatchedCallback( + context: UnsafeMutableRawPointer?, + result: IOReturn, + sender: UnsafeMutableRawPointer?, + device: IOHIDDevice +) { + _ = sender + monitor(from: context)?.deviceMatched(device, result: result) +} + +private func deviceRemovedCallback( + context: UnsafeMutableRawPointer?, + result: IOReturn, + sender: UnsafeMutableRawPointer?, + device: IOHIDDevice +) { + _ = sender + monitor(from: context)?.deviceRemoved(device, result: result) +} + +private func inputValueCallback( + context: UnsafeMutableRawPointer?, + result: IOReturn, + sender: UnsafeMutableRawPointer?, + value: IOHIDValue +) { + _ = sender + monitor(from: context)?.inputValue(value, result: result) +} + +let manager: IOHIDManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) +let monitorInstance = HIDMonitor(manager: manager, runLoop: CFRunLoopGetCurrent()) +let openResult = monitorInstance.start() +if openResult != kIOReturnSuccess { + monitorInstance.cleanup() + exit(EXIT_FAILURE) +} + +CFRunLoopRun() +monitorInstance.cleanup() +print("Stopped.") diff --git a/tusb_config.h b/tusb_config.h index a6f81b6..68e9be5 100644 --- a/tusb_config.h +++ b/tusb_config.h @@ -1,5 +1,5 @@ -// TinyUSB configuration for one to four Switch Pro style HID interfaces. -// Each interface uses independent 64-byte interrupt IN and OUT endpoints. +// Switch Pro interfaces use independent 64-byte interrupt IN and OUT endpoints; +// generic HID interfaces use input-only 15-byte reports/endpoints. #ifndef _TUSB_CONFIG_H_ #define _TUSB_CONFIG_H_ @@ -36,7 +36,7 @@ extern "C" { #define CFG_TUD_MSC 0 #define CFG_TUD_MIDI 0 #define CFG_TUD_VENDOR 0 -// Always enable TinyUSB debug at level 2; LOG_PRINTF controls user-facing logs. +// TinyUSB debug is disabled by default (CFG_TUSB_DEBUG=0); LOG_PRINTF controls user-facing logs. #ifdef CFG_TUSB_DEBUG #undef CFG_TUSB_DEBUG #endif diff --git a/udev/99-switch-pico.rules b/udev/99-switch-pico.rules new file mode 100644 index 0000000..9d3b925 --- /dev/null +++ b/udev/99-switch-pico.rules @@ -0,0 +1,5 @@ +# Development USB identities used by switch-pico XInput and generic HID modes. +# Restrict configuration access to the active local seat and plugdev members. +SUBSYSTEM=="usb", ATTR{idVendor}=="cafe", ATTR{idProduct}=="4010", MODE="0660", GROUP="plugdev", TAG+="uaccess" +SUBSYSTEM=="usb", ATTR{idVendor}=="cafe", ATTR{idProduct}=="4020", MODE="0660", GROUP="plugdev", TAG+="uaccess" +SUBSYSTEM=="usb", ATTR{idVendor}=="cafe", ATTR{idProduct}=="4021", MODE="0660", GROUP="plugdev", TAG+="uaccess" diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp index 517651e..68be855 100644 --- a/usb_configuration_management.cpp +++ b/usb_configuration_management.cpp @@ -3,10 +3,11 @@ #include #include "adapter_configuration.h" -#include "tusb.h" -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY #include "adapter_host_probe.h" -#endif +#include "adapter_reboot.h" +#include "adapter_usb_mode.h" +#include "tusb.h" +#include "usb_output_driver.h" namespace UsbConfigurationManagement { namespace { @@ -74,6 +75,10 @@ Status profile_service_status(const ProfileServiceMetadata& metadata) { bool valid_out_size(Operation operation, size_t size) { switch (operation) { + case Operation::kModeSet: + return size == kRequestHeaderSize + 5; + case Operation::kReboot: + return size == kRequestHeaderSize + 4; case Operation::kConfigurationBegin: return size == kRequestHeaderSize + 12; case Operation::kProfileBegin: @@ -147,12 +152,8 @@ size_t encode_transaction(uint8_t* output, size_t output_size) { size_t encode_info(uint8_t* output, size_t output_size) { uint8_t payload[8] = { 0, 2, 0, 2, -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - adapter_host_probe_mode() == AdapterUsbMode::kXInput ? 1u : 0u, -#else - 0, -#endif - 0, + static_cast(usb_output_driver_mode()), + usb_output_driver_capabilities(), static_cast(CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE), static_cast( CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE >> 8), @@ -329,6 +330,41 @@ bool process_out_request() { const uint8_t* payload = request.payload; switch (request.operation) { + case Operation::kModeSet: { + const uint32_t transaction_id = + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24); + const AdapterRequestedMode requested_mode = + static_cast(payload[4]); + if (transaction_id == 0 || + (transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0 || + !adapter_requested_mode_valid(requested_mode)) { + return false; + } + const ConfigurationTransactionStatus status = + configuration_service_set_mode( + transaction_id, requested_mode, + adapter_usb_mode_availability()); + return status == ConfigurationTransactionStatus::kPending || + status == ConfigurationTransactionStatus::kCommitted || + status == ConfigurationTransactionStatus::kUnchanged; + } + case Operation::kReboot: { + const uint32_t transaction_id = + static_cast(payload[0]) | + (static_cast(payload[1]) << 8) | + (static_cast(payload[2]) << 16) | + (static_cast(payload[3]) << 24); + if (transaction_id == 0 || + (transaction_id & + CONFIGURATION_SERVICE_INTERNAL_TRANSACTION_ID_MASK) != 0) { + return false; + } + return adapter_reboot_for_mode_transaction(transaction_id); + } case Operation::kConfigurationBegin: configuration_service_begin( static_cast(payload[0]) | @@ -479,14 +515,12 @@ bool process_out_request() { } // namespace -extern "C" bool tud_vendor_control_xfer_cb( +bool usb_configuration_management_vendor_control( uint8_t rhport, uint8_t stage, tusb_control_request_t const* request) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY if (adapter_host_probe_vendor_control(rhport, stage, request)) { return true; } -#endif using namespace UsbConfigurationManagement; if (request == nullptr || request->bmRequestType_bit.type != TUSB_REQ_TYPE_VENDOR || diff --git a/usb_configuration_management.h b/usb_configuration_management.h index 21e07f5..bb223dc 100644 --- a/usb_configuration_management.h +++ b/usb_configuration_management.h @@ -3,6 +3,8 @@ #include #include +#include "tusb.h" + #include "bluepad32_input_backend.h" #include "configuration_service.h" #include "profile_service.h" @@ -28,6 +30,8 @@ static_assert(kMaximumResponseSize == 293, enum class Operation : uint8_t { kInfo = 0x01, + kModeSet = 0x02, + kReboot = 0x03, kConfigurationRead = 0x10, kConfigurationBegin = 0x11, kConfigurationChunk = 0x12, @@ -83,3 +87,7 @@ size_t encode_profile_transaction( uint8_t* output, size_t output_size); } // namespace UsbConfigurationManagement + +bool usb_configuration_management_vendor_control( + uint8_t rhport, uint8_t stage, + tusb_control_request_t const* request); diff --git a/usb_output_driver.cpp b/usb_output_driver.cpp new file mode 100644 index 0000000..6560a73 --- /dev/null +++ b/usb_output_driver.cpp @@ -0,0 +1,403 @@ +#include "usb_output_driver.h" + +#include +#include + +#include "device/usbd_pvt.h" +#include "switch_pro_driver.h" +#include "switch_pro_descriptors.h" +#include "tusb.h" + +#ifdef SWITCH_PICO_BLUEPAD32 +#include "usb_configuration_management.h" +#endif + +#ifdef SWITCH_PICO_USB_OUTPUT_MODES +#include "adapter_host_probe.h" +#include "generic_hid_descriptors.h" +#include "generic_hid_driver.h" +#include "xinput_descriptors.h" +#include "xinput_driver.h" +#endif + +#ifdef SWITCH_PICO_LOG +#define LOG_PRINTF(...) printf(__VA_ARGS__) +#else +#define LOG_PRINTF(...) ((void)0) +#endif + +namespace { + +AdapterUsbMode g_mode = AdapterUsbMode::kSwitch; +uint16_t g_string_descriptor[32]{}; + +bool xinput_selected() { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + return g_mode == AdapterUsbMode::kXInput; +#else + return false; +#endif +} + +bool generic_selected() { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + return g_mode == AdapterUsbMode::kDInput || + g_mode == AdapterUsbMode::kMac; +#else + return false; +#endif +} + +bool switch_selected() { + return !xinput_selected() && !generic_selected(); +} + +} // namespace + +void usb_output_driver_init(AdapterUsbMode mode) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + g_mode = mode; +#else + (void)mode; + g_mode = AdapterUsbMode::kSwitch; +#endif + + for (uint8_t instance = 0; + instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + xinput_init(instance); + } else if (generic_selected()) { + generic_hid_init(instance); + } else +#endif + { + switch_pro_init(instance); + } + } +} + +const char* usb_output_driver_mode_name() { + switch (g_mode) { + case AdapterUsbMode::kSwitch: + return "Switch"; + case AdapterUsbMode::kSwitchProbe: + return "Switch probe"; + case AdapterUsbMode::kXInput: + return "XInput"; + case AdapterUsbMode::kDInput: + return "DInput"; + case AdapterUsbMode::kMac: + return "Mac"; + } + return "Switch"; +} + +AdapterUsbMode usb_output_driver_mode() { + return g_mode; +} + +const char* usb_output_driver_name() { + switch (g_mode) { + case AdapterUsbMode::kXInput: + return "XINPUT"; + case AdapterUsbMode::kDInput: + return "DINPUT"; + case AdapterUsbMode::kMac: + return "MAC"; + case AdapterUsbMode::kSwitch: + case AdapterUsbMode::kSwitchProbe: + return "SWITCH"; + } + return "SWITCH"; +} + +uint8_t usb_output_driver_capabilities() { + switch (g_mode) { + case AdapterUsbMode::kSwitch: + case AdapterUsbMode::kSwitchProbe: + return USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; + case AdapterUsbMode::kXInput: + return USB_OUTPUT_CAPABILITY_INPUT | + USB_OUTPUT_CAPABILITY_RUMBLE; + case AdapterUsbMode::kDInput: + case AdapterUsbMode::kMac: + return USB_OUTPUT_CAPABILITY_INPUT; + } + return USB_OUTPUT_CAPABILITY_INPUT; +} + +void usb_output_driver_set_input(uint8_t instance, + const ControllerState& state, + uint16_t left_trigger_threshold, + uint16_t right_trigger_threshold) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + xinput_set_input(instance, state); + return; + } + if (generic_selected()) { + generic_hid_set_input(instance, state); + return; + } +#endif + switch_pro_set_input(instance, state, left_trigger_threshold, + right_trigger_threshold); +} + +bool usb_output_driver_task(uint8_t instance) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + return xinput_task(instance); + } + if (generic_selected()) { + return generic_hid_task(instance); + } +#endif + return switch_pro_task(instance); +} + +bool usb_output_driver_is_ready(uint8_t instance) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + return xinput_is_ready(instance); + } + if (generic_selected()) { + return generic_hid_is_ready(instance); + } +#endif + return switch_pro_is_ready(instance); +} + +void usb_output_driver_set_rumble_callback( + uint8_t instance, ControllerRumbleCallback callback) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + xinput_set_rumble_callback(instance, callback); + return; + } + if (generic_selected()) { + return; + } +#endif + switch_pro_set_rumble_callback(instance, callback); +} + +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) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + return 0; + } + if (generic_selected()) { + return generic_hid_get_report(instance, report_id, report_type, + buffer, requested_length); + } +#endif + return switch_pro_hid_get_report(instance, report_id, report_type, buffer, + requested_length); +} + +extern "C" 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 buffer_size) { + if (switch_selected()) { + switch_pro_hid_set_report(instance, report_id, report_type, buffer, + buffer_size); + } +} + +extern "C" void tud_hid_report_received_cb( + uint8_t instance, uint8_t report_id, const uint8_t* buffer, + uint16_t buffer_size) { + if (switch_selected()) { + switch_pro_hid_report_received(instance, report_id, buffer, + buffer_size); + } +} + +extern "C" uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + return nullptr; + } + if (generic_selected()) { + const GenericHid::ReportDescriptorVariant variant = + g_mode == AdapterUsbMode::kMac + ? GenericHid::ReportDescriptorVariant::kMac + : GenericHid::ReportDescriptorVariant::kDInput; + return generic_hid_report_descriptor(instance, variant); + } +#endif + return switch_pro_hid_report_descriptor(instance); +} + +extern "C" uint8_t const* tud_descriptor_device_cb() { +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + return XInput::kDeviceDescriptor; + } + if (generic_selected()) { + return g_mode == AdapterUsbMode::kDInput + ? GenericHid::kDInputDeviceDescriptor + : GenericHid::kMacDeviceDescriptor; + } + if (g_mode == AdapterUsbMode::kSwitchProbe) { + return XInput::kSwitchProbeDeviceDescriptor; + } +#endif + return switch_pro_device_descriptor; +} + +extern "C" uint8_t const* tud_descriptor_configuration_cb(uint8_t index) { + (void)index; +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + return XInput::kConfigurationDescriptor; + } + if (generic_selected()) { + return GenericHid::kConfigurationDescriptor; + } +#endif + return switch_pro_configuration_descriptor; +} + +extern "C" uint16_t const* tud_descriptor_string_cb(uint8_t index, + uint16_t langid) { + (void)langid; + +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + adapter_host_probe_note_string_descriptor(index); + if (index == 0xee && + (xinput_selected() || + g_mode == AdapterUsbMode::kSwitchProbe)) { + static constexpr char kSignature[] = "MSFT100"; + for (uint8_t i = 0; i < sizeof(kSignature) - 1; ++i) { + g_string_descriptor[1 + i] = kSignature[i]; + } + g_string_descriptor[8] = XInput::kMsVendorRequest; + g_string_descriptor[0] = + static_cast((TUSB_DESC_STRING << 8) | 18); + return g_string_descriptor; + } +#endif + + uint8_t character_count = 0; + if (index == 0) { + memcpy(&g_string_descriptor[1], switch_pro_string_language, 2); + character_count = 1; + } else { + const uint8_t* string = nullptr; +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + static const uint8_t kManufacturer[] = "Switch Pico"; + static const uint8_t kProduct[] = "XInput Feasibility"; + static const uint8_t kSerial[] = "XINPUT-PROTOTYPE"; + static const uint8_t* const kXInputStrings[] = { + nullptr, kManufacturer, kProduct, kSerial}; + if (xinput_selected()) { + if (index >= sizeof(kXInputStrings) / + sizeof(kXInputStrings[0])) { + return nullptr; + } + string = kXInputStrings[index]; + } else if (generic_selected()) { + if (index == 1) { + string = reinterpret_cast( + GenericHid::kManufacturerString); + } else if (index == 2) { + string = reinterpret_cast( + g_mode == AdapterUsbMode::kDInput + ? GenericHid::kDInputProductString + : GenericHid::kMacProductString); + } else if (index == 3) { + string = reinterpret_cast( + g_mode == AdapterUsbMode::kDInput + ? GenericHid::kDInputSerialString + : GenericHid::kMacSerialString); + } else { + return nullptr; + } + } else +#endif + { + if (index >= sizeof(switch_pro_string_descriptors) / + sizeof(switch_pro_string_descriptors[0])) { + return nullptr; + } + string = switch_pro_string_descriptors[index]; + } + + while (string[character_count] != 0) { + ++character_count; + } + if (character_count > 31) { + character_count = 31; + } + for (uint8_t i = 0; i < character_count; ++i) { + g_string_descriptor[1 + i] = string[i]; + } + } + + g_string_descriptor[0] = static_cast( + (TUSB_DESC_STRING << 8) | (2 * character_count + 2)); + return g_string_descriptor; +} + +extern "C" bool tud_vendor_control_xfer_cb( + uint8_t rhport, uint8_t stage, + tusb_control_request_t const* request) { +#ifdef SWITCH_PICO_BLUEPAD32 + return usb_configuration_management_vendor_control(rhport, stage, + request); +#else + (void)rhport; + (void)stage; + (void)request; + return false; +#endif +} + +extern "C" bool tud_control_request_cb( + uint8_t rhport, tusb_control_request_t const* request) { + (void)rhport; + if (request != nullptr) { + LOG_PRINTF( + "[CTRL] bmReq=0x%02x bReq=0x%02x wValue=0x%04x " + "wIndex=0x%04x wLen=%u\n", + request->bmRequestType, request->bRequest, request->wValue, + request->wIndex, request->wLength); + } + return false; +} + +extern "C" void tud_mount_cb() { + LOG_PRINTF("[USB] mount_cb\n"); + if (switch_selected()) { + switch_pro_mount(); + } +} + +extern "C" void tud_umount_cb() { + LOG_PRINTF("[USB] umount_cb\n"); + if (switch_selected()) { + switch_pro_unmount(); + } +} + +extern "C" usbd_class_driver_t const* usbd_app_driver_get_cb( + uint8_t* driver_count) { + if (driver_count == nullptr) { + return nullptr; + } +#ifdef SWITCH_PICO_USB_OUTPUT_MODES + if (xinput_selected()) { + *driver_count = 1; + return xinput_class_driver(); + } +#endif + *driver_count = 0; + return nullptr; +} diff --git a/usb_output_driver.h b/usb_output_driver.h new file mode 100644 index 0000000..ec2bad4 --- /dev/null +++ b/usb_output_driver.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include "adapter_usb_mode.h" +#include "controller_state.h" +#include "switch_haptics.h" + +// Mode capability bits published in USB management info. Input is present in +// every current mode; rumble/motion describe host-visible USB capabilities. +constexpr uint8_t USB_OUTPUT_CAPABILITY_INPUT = 1u << 0; +constexpr uint8_t USB_OUTPUT_CAPABILITY_RUMBLE = 1u << 1; +constexpr uint8_t USB_OUTPUT_CAPABILITY_MOTION = 1u << 2; +constexpr uint8_t USB_OUTPUT_CAPABILITY_MASK = + USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE | + USB_OUTPUT_CAPABILITY_MOTION; + +// Select and initialize the static USB output implementation. Call once before +// tusb_init(); the selected descriptors and class driver remain fixed for the +// lifetime of the USB device stack. +void usb_output_driver_init(AdapterUsbMode mode); + +const char* usb_output_driver_mode_name(); +AdapterUsbMode usb_output_driver_mode(); +const char* usb_output_driver_name(); +uint8_t usb_output_driver_capabilities(); + +void usb_output_driver_set_input(uint8_t instance, + const ControllerState& state, + uint16_t left_trigger_threshold, + uint16_t right_trigger_threshold); +bool usb_output_driver_task(uint8_t instance); +bool usb_output_driver_is_ready(uint8_t instance); +void usb_output_driver_set_rumble_callback( + uint8_t instance, ControllerRumbleCallback callback); diff --git a/xinput_feasibility_descriptors.h b/xinput_descriptors.h similarity index 78% rename from xinput_feasibility_descriptors.h rename to xinput_descriptors.h index 785c8d1..5776548 100644 --- a/xinput_feasibility_descriptors.h +++ b/xinput_descriptors.h @@ -11,11 +11,11 @@ #error "SWITCH_PICO_HID_INSTANCE_COUNT must be between 1 and 4" #endif -namespace XInputFeasibility { +namespace XInput { -constexpr uint16_t kPrototypeVendorId = 0xcafe; -constexpr uint16_t kPrototypeProductId = 0x4010; -constexpr uint16_t kPrototypeDeviceRevision = 0x0101; +constexpr uint16_t kDevelopmentVendorId = 0xcafe; +constexpr uint16_t kDevelopmentProductId = 0x4010; +constexpr uint16_t kDevelopmentDeviceRevision = 0x0101; constexpr uint8_t kInterfaceDescriptorSize = 39; constexpr uint16_t kConfigurationDescriptorSize = 9 + SWITCH_PICO_HID_INSTANCE_COUNT * kInterfaceDescriptorSize; @@ -61,19 +61,19 @@ static const uint8_t kDeviceDescriptor[] = { 0x00, 0x00, // Composite device; each interface binds to the XUSB driver 0x40, // Endpoint zero packet size - static_cast(kPrototypeVendorId & 0xff), - static_cast(kPrototypeVendorId >> 8), - static_cast(kPrototypeProductId & 0xff), - static_cast(kPrototypeProductId >> 8), - static_cast(kPrototypeDeviceRevision & 0xff), - static_cast(kPrototypeDeviceRevision >> 8), + static_cast(kDevelopmentVendorId & 0xff), + static_cast(kDevelopmentVendorId >> 8), + static_cast(kDevelopmentProductId & 0xff), + static_cast(kDevelopmentProductId >> 8), + static_cast(kDevelopmentDeviceRevision & 0xff), + static_cast(kDevelopmentDeviceRevision >> 8), 0x01, 0x02, 0x03, // Manufacturer, product, serial strings 0x01, // One configuration }; -#define XINPUT_FEASIBILITY_INTERFACE(number, endpoint) \ +#define XINPUT_INTERFACE(number, endpoint) \ 0x09, 0x04, number, 0x00, 0x02, 0xff, 0x5d, 0x01, 0x00, 0x10, 0x21, 0x10, \ 0x01, 0x01, 0x24, static_cast(0x80 | endpoint), 0x14, 0x03, \ 0x00, 0x03, 0x13, endpoint, 0x00, 0x03, 0x00, 0x07, 0x05, \ @@ -90,21 +90,21 @@ static const uint8_t kConfigurationDescriptor[] = { 0x00, 0x80, 0xfa, - XINPUT_FEASIBILITY_INTERFACE(0x00, 0x01), + XINPUT_INTERFACE(0x00, 0x01), #if SWITCH_PICO_HID_INSTANCE_COUNT >= 2 - XINPUT_FEASIBILITY_INTERFACE(0x01, 0x02), + XINPUT_INTERFACE(0x01, 0x02), #endif #if SWITCH_PICO_HID_INSTANCE_COUNT >= 3 - XINPUT_FEASIBILITY_INTERFACE(0x02, 0x03), + XINPUT_INTERFACE(0x02, 0x03), #endif #if SWITCH_PICO_HID_INSTANCE_COUNT >= 4 - XINPUT_FEASIBILITY_INTERFACE(0x03, 0x04), + XINPUT_INTERFACE(0x03, 0x04), #endif }; -#undef XINPUT_FEASIBILITY_INTERFACE +#undef XINPUT_INTERFACE -#define XINPUT_FEASIBILITY_COMPAT_FUNCTION(number) \ +#define XINPUT_COMPAT_FUNCTION(number) \ number, 0x01, 'X', 'U', 'S', 'B', '1', '0', 0x00, 0x00, 0x00, 0x00, 0x00, \ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 @@ -125,19 +125,19 @@ static const uint8_t kMsCompatIdDescriptor[] = { 0x00, 0x00, 0x00, - XINPUT_FEASIBILITY_COMPAT_FUNCTION(0x00), + XINPUT_COMPAT_FUNCTION(0x00), #if SWITCH_PICO_HID_INSTANCE_COUNT >= 2 - XINPUT_FEASIBILITY_COMPAT_FUNCTION(0x01), + XINPUT_COMPAT_FUNCTION(0x01), #endif #if SWITCH_PICO_HID_INSTANCE_COUNT >= 3 - XINPUT_FEASIBILITY_COMPAT_FUNCTION(0x02), + XINPUT_COMPAT_FUNCTION(0x02), #endif #if SWITCH_PICO_HID_INSTANCE_COUNT >= 4 - XINPUT_FEASIBILITY_COMPAT_FUNCTION(0x03), + XINPUT_COMPAT_FUNCTION(0x03), #endif }; -#undef XINPUT_FEASIBILITY_COMPAT_FUNCTION +#undef XINPUT_COMPAT_FUNCTION static const uint8_t kProbeMsCompatIdDescriptor[] = { 0x10, 0x00, 0x00, 0x00, 0x00, 0x01, 0x04, 0x00, @@ -150,4 +150,4 @@ static_assert(sizeof(kConfigurationDescriptor) == kConfigurationDescriptorSize); static_assert(sizeof(kMsCompatIdDescriptor) == kMsCompatIdDescriptorSize); static_assert(sizeof(kProbeMsCompatIdDescriptor) == 16); -} // namespace XInputFeasibility +} // namespace XInput diff --git a/xinput_feasibility_driver.cpp b/xinput_driver.cpp similarity index 80% rename from xinput_feasibility_driver.cpp rename to xinput_driver.cpp index bc0b27e..8844ad4 100644 --- a/xinput_feasibility_driver.cpp +++ b/xinput_driver.cpp @@ -1,12 +1,12 @@ -#include "xinput_feasibility_driver.h" +#include "xinput_driver.h" #include #include #include "device/usbd_pvt.h" #include "tusb.h" -#include "xinput_feasibility_descriptors.h" -#include "xinput_feasibility_protocol.h" +#include "xinput_descriptors.h" +#include "xinput_protocol.h" namespace { @@ -15,7 +15,7 @@ constexpr uint8_t kEndpointBufferSize = 32; struct XInputContext { ControllerState input{}; - XInputFeasibility::InputReport input_report{}; + XInput::InputReport input_report{}; uint8_t output_report[kEndpointBufferSize]{}; ControllerRumbleCallback rumble_callback = nullptr; uint8_t endpoint_in = 0; @@ -73,7 +73,7 @@ uint16_t driver_open(uint8_t rhport, interface_descriptor->bInterfaceProtocol != 0x01 || interface_descriptor->bInterfaceNumber >= SWITCH_PICO_HID_INSTANCE_COUNT || - max_length < XInputFeasibility::kInterfaceDescriptorSize) { + max_length < XInput::kInterfaceDescriptorSize) { return 0; } @@ -83,11 +83,11 @@ uint16_t driver_open(uint8_t rhport, uint16_t consumed = sizeof(tusb_desc_interface_t); uint8_t const *descriptor = tu_desc_next(interface_descriptor); uint8_t endpoints_found = 0; - while (consumed < XInputFeasibility::kInterfaceDescriptorSize) { + while (consumed < XInput::kInterfaceDescriptorSize) { const uint8_t descriptor_length = descriptor[0]; if (descriptor_length == 0 || consumed + descriptor_length > - XInputFeasibility::kInterfaceDescriptorSize) { + XInput::kInterfaceDescriptorSize) { reset_context(context); return 0; } @@ -139,8 +139,8 @@ bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result, } if (endpoint == context->endpoint_out) { ControllerRumbleOutput rumble{}; - if (XInputFeasibility::parse_rumble_report(context->output_report, - transferred, &rumble) && + if (XInput::parse_rumble_report(context->output_report, transferred, + &rumble) && context->rumble_callback != nullptr) { const uint8_t instance = static_cast(context - g_contexts); context->rumble_callback(instance, rumble); @@ -154,43 +154,41 @@ bool driver_transfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result, } usbd_class_driver_t const kDriver = { - "XINPUT-FEASIBILITY", driver_init, driver_deinit, driver_reset, - driver_open, driver_control, driver_transfer, nullptr, + "XINPUT", driver_init, driver_deinit, driver_reset, + driver_open, driver_control, driver_transfer, nullptr, }; } // namespace -void xinput_feasibility_init(uint8_t instance) { +void xinput_init(uint8_t instance) { XInputContext *context = context_for(instance); if (context != nullptr) { reset_context(*context); } } -void xinput_feasibility_set_rumble_callback(uint8_t instance, - ControllerRumbleCallback callback) { +void xinput_set_rumble_callback(uint8_t instance, + ControllerRumbleCallback callback) { XInputContext *context = context_for(instance); if (context != nullptr) { context->rumble_callback = callback; } } -void xinput_feasibility_set_input(uint8_t instance, - const ControllerState& state) { +void xinput_set_input(uint8_t instance, const ControllerState& state) { XInputContext *context = context_for(instance); if (context != nullptr) { context->input = state; } } -bool xinput_feasibility_task(uint8_t instance) { +bool xinput_task(uint8_t instance) { XInputContext *context = context_for(instance); if (context == nullptr || !context->configured || !tud_ready() || usbd_edpt_busy(kRhport, context->endpoint_in)) { return false; } - context->input_report = - XInputFeasibility::build_input_report(context->input); + context->input_report = XInput::build_input_report(context->input); if (!usbd_edpt_claim(kRhport, context->endpoint_in)) { return false; } @@ -203,16 +201,11 @@ bool xinput_feasibility_task(uint8_t instance) { return true; } -bool xinput_feasibility_is_ready(uint8_t instance) { +bool xinput_is_ready(uint8_t instance) { XInputContext *context = context_for(instance); return context != nullptr && context->configured && tud_ready(); } -extern "C" usbd_class_driver_t const * -usbd_app_driver_get_cb(uint8_t *driver_count) { - if (driver_count == nullptr) { - return nullptr; - } - *driver_count = 1; +usbd_class_driver_t const* xinput_class_driver() { return &kDriver; } diff --git a/xinput_driver.h b/xinput_driver.h new file mode 100644 index 0000000..a6158fb --- /dev/null +++ b/xinput_driver.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +#include "controller_state.h" +#include "device/usbd_pvt.h" +#include "switch_haptics.h" + +void xinput_init(uint8_t instance); +void xinput_set_rumble_callback(uint8_t instance, + ControllerRumbleCallback callback); +void xinput_set_input(uint8_t instance, const ControllerState& state); +bool xinput_task(uint8_t instance); +bool xinput_is_ready(uint8_t instance); + +usbd_class_driver_t const* xinput_class_driver(); diff --git a/xinput_feasibility_driver.h b/xinput_feasibility_driver.h deleted file mode 100644 index 33e15b8..0000000 --- a/xinput_feasibility_driver.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -#include "controller_state.h" -#include "switch_haptics.h" - -void xinput_feasibility_init(uint8_t instance); -void xinput_feasibility_set_rumble_callback(uint8_t instance, - ControllerRumbleCallback callback); -void xinput_feasibility_set_input(uint8_t instance, - const ControllerState& state); -bool xinput_feasibility_task(uint8_t instance); -bool xinput_feasibility_is_ready(uint8_t instance); diff --git a/xinput_feasibility_protocol.h b/xinput_protocol.h similarity index 97% rename from xinput_feasibility_protocol.h rename to xinput_protocol.h index 43315ca..03ca0a9 100644 --- a/xinput_feasibility_protocol.h +++ b/xinput_protocol.h @@ -5,7 +5,7 @@ #include "controller_state.h" #include "switch_haptics.h" -namespace XInputFeasibility { +namespace XInput { constexpr uint16_t kDpadUp = 0x0001; constexpr uint16_t kDpadDown = 0x0002; @@ -84,4 +84,4 @@ inline bool parse_rumble_report(const uint8_t *data, uint32_t size, return true; } -} // namespace XInputFeasibility +} // namespace XInput