From ec9359bce8afdacbaffc445e71971901bae6483f Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 3 Sep 2026 07:26:56 -0600 Subject: [PATCH] Add persistent USB output mode selection --- CMakeLists.txt | 17 +- adapter_configuration.cpp | 113 +++- adapter_configuration.h | 24 +- adapter_host_probe.cpp | 52 +- adapter_host_probe.h | 6 +- adapter_mode_controller.cpp | 430 ++++++++++++ adapter_mode_controller.h | 34 + adapter_reboot.h | 7 + adapter_usb_mode.h | 24 +- bluepad32_input_backend.cpp | 53 +- bluepad32_input_backend.h | 22 +- configuration_service.cpp | 530 +++++++++++++-- configuration_service.h | 28 + src/switch_pico_bridge/config_manager.py | 338 +++++++++- switch-pico.cpp | 38 +- tests/adapter_host_probe_test.cpp | 193 ++++++ tests/adapter_mode_controller_test.cpp | 612 ++++++++++++++++++ tests/bluepad32_backend_lifecycle_test.cpp | 131 +++- tests/configuration_service_test.cpp | 520 +++++++++++++++ tests/configuration_storage_test.cpp | 122 +++- .../hardware/structs/watchdog.h | 9 + tests/mode_native_stubs/hardware/watchdog.h | 5 + tests/mode_native_stubs/pico/time.h | 12 + tests/mode_native_stubs/tusb.h | 33 + tests/test_adapter_host_probe_native.py | 32 + tests/test_adapter_mode_controller_native.py | 32 + ...test_bluepad32_backend_lifecycle_native.py | 2 +- tests/test_config_manager.py | 593 ++++++++++++++++- tests/test_configuration_service_native.py | 36 ++ tests/test_usb_output_driver_native.py | 2 +- tests/usb_configuration_management_test.cpp | 183 +++++- tests/usb_output_driver_test.cpp | 15 + usb_configuration_management.cpp | 54 +- usb_configuration_management.h | 2 + usb_output_driver.cpp | 49 +- 35 files changed, 4136 insertions(+), 217 deletions(-) create mode 100644 adapter_mode_controller.cpp create mode 100644 adapter_mode_controller.h create mode 100644 adapter_reboot.h create mode 100644 tests/adapter_host_probe_test.cpp create mode 100644 tests/adapter_mode_controller_test.cpp create mode 100644 tests/configuration_service_test.cpp create mode 100644 tests/mode_native_stubs/hardware/structs/watchdog.h create mode 100644 tests/mode_native_stubs/hardware/watchdog.h create mode 100644 tests/mode_native_stubs/pico/time.h create mode 100644 tests/mode_native_stubs/tusb.h create mode 100644 tests/test_adapter_host_probe_native.py create mode 100644 tests/test_adapter_mode_controller_native.py create mode 100644 tests/test_configuration_service_native.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 05b9f85..7c8e944 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,6 +100,8 @@ add_executable(switch-pico 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 @@ -115,19 +117,12 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") configuration_service.cpp pico_configuration_storage.cpp usb_configuration_management.cpp + xinput_driver.cpp ) - if(SWITCH_PICO_ADAPTER_FEASIBILITY) - target_sources(switch-pico PRIVATE - adapter_host_probe.cpp - xinput_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() @@ -162,9 +157,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/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 9464042..deb2c03 100644 --- a/adapter_host_probe.cpp +++ b/adapter_host_probe.cpp @@ -9,6 +9,7 @@ #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" @@ -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,39 @@ 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::kAuto: + g_mode = scratch == kXInputBootMagic + ? AdapterUsbMode::kXInput + : AdapterUsbMode::kSwitchProbe; + break; + case AdapterRequestedMode::kDInput: + case AdapterRequestedMode::kMac: + // Keep USB usable without treating unavailable explicit choices + // as Auto. Host/controller setters reject these until drivers land. + g_mode = AdapterUsbMode::kSwitch; + break; } g_probe = {}; + g_reboot_alarm = 0; PROBE_LOG("[HOST PROBE] boot mode=%s\n", - g_mode == AdapterUsbMode::kXInput ? "XInput" : "Switch probe"); + g_mode == AdapterUsbMode::kXInput + ? "XInput" + : (g_mode == AdapterUsbMode::kSwitch + ? "Switch" + : "Switch probe")); } AdapterUsbMode adapter_host_probe_mode() { return g_mode; } @@ -105,8 +130,11 @@ bool adapter_host_probe_vendor_control(uint8_t rhport, uint8_t stage, return queued; } - return tud_control_xfer( - rhport, request, - const_cast(XInput::kMsCompatIdDescriptor), - sizeof(XInput::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..b978c5c --- /dev/null +++ b/adapter_mode_controller.cpp @@ -0,0 +1,430 @@ +#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: + case AdapterRequestedMode::kDInput: + case AdapterRequestedMode::kMac: + return AdapterRequestedMode::kAuto; + } + return AdapterRequestedMode::kAuto; +} + +uint8_t feedback_pulse_count(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 1; + } + return 1; +} + +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 pulses = feedback_pulse_count(g_operation.target_mode); + bluepad32_input_backend_queue_profile_feedback( + g_operation.slot, g_operation.connection_generation, pulses, + ControllerProfileConfirmationPolicy::kRumbleAndLed); + g_operation.feedback_deadline_ms = + now_ms + static_cast(pulses) * 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, false, false}; + 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..55a4415 100644 --- a/adapter_usb_mode.h +++ b/adapter_usb_mode.h @@ -2,9 +2,27 @@ #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, +}; + +// 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/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index aa71e66..95f9c14 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 @@ -28,8 +28,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 +64,21 @@ 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[:3] +ACTIVE_MODE_SWITCH = 0 +ACTIVE_MODE_SWITCH_PROBE = 1 +ACTIVE_MODE_XINPUT = 2 +ACTIVE_MODE_NAMES = ("Switch", "Switch probe", "XInput") PAIRING_RECORD_SIZE = 8 PAIRING_RECORD_CAPACITY = 16 TRANSPORT_UNKNOWN = 0 @@ -123,6 +137,7 @@ class ConfigManagerError(RuntimeError): class UsbDevice(Protocol): bus: int | None address: int | None + port_numbers: tuple[int, ...] | None def ctrl_transfer( self, @@ -154,12 +169,21 @@ class DeviceInfo: active_mode: 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 + @dataclass(frozen=True) class AdapterConfiguration: pairing_window_seconds: int generation: int crc: int + requested_mode: int = REQUESTED_MODE_AUTO @dataclass(frozen=True) @@ -1168,6 +1192,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") @@ -1267,8 +1295,11 @@ def _control_out( def read_info(device: UsbDevice) -> DeviceInfo: envelope = _control_in(device, OP_INFO) _raise_status(envelope) - if len(envelope.payload) != 8: + if len(envelope.payload) != 8 or envelope.payload[5] != 0: raise ConfigManagerError("invalid device-info payload") + active_mode = envelope.payload[4] + if active_mode >= len(ACTIVE_MODE_NAMES): + raise ConfigManagerError(f"unknown active USB mode {active_mode}") return DeviceInfo( firmware_version=( envelope.payload[0], @@ -1276,7 +1307,7 @@ def read_info(device: UsbDevice) -> DeviceInfo: envelope.payload[2], ), board=envelope.payload[3], - active_mode=envelope.payload[4], + active_mode=active_mode, 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 +1377,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, + ): + 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 + return False + + def parse_profile_list(envelope: Envelope) -> tuple[ProfileListEntry, ...]: _raise_status(envelope) if envelope.schema_version not in ( @@ -1683,6 +1764,220 @@ 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, + ): + 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 +2075,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 +2092,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 +2197,36 @@ 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"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 +2237,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 3114caa..58d695d 100644 --- a/switch-pico.cpp +++ b/switch-pico.cpp @@ -7,12 +7,10 @@ #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" -#endif +#include "controller_profile_runtime.h" #endif #ifdef SWITCH_PICO_LOG @@ -191,14 +189,9 @@ static void log_usb_state() { 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", usb_output_driver_name(), instance, ready ? "ready" : "not ready"); -#else - LOG_PRINTF("[SWITCH %u] driver %s\n", instance, - ready ? "ready (handshake OK)" : "not ready"); -#endif } } #else @@ -218,19 +211,12 @@ 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 -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - usb_output_driver_init(adapter_host_probe_mode()); -#else - usb_output_driver_init(AdapterUsbMode::kSwitchProbe); -#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) { @@ -253,12 +239,8 @@ int main() { #ifdef SWITCH_PICO_BLUEPAD32 bluepad32_input_backend_start(); -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY - LOG_PRINTF("[BOOT] adapter feasibility mode=%s\n", + LOG_PRINTF("[BOOT] adapter mode=%s\n", usb_output_driver_mode_name()); -#else - LOG_PRINTF("[BOOT] switch-pico starting (Bluepad32 wireless @ 115200)\n"); -#endif #else LOG_PRINTF("[BOOT] switch-pico starting (UART0 log @ 115200)\n"); LOG_PRINTF("[INFO] UART1 pins TX=%d RX=%d baud=%d\n", @@ -274,7 +256,7 @@ 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; @@ -286,6 +268,11 @@ int main() { 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); @@ -318,6 +305,7 @@ int main() { bluepad32_input_backend_report_sent(instance); } } + adapter_mode_controller_task(now_ms); #else bool new_data = poll_uart_frames(); // Pull controller state from UART1 (void)new_data; diff --git a/tests/adapter_host_probe_test.cpp b/tests/adapter_host_probe_test.cpp new file mode 100644 index 0000000..a8af541 --- /dev/null +++ b/tests/adapter_host_probe_test.cpp @@ -0,0 +1,193 @@ +#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::kSwitch && + watchdog_registers.scratch[0] == 0 && alarm_count == 0, + "unavailable DInput was selected or treated as Auto"); +} + +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..fcc4971 --- /dev/null +++ b/tests/adapter_mode_controller_test.cpp @@ -0,0 +1,612 @@ +#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 implemented-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::kAuto, + "XInput did not cycle to Auto"); + require(triggered_target(AdapterRequestedMode::kDInput) == + AdapterRequestedMode::kAuto, + "unimplemented configured value entered the chord cycle"); + + 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_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 advertised unavailable 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_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..5ca525a --- /dev/null +++ b/tests/configuration_service_test.cpp @@ -0,0 +1,520 @@ +#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{}; + 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; + require(configuration_service_set_mode( + 11, AdapterRequestedMode::kDInput, implemented) == + 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{}; + 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/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/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..f5129cb 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -36,15 +36,23 @@ 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( + ("stored_mode", "active_mode", "message"), + ( + ( + config_manager.REQUESTED_MODE_SWITCH, + config_manager.ACTIVE_MODE_SWITCH_PROBE, + "activated", + ), + ( + config_manager.REQUESTED_MODE_AUTO, + config_manager.ACTIVE_MODE_SWITCH, + "was not stored", + ), + ), +) +def test_mode_verifies_requested_and_active_state_after_reenumeration( + monkeypatch: pytest.MonkeyPatch, + 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 +1200,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 +1471,8 @@ 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 config_manager.main(["pairings", "list"]) == 0 output = capsys.readouterr().out @@ -993,6 +1485,95 @@ def test_status_and_pairing_commands( assert capsys.readouterr().out == "Cleared 2 stored pairing(s).\n" +def test_config_cli_preserves_requested_mode( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + device = FakeDevice() + device.configuration = struct.pack( + " None: + previous = FakeDevice() + reenumerated = FakeDevice() + reenumerated.address = 8 + reenumerated.configuration = struct.pack( + " None: + for mode in ("dinput", "mac"): + with pytest.raises(SystemExit): + config_manager.build_parser().parse_args(["mode", mode]) + + +def test_candidate_discovery_checks_switch_and_xinput_identities( + monkeypatch: pytest.MonkeyPatch, +) -> None: + lookups: list[tuple[int, int]] = [] + + 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_usb_output_driver_native.py b/tests/test_usb_output_driver_native.py index 3199001..0c91f77 100644 --- a/tests/test_usb_output_driver_native.py +++ b/tests/test_usb_output_driver_native.py @@ -33,7 +33,7 @@ def test_usb_output_driver_contracts(tmp_path: Path) -> None: "-Werror", "-pedantic", f"-DSWITCH_PICO_HID_INSTANCE_COUNT={instance_count}", - "-DSWITCH_PICO_ADAPTER_FEASIBILITY=1", + "-DSWITCH_PICO_USB_OUTPUT_MODES=1", *backend_definitions, f"-I{root / 'tests' / 'native_stubs'}", f"-I{root}", diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index d46f904..a02f3b2 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -14,6 +14,18 @@ ConfigurationServiceSnapshot current_configuration{}; ProfileServiceListSnapshot current_profile_list{}; ProfileServiceSelectedSnapshot current_profile_selected{}; ProfileServiceTransactionSnapshot current_profile_transaction{}; +AdapterUsbMode current_active_mode = AdapterUsbMode::kSwitchProbe; +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{}; +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; @@ -233,6 +245,142 @@ void test_vendor_requests() { "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), + "info response did not report the active USB mode"); + + 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 unavailable : { + AdapterRequestedMode::kDInput, + AdapterRequestedMode::kMac, + }) { + mode_set[4] = static_cast(unavailable); + perform_out(Operation::kModeSet, mode_set, false); + } + require(mode_set_call_count == calls_before_invalid + 2 && + mode_availability_query_count == + availability_queries_before_invalid + 2, + "unsupported mode 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{}; @@ -448,6 +596,32 @@ 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; } + +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 +695,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 +705,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 +740,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_output_driver_test.cpp b/tests/usb_output_driver_test.cpp index 3efb0a5..7332e34 100644 --- a/tests/usb_output_driver_test.cpp +++ b/tests/usb_output_driver_test.cpp @@ -289,6 +289,7 @@ void expect_usb_string(uint8_t index, const char* expected, } void test_switch_boundary_dispatch() { + reset_usb_harness(); usb_output_driver_init(AdapterUsbMode::kSwitchProbe); expect(usb_output_driver_mode() == AdapterUsbMode::kSwitchProbe && @@ -373,6 +374,19 @@ void test_switch_boundary_dispatch() { 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"); +} void open_xinput_interfaces(usbd_class_driver_t const* driver) { endpoint_harness = {}; @@ -671,6 +685,7 @@ int main() { test_rumble_report(); test_host_probe_sequence(); test_switch_boundary_dispatch(); + test_manual_switch_selection(); test_xinput_boundary_dispatch(); test_vendor_control_boundary(); return failures == 0 ? 0 : 1; diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp index 967e887..ff24aea 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,11 +152,7 @@ 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 + static_cast(usb_output_driver_mode()), 0, static_cast(CONFIGURATION_STORAGE_MAX_PAYLOAD_SIZE), static_cast( @@ -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]) | @@ -482,11 +518,9 @@ bool process_out_request() { 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 f42e4d1..bb223dc 100644 --- a/usb_configuration_management.h +++ b/usb_configuration_management.h @@ -30,6 +30,8 @@ static_assert(kMaximumResponseSize == 293, enum class Operation : uint8_t { kInfo = 0x01, + kModeSet = 0x02, + kReboot = 0x03, kConfigurationRead = 0x10, kConfigurationBegin = 0x11, kConfigurationChunk = 0x12, diff --git a/usb_output_driver.cpp b/usb_output_driver.cpp index f5d6018..83c1a21 100644 --- a/usb_output_driver.cpp +++ b/usb_output_driver.cpp @@ -12,7 +12,7 @@ #include "usb_configuration_management.h" #endif -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES #include "adapter_host_probe.h" #include "xinput_descriptors.h" #include "xinput_driver.h" @@ -26,11 +26,11 @@ namespace { -AdapterUsbMode g_mode = AdapterUsbMode::kSwitchProbe; +AdapterUsbMode g_mode = AdapterUsbMode::kSwitch; uint16_t g_string_descriptor[32]{}; bool xinput_selected() { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES return g_mode == AdapterUsbMode::kXInput; #else return false; @@ -40,16 +40,16 @@ bool xinput_selected() { } // namespace void usb_output_driver_init(AdapterUsbMode mode) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES g_mode = mode; #else (void)mode; - g_mode = AdapterUsbMode::kSwitchProbe; + g_mode = AdapterUsbMode::kSwitch; #endif for (uint8_t instance = 0; instance < SWITCH_PICO_HID_INSTANCE_COUNT; ++instance) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { xinput_init(instance); } else @@ -61,7 +61,15 @@ void usb_output_driver_init(AdapterUsbMode mode) { } const char* usb_output_driver_mode_name() { - return xinput_selected() ? "XInput" : "Switch probe"; + switch (g_mode) { + case AdapterUsbMode::kSwitch: + return "Switch"; + case AdapterUsbMode::kSwitchProbe: + return "Switch probe"; + case AdapterUsbMode::kXInput: + return "XInput"; + } + return "Switch"; } AdapterUsbMode usb_output_driver_mode() { @@ -76,7 +84,7 @@ 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_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { xinput_set_input(instance, state); return; @@ -87,7 +95,7 @@ void usb_output_driver_set_input(uint8_t instance, } bool usb_output_driver_task(uint8_t instance) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { return xinput_task(instance); } @@ -96,7 +104,7 @@ bool usb_output_driver_task(uint8_t instance) { } bool usb_output_driver_is_ready(uint8_t instance) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { return xinput_is_ready(instance); } @@ -106,7 +114,7 @@ bool usb_output_driver_is_ready(uint8_t instance) { void usb_output_driver_set_rumble_callback( uint8_t instance, ControllerRumbleCallback callback) { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { xinput_set_rumble_callback(instance, callback); return; @@ -151,19 +159,20 @@ extern "C" uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) { } extern "C" uint8_t const* tud_descriptor_device_cb() { -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { return XInput::kDeviceDescriptor; } - return XInput::kSwitchProbeDeviceDescriptor; -#else - return switch_pro_device_descriptor; + 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_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { return XInput::kConfigurationDescriptor; } @@ -175,9 +184,9 @@ extern "C" uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) { (void)langid; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES adapter_host_probe_note_string_descriptor(index); - if (index == 0xee) { + if (index == 0xee && g_mode != AdapterUsbMode::kSwitch) { static constexpr char kSignature[] = "MSFT100"; for (uint8_t i = 0; i < sizeof(kSignature) - 1; ++i) { g_string_descriptor[1 + i] = kSignature[i]; @@ -195,7 +204,7 @@ extern "C" uint16_t const* tud_descriptor_string_cb(uint8_t index, character_count = 1; } else { const uint8_t* string = nullptr; -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#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"; @@ -279,7 +288,7 @@ extern "C" usbd_class_driver_t const* usbd_app_driver_get_cb( if (driver_count == nullptr) { return nullptr; } -#ifdef SWITCH_PICO_ADAPTER_FEASIBILITY +#ifdef SWITCH_PICO_USB_OUTPUT_MODES if (xinput_selected()) { *driver_count = 1; return xinput_class_driver();